| … | … | @@ -0,0 +1,3 @@ |
| 1 | dist/ | |
| 2 | *.pyc | |
| 3 | __pycache__/ |
| … | … | @@ -0,0 +1,20 @@ |
| 1 | # kodi-jacktook | |
| 2 | ||
| 3 | Patched fork of `plugin.video.jacktook` for the LibreELEC box (192.168.50.192), | |
| 4 | carrying 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 | ||
| 18 | Bump `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. | |
| 20 | The `kodi-repo` user unit serves `dist/` to the Pi over LAN HTTP. |
| … | … | @@ -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. | |
| 13 | set -euo pipefail | |
| 14 | cd "$(dirname "$0")" | |
| 15 | ||
| 16 | ID="plugin.video.jacktook" | |
| 17 | VER=$(python3 -c "import xml.etree.ElementTree as ET;print(ET.parse('$ID/addon.xml').getroot().attrib['version'])") | |
| 18 | OUT="dist" | |
| 19 | ZIPDIR="$OUT/zips/$ID" | |
| 20 | ||
| 21 | mkdir -p "$OUT/packages" "$ZIPDIR" | |
| 22 | ||
| 23 | # Stale bytecode must never ship inside the package. | |
| 24 | find "$ID" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true | |
| 25 | ||
| 26 | rm -f "$ZIPDIR/$ID-$VER.zip" | |
| 27 | zip -qr "$ZIPDIR/$ID-$VER.zip" "$ID" -x '*.pyc' -x '*__pycache__*' -x '*.git*' | |
| 28 | ||
| 29 | printf '%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 | ||
| 33 | python3 - "$ZIPDIR" <<'PY' | |
| 34 | import json, os, sys | |
| 35 | d = sys.argv[1] | |
| 36 | names = sorted(f for f in os.listdir(d) if f.endswith(".zip")) | |
| 37 | json.dump([{"name": n} for n in names], open(os.path.join(d, "index.json"), "w")) | |
| 38 | print("index.json ->", names) | |
| 39 | PY | |
| 40 | ||
| 41 | echo "built $ID $VER" | |
| 42 | ls -la "$ZIPDIR" |
| … | … | @@ -0,0 +1,195 @@ |
| 1 | # AGENTS.md | |
| 2 | ||
| 3 | Guidance 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 | ||
| 40 | Use `python3` commands from repo root. | |
| 41 | ||
| 42 | ```bash | |
| 43 | python3 -m pip install -r requirements-test.txt | |
| 44 | ``` | |
| 45 | ||
| 46 | Optional 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 | ||
| 50 | There is no traditional compile/build pipeline. Packaging is done via script: | |
| 51 | ||
| 52 | ```bash | |
| 53 | python3 scripts/create_release.py | |
| 54 | ``` | |
| 55 | ||
| 56 | Notes: | |
| 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 | ||
| 63 | Run tests from repository root. | |
| 64 | ||
| 65 | ### Run full suite | |
| 66 | ||
| 67 | ```bash | |
| 68 | python3 -m pytest -q | |
| 69 | ``` | |
| 70 | ||
| 71 | ### Run a single file | |
| 72 | ||
| 73 | ```bash | |
| 74 | python3 -m pytest tests/unit/test_jackett.py -q | |
| 75 | ``` | |
| 76 | ||
| 77 | ### Run a single test (node id) | |
| 78 | ||
| 79 | ```bash | |
| 80 | python3 -m pytest tests/unit/test_general_utils.py::test_is_url -q | |
| 81 | ``` | |
| 82 | ||
| 83 | ### Run tests by keyword expression | |
| 84 | ||
| 85 | ```bash | |
| 86 | python3 -m pytest -k "jackett and search_movie" tests/unit -q | |
| 87 | ``` | |
| 88 | ||
| 89 | ### Run with coverage | |
| 90 | ||
| 91 | ```bash | |
| 92 | python3 -m pytest --cov=lib --cov-report=term-missing | |
| 93 | ``` | |
| 94 | ||
| 95 | ### Collect-only (debug discovery) | |
| 96 | ||
| 97 | ```bash | |
| 98 | python3 -m pytest --collect-only -q | |
| 99 | ``` | |
| 100 | ||
| 101 | ### Manual/integration helper scripts | |
| 102 | ||
| 103 | ```bash | |
| 104 | python3 tests/manual_easynews_test.py | |
| 105 | python3 tests/verify_debrid.py | |
| 106 | ``` | |
| 107 | ||
| 108 | Notes: | |
| 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 | ||
| 114 | No dedicated linter config (`pyproject.toml`, `setup.cfg`, `tox.ini`, `pytest.ini`) is present. | |
| 115 | ||
| 116 | Recommended lightweight checks: | |
| 117 | ||
| 118 | ```bash | |
| 119 | python3 -m py_compile jacktook.py service.py | |
| 120 | python3 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 | ||
| 134 | Follow 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. |
| … | … | @@ -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 | ||
| 10 | Spanish: | |
| 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 | ||
| 25 | Spanish: | |
| 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 | ||
| 42 | Spanish: | |
| 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 | ||
| 63 | Spanish: | |
| 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 | ||
| 81 | Spanish: | |
| 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 | ||
| 99 | Spanish: | |
| 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 | ||
| 116 | Spanish: | |
| 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 | ||
| 132 | Spanish: | |
| 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 | ||
| 147 | Spanish: | |
| 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 | ||
| 158 | Spanish: | |
| 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 | ||
| 173 | Spanish: | |
| 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 | ||
| 189 | Spanish: | |
| 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 | ||
| 204 | Spanish: | |
| 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 | ||
| 218 | Spanish: | |
| 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 | ||
| 228 | Spanish: | |
| 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 | ||
| 240 | Spanish: | |
| 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 | ||
| 257 | Spanish: | |
| 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 | ||
| 272 | Spanish: | |
| 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 | ||
| 282 | Spanish: | |
| 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 | ||
| 295 | Spanish: | |
| 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 | ||
| 319 | Spanish: | |
| 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 | ||
| 366 | Spanish: | |
| 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 | ||
| 398 | Spanish: | |
| 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 | ||
| 443 | Spanish: | |
| 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 | ||
| 451 | Note: | |
| 452 | Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias! | |
| 453 | ||
| 454 | If 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 | ||
| 463 | Spanish: | |
| 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 | ||
| 470 | Note: | |
| 471 | Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias! | |
| 472 | ||
| 473 | If 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 | ||
| 507 | Note: | |
| 508 | Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias! | |
| 509 | ||
| 510 | If 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 | ||
| 520 | Note: | |
| 521 | Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias! | |
| 522 | ||
| 523 | If 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 | ||
| 538 | Spanish: | |
| 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 | ||
| 551 | Note: | |
| 552 | Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias! | |
| 553 | ||
| 554 | If 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 | ||
| 565 | Spanish: | |
| 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 | ||
| 574 | Note: | |
| 575 | Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias! | |
| 576 | ||
| 577 | If 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 | ||
| 595 | Spanish: | |
| 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 | ||
| 606 | Note: | |
| 607 | Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias! | |
| 608 | ||
| 609 | If 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 | ||
| 618 | Note: | |
| 619 | Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias! | |
| 620 | ||
| 621 | If 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 | ||
| 625 | Features: | |
| 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 |
| … | … | @@ -0,0 +1,674 @@ |
| 1 | GNU 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 | |
| 11 | software and other kinds of works. | |
| 12 | ||
| 13 | The licenses for most software and other practical works are designed | |
| 14 | to take away your freedom to share and change the works. By contrast, | |
| 15 | the GNU General Public License is intended to guarantee your freedom to | |
| 16 | share and change all versions of a program--to make sure it remains free | |
| 17 | software for all its users. We, the Free Software Foundation, use the | |
| 18 | GNU General Public License for most of our software; it applies also to | |
| 19 | any other work released this way by its authors. You can apply it to | |
| 20 | your programs, too. | |
| 21 | ||
| 22 | When we speak of free software, we are referring to freedom, not | |
| 23 | price. Our General Public Licenses are designed to make sure that you | |
| 24 | have the freedom to distribute copies of free software (and charge for | |
| 25 | them if you wish), that you receive source code or can get it if you | |
| 26 | want it, that you can change the software or use pieces of it in new | |
| 27 | free programs, and that you know you can do these things. | |
| 28 | ||
| 29 | To protect your rights, we need to prevent others from denying you | |
| 30 | these rights or asking you to surrender the rights. Therefore, you have | |
| 31 | certain responsibilities if you distribute copies of the software, or if | |
| 32 | you modify it: responsibilities to respect the freedom of others. | |
| 33 | ||
| 34 | For example, if you distribute copies of such a program, whether | |
| 35 | gratis or for a fee, you must pass on to the recipients the same | |
| 36 | freedoms that you received. You must make sure that they, too, receive | |
| 37 | or can get the source code. And you must show them these terms so they | |
| 38 | know 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 | |
| 42 | giving you legal permission to copy, distribute and/or modify it. | |
| 43 | ||
| 44 | For the developers' and authors' protection, the GPL clearly explains | |
| 45 | that there is no warranty for this free software. For both users' and | |
| 46 | authors' sake, the GPL requires that modified versions be marked as | |
| 47 | changed, so that their problems will not be attributed erroneously to | |
| 48 | authors of previous versions. | |
| 49 | ||
| 50 | Some devices are designed to deny users access to install or run | |
| 51 | modified versions of the software inside them, although the manufacturer | |
| 52 | can do so. This is fundamentally incompatible with the aim of | |
| 53 | protecting users' freedom to change the software. The systematic | |
| 54 | pattern of such abuse occurs in the area of products for individuals to | |
| 55 | use, which is precisely where it is most unacceptable. Therefore, we | |
| 56 | have designed this version of the GPL to prohibit the practice for those | |
| 57 | products. If such problems arise substantially in other domains, we | |
| 58 | stand ready to extend this provision to those domains in future versions | |
| 59 | of the GPL, as needed to protect the freedom of users. | |
| 60 | ||
| 61 | Finally, every program is threatened constantly by software patents. | |
| 62 | States should not allow patents to restrict development and use of | |
| 63 | software on general-purpose computers, but in those that do, we wish to | |
| 64 | avoid the special danger that patents applied to a free program could | |
| 65 | make it effectively proprietary. To prevent this, the GPL assures that | |
| 66 | patents cannot be used to render the program non-free. | |
| 67 | ||
| 68 | The precise terms and conditions for copying, distribution and | |
| 69 | modification 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 | |
| 78 | works, such as semiconductor masks. | |
| 79 | ||
| 80 | "The Program" refers to any copyrightable work licensed under this | |
| 81 | License. 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 | |
| 85 | in a fashion requiring copyright permission, other than the making of an | |
| 86 | exact copy. The resulting work is called a "modified version" of the | |
| 87 | earlier work or a work "based on" the earlier work. | |
| 88 | ||
| 89 | A "covered work" means either the unmodified Program or a work based | |
| 90 | on the Program. | |
| 91 | ||
| 92 | To "propagate" a work means to do anything with it that, without | |
| 93 | permission, would make you directly or secondarily liable for | |
| 94 | infringement under applicable copyright law, except executing it on a | |
| 95 | computer or modifying a private copy. Propagation includes copying, | |
| 96 | distribution (with or without modification), making available to the | |
| 97 | public, and in some countries other activities as well. | |
| 98 | ||
| 99 | To "convey" a work means any kind of propagation that enables other | |
| 100 | parties to make or receive copies. Mere interaction with a user through | |
| 101 | a computer network, with no transfer of a copy, is not conveying. | |
| 102 | ||
| 103 | An interactive user interface displays "Appropriate Legal Notices" | |
| 104 | to the extent that it includes a convenient and prominently visible | |
| 105 | feature that (1) displays an appropriate copyright notice, and (2) | |
| 106 | tells the user that there is no warranty for the work (except to the | |
| 107 | extent that warranties are provided), that licensees may convey the | |
| 108 | work under this License, and how to view a copy of this License. If | |
| 109 | the interface presents a list of user commands or options, such as a | |
| 110 | menu, 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 | |
| 115 | for making modifications to it. "Object code" means any non-source | |
| 116 | form of a work. | |
| 117 | ||
| 118 | A "Standard Interface" means an interface that either is an official | |
| 119 | standard defined by a recognized standards body, or, in the case of | |
| 120 | interfaces specified for a particular programming language, one that | |
| 121 | is widely used among developers working in that language. | |
| 122 | ||
| 123 | The "System Libraries" of an executable work include anything, other | |
| 124 | than the work as a whole, that (a) is included in the normal form of | |
| 125 | packaging a Major Component, but which is not part of that Major | |
| 126 | Component, and (b) serves only to enable use of the work with that | |
| 127 | Major Component, or to implement a Standard Interface for which an | |
| 128 | implementation 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 | |
| 132 | produce 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 | |
| 135 | the source code needed to generate, install, and (for an executable | |
| 136 | work) run the object code and to modify the work, including scripts to | |
| 137 | control those activities. However, it does not include the work's | |
| 138 | System Libraries, or general-purpose tools or generally available free | |
| 139 | programs which are used unmodified in performing those activities but | |
| 140 | which are not part of the work. For example, Corresponding Source | |
| 141 | includes interface definition files associated with source files for | |
| 142 | the work, and the source code for shared libraries and dynamically | |
| 143 | linked subprograms that the work is specifically designed to require, | |
| 144 | such as by intimate data communication or control flow between those | |
| 145 | subprograms and other parts of the work. | |
| 146 | ||
| 147 | The Corresponding Source need not include anything that users | |
| 148 | can regenerate automatically from other parts of the Corresponding | |
| 149 | Source. | |
| 150 | ||
| 151 | The Corresponding Source for a work in source code form is that | |
| 152 | same work. | |
| 153 | ||
| 154 | 2. Basic Permissions. | |
| 155 | ||
| 156 | All rights granted under this License are granted for the term of | |
| 157 | copyright on the Program, and are irrevocable provided the stated | |
| 158 | conditions are met. This License explicitly affirms your unlimited | |
| 159 | permission to run the unmodified Program. The output from running a | |
| 160 | covered work is covered by this License only if the output, given its | |
| 161 | content, constitutes a covered work. This License acknowledges your | |
| 162 | rights 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 | |
| 165 | convey, without conditions so long as your license otherwise remains | |
| 166 | in force. You may convey covered works to others for the sole purpose | |
| 167 | of having them make modifications exclusively for you, or provide you | |
| 168 | with facilities for running those works, provided that you comply with | |
| 169 | the terms of this License in conveying all material for which you do | |
| 170 | not control copyright. Those thus making or running the covered works | |
| 171 | for you must do so exclusively on your behalf, under your direction | |
| 172 | and control, on terms that prohibit them from making any copies of | |
| 173 | your copyrighted material outside their relationship with you. | |
| 174 | ||
| 175 | Conveying under any other circumstances is permitted solely under | |
| 176 | the conditions stated below. Sublicensing is not allowed; section 10 | |
| 177 | makes 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 | |
| 182 | measure under any applicable law fulfilling obligations under article | |
| 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or | |
| 184 | similar laws prohibiting or restricting circumvention of such | |
| 185 | measures. | |
| 186 | ||
| 187 | When you convey a covered work, you waive any legal power to forbid | |
| 188 | circumvention of technological measures to the extent such circumvention | |
| 189 | is effected by exercising rights under this License with respect to | |
| 190 | the covered work, and you disclaim any intention to limit operation or | |
| 191 | modification of the work as a means of enforcing, against the work's | |
| 192 | users, your or third parties' legal rights to forbid circumvention of | |
| 193 | technological measures. | |
| 194 | ||
| 195 | 4. Conveying Verbatim Copies. | |
| 196 | ||
| 197 | You may convey verbatim copies of the Program's source code as you | |
| 198 | receive it, in any medium, provided that you conspicuously and | |
| 199 | appropriately publish on each copy an appropriate copyright notice; | |
| 200 | keep intact all notices stating that this License and any | |
| 201 | non-permissive terms added in accord with section 7 apply to the code; | |
| 202 | keep intact all notices of the absence of any warranty; and give all | |
| 203 | recipients 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, | |
| 206 | and 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 | |
| 211 | produce it from the Program, in the form of source code under the | |
| 212 | terms 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 | |
| 236 | works, which are not by their nature extensions of the covered work, | |
| 237 | and which are not combined with it such as to form a larger program, | |
| 238 | in or on a volume of a storage or distribution medium, is called an | |
| 239 | "aggregate" if the compilation and its resulting copyright are not | |
| 240 | used to limit the access or legal rights of the compilation's users | |
| 241 | beyond what the individual works permit. Inclusion of a covered work | |
| 242 | in an aggregate does not cause this License to apply to the other | |
| 243 | parts 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 | |
| 248 | of sections 4 and 5, provided that you also convey the | |
| 249 | machine-readable Corresponding Source under the terms of this License, | |
| 250 | in 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 | |
| 294 | from the Corresponding Source as a System Library, need not be | |
| 295 | included in conveying the object code work. | |
| 296 | ||
| 297 | A "User Product" is either (1) a "consumer product", which means any | |
| 298 | tangible personal property which is normally used for personal, family, | |
| 299 | or household purposes, or (2) anything designed or sold for incorporation | |
| 300 | into a dwelling. In determining whether a product is a consumer product, | |
| 301 | doubtful cases shall be resolved in favor of coverage. For a particular | |
| 302 | product received by a particular user, "normally used" refers to a | |
| 303 | typical or common use of that class of product, regardless of the status | |
| 304 | of the particular user or of the way in which the particular user | |
| 305 | actually uses, or expects or is expected to use, the product. A product | |
| 306 | is a consumer product regardless of whether the product has substantial | |
| 307 | commercial, industrial or non-consumer uses, unless such uses represent | |
| 308 | the only significant mode of use of the product. | |
| 309 | ||
| 310 | "Installation Information" for a User Product means any methods, | |
| 311 | procedures, authorization keys, or other information required to install | |
| 312 | and execute modified versions of a covered work in that User Product from | |
| 313 | a modified version of its Corresponding Source. The information must | |
| 314 | suffice to ensure that the continued functioning of the modified object | |
| 315 | code is in no case prevented or interfered with solely because | |
| 316 | modification has been made. | |
| 317 | ||
| 318 | If you convey an object code work under this section in, or with, or | |
| 319 | specifically for use in, a User Product, and the conveying occurs as | |
| 320 | part of a transaction in which the right of possession and use of the | |
| 321 | User Product is transferred to the recipient in perpetuity or for a | |
| 322 | fixed term (regardless of how the transaction is characterized), the | |
| 323 | Corresponding Source conveyed under this section must be accompanied | |
| 324 | by the Installation Information. But this requirement does not apply | |
| 325 | if neither you nor any third party retains the ability to install | |
| 326 | modified object code on the User Product (for example, the work has | |
| 327 | been installed in ROM). | |
| 328 | ||
| 329 | The requirement to provide Installation Information does not include a | |
| 330 | requirement to continue to provide support service, warranty, or updates | |
| 331 | for a work that has been modified or installed by the recipient, or for | |
| 332 | the User Product in which it has been modified or installed. Access to a | |
| 333 | network may be denied when the modification itself materially and | |
| 334 | adversely affects the operation of the network or violates the rules and | |
| 335 | protocols for communication across the network. | |
| 336 | ||
| 337 | Corresponding Source conveyed, and Installation Information provided, | |
| 338 | in accord with this section must be in a format that is publicly | |
| 339 | documented (and with an implementation available to the public in | |
| 340 | source code form), and must require no special password or key for | |
| 341 | unpacking, reading or copying. | |
| 342 | ||
| 343 | 7. Additional Terms. | |
| 344 | ||
| 345 | "Additional permissions" are terms that supplement the terms of this | |
| 346 | License by making exceptions from one or more of its conditions. | |
| 347 | Additional permissions that are applicable to the entire Program shall | |
| 348 | be treated as though they were included in this License, to the extent | |
| 349 | that they are valid under applicable law. If additional permissions | |
| 350 | apply only to part of the Program, that part may be used separately | |
| 351 | under those permissions, but the entire Program remains governed by | |
| 352 | this License without regard to the additional permissions. | |
| 353 | ||
| 354 | When you convey a copy of a covered work, you may at your option | |
| 355 | remove any additional permissions from that copy, or from any part of | |
| 356 | it. (Additional permissions may be written to require their own | |
| 357 | removal in certain cases when you modify the work.) You may place | |
| 358 | additional permissions on material, added by you to a covered work, | |
| 359 | for which you have or can give appropriate copyright permission. | |
| 360 | ||
| 361 | Notwithstanding any other provision of this License, for material you | |
| 362 | add to a covered work, you may (if authorized by the copyright holders of | |
| 363 | that 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 | |
| 389 | restrictions" within the meaning of section 10. If the Program as you | |
| 390 | received it, or any part of it, contains a notice stating that it is | |
| 391 | governed by this License along with a term that is a further | |
| 392 | restriction, you may remove that term. If a license document contains | |
| 393 | a further restriction but permits relicensing or conveying under this | |
| 394 | License, you may add to a covered work material governed by the terms | |
| 395 | of that license document, provided that the further restriction does | |
| 396 | not survive such relicensing or conveying. | |
| 397 | ||
| 398 | If you add terms to a covered work in accord with this section, you | |
| 399 | must place, in the relevant source files, a statement of the | |
| 400 | additional terms that apply to those files, or a notice indicating | |
| 401 | where to find the applicable terms. | |
| 402 | ||
| 403 | Additional terms, permissive or non-permissive, may be stated in the | |
| 404 | form of a separately written license, or stated as exceptions; | |
| 405 | the above requirements apply either way. | |
| 406 | ||
| 407 | 8. Termination. | |
| 408 | ||
| 409 | You may not propagate or modify a covered work except as expressly | |
| 410 | provided under this License. Any attempt otherwise to propagate or | |
| 411 | modify it is void, and will automatically terminate your rights under | |
| 412 | this License (including any patent licenses granted under the third | |
| 413 | paragraph of section 11). | |
| 414 | ||
| 415 | However, if you cease all violation of this License, then your | |
| 416 | license from a particular copyright holder is reinstated (a) | |
| 417 | provisionally, unless and until the copyright holder explicitly and | |
| 418 | finally terminates your license, and (b) permanently, if the copyright | |
| 419 | holder fails to notify you of the violation by some reasonable means | |
| 420 | prior to 60 days after the cessation. | |
| 421 | ||
| 422 | Moreover, your license from a particular copyright holder is | |
| 423 | reinstated permanently if the copyright holder notifies you of the | |
| 424 | violation by some reasonable means, this is the first time you have | |
| 425 | received notice of violation of this License (for any work) from that | |
| 426 | copyright holder, and you cure the violation prior to 30 days after | |
| 427 | your receipt of the notice. | |
| 428 | ||
| 429 | Termination of your rights under this section does not terminate the | |
| 430 | licenses of parties who have received copies or rights from you under | |
| 431 | this License. If your rights have been terminated and not permanently | |
| 432 | reinstated, you do not qualify to receive new licenses for the same | |
| 433 | material 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 | |
| 438 | run a copy of the Program. Ancillary propagation of a covered work | |
| 439 | occurring solely as a consequence of using peer-to-peer transmission | |
| 440 | to receive a copy likewise does not require acceptance. However, | |
| 441 | nothing other than this License grants you permission to propagate or | |
| 442 | modify any covered work. These actions infringe copyright if you do | |
| 443 | not accept this License. Therefore, by modifying or propagating a | |
| 444 | covered 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 | |
| 449 | receives a license from the original licensors, to run, modify and | |
| 450 | propagate that work, subject to this License. You are not responsible | |
| 451 | for enforcing compliance by third parties with this License. | |
| 452 | ||
| 453 | An "entity transaction" is a transaction transferring control of an | |
| 454 | organization, or substantially all assets of one, or subdividing an | |
| 455 | organization, or merging organizations. If propagation of a covered | |
| 456 | work results from an entity transaction, each party to that | |
| 457 | transaction who receives a copy of the work also receives whatever | |
| 458 | licenses to the work the party's predecessor in interest had or could | |
| 459 | give under the previous paragraph, plus a right to possession of the | |
| 460 | Corresponding Source of the work from the predecessor in interest, if | |
| 461 | the predecessor has it or can get it with reasonable efforts. | |
| 462 | ||
| 463 | You may not impose any further restrictions on the exercise of the | |
| 464 | rights granted or affirmed under this License. For example, you may | |
| 465 | not impose a license fee, royalty, or other charge for exercise of | |
| 466 | rights granted under this License, and you may not initiate litigation | |
| 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that | |
| 468 | any patent claim is infringed by making, using, selling, offering for | |
| 469 | sale, 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 | |
| 474 | License of the Program or a work on which the Program is based. The | |
| 475 | work thus licensed is called the contributor's "contributor version". | |
| 476 | ||
| 477 | A contributor's "essential patent claims" are all patent claims | |
| 478 | owned or controlled by the contributor, whether already acquired or | |
| 479 | hereafter acquired, that would be infringed by some manner, permitted | |
| 480 | by this License, of making, using, or selling its contributor version, | |
| 481 | but do not include claims that would be infringed only as a | |
| 482 | consequence of further modification of the contributor version. For | |
| 483 | purposes of this definition, "control" includes the right to grant | |
| 484 | patent sublicenses in a manner consistent with the requirements of | |
| 485 | this License. | |
| 486 | ||
| 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free | |
| 488 | patent license under the contributor's essential patent claims, to | |
| 489 | make, use, sell, offer for sale, import and otherwise run, modify and | |
| 490 | propagate the contents of its contributor version. | |
| 491 | ||
| 492 | In the following three paragraphs, a "patent license" is any express | |
| 493 | agreement or commitment, however denominated, not to enforce a patent | |
| 494 | (such as an express permission to practice a patent or covenant not to | |
| 495 | sue for patent infringement). To "grant" such a patent license to a | |
| 496 | party means to make such an agreement or commitment not to enforce a | |
| 497 | patent against the party. | |
| 498 | ||
| 499 | If you convey a covered work, knowingly relying on a patent license, | |
| 500 | and the Corresponding Source of the work is not available for anyone | |
| 501 | to copy, free of charge and under the terms of this License, through a | |
| 502 | publicly available network server or other readily accessible means, | |
| 503 | then you must either (1) cause the Corresponding Source to be so | |
| 504 | available, or (2) arrange to deprive yourself of the benefit of the | |
| 505 | patent license for this particular work, or (3) arrange, in a manner | |
| 506 | consistent with the requirements of this License, to extend the patent | |
| 507 | license to downstream recipients. "Knowingly relying" means you have | |
| 508 | actual knowledge that, but for the patent license, your conveying the | |
| 509 | covered work in a country, or your recipient's use of the covered work | |
| 510 | in a country, would infringe one or more identifiable patents in that | |
| 511 | country that you have reason to believe are valid. | |
| 512 | ||
| 513 | If, pursuant to or in connection with a single transaction or | |
| 514 | arrangement, you convey, or propagate by procuring conveyance of, a | |
| 515 | covered work, and grant a patent license to some of the parties | |
| 516 | receiving the covered work authorizing them to use, propagate, modify | |
| 517 | or convey a specific copy of the covered work, then the patent license | |
| 518 | you grant is automatically extended to all recipients of the covered | |
| 519 | work and works based on it. | |
| 520 | ||
| 521 | A patent license is "discriminatory" if it does not include within | |
| 522 | the scope of its coverage, prohibits the exercise of, or is | |
| 523 | conditioned on the non-exercise of one or more of the rights that are | |
| 524 | specifically granted under this License. You may not convey a covered | |
| 525 | work if you are a party to an arrangement with a third party that is | |
| 526 | in the business of distributing software, under which you make payment | |
| 527 | to the third party based on the extent of your activity of conveying | |
| 528 | the work, and under which the third party grants, to any of the | |
| 529 | parties who would receive the covered work from you, a discriminatory | |
| 530 | patent license (a) in connection with copies of the covered work | |
| 531 | conveyed by you (or copies made from those copies), or (b) primarily | |
| 532 | for and in connection with specific products or compilations that | |
| 533 | contain the covered work, unless you entered into that arrangement, | |
| 534 | or that patent license was granted, prior to 28 March 2007. | |
| 535 | ||
| 536 | Nothing in this License shall be construed as excluding or limiting | |
| 537 | any implied license or other defenses to infringement that may | |
| 538 | otherwise 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 | |
| 543 | otherwise) that contradict the conditions of this License, they do not | |
| 544 | excuse you from the conditions of this License. If you cannot convey a | |
| 545 | covered work so as to satisfy simultaneously your obligations under this | |
| 546 | License and any other pertinent obligations, then as a consequence you may | |
| 547 | not convey it at all. For example, if you agree to terms that obligate you | |
| 548 | to collect a royalty for further conveying from those to whom you convey | |
| 549 | the Program, the only way you could satisfy both those terms and this | |
| 550 | License 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 | |
| 555 | permission to link or combine any covered work with a work licensed | |
| 556 | under version 3 of the GNU Affero General Public License into a single | |
| 557 | combined work, and to convey the resulting work. The terms of this | |
| 558 | License will continue to apply to the part which is the covered work, | |
| 559 | but the special requirements of the GNU Affero General Public License, | |
| 560 | section 13, concerning interaction through a network will apply to the | |
| 561 | combination as such. | |
| 562 | ||
| 563 | 14. Revised Versions of this License. | |
| 564 | ||
| 565 | The Free Software Foundation may publish revised and/or new versions of | |
| 566 | the GNU General Public License from time to time. Such new versions will | |
| 567 | be similar in spirit to the present version, but may differ in detail to | |
| 568 | address new problems or concerns. | |
| 569 | ||
| 570 | Each version is given a distinguishing version number. If the | |
| 571 | Program specifies that a certain numbered version of the GNU General | |
| 572 | Public License "or any later version" applies to it, you have the | |
| 573 | option of following the terms and conditions either of that numbered | |
| 574 | version or of any later version published by the Free Software | |
| 575 | Foundation. If the Program does not specify a version number of the | |
| 576 | GNU General Public License, you may choose any version ever published | |
| 577 | by the Free Software Foundation. | |
| 578 | ||
| 579 | If the Program specifies that a proxy can decide which future | |
| 580 | versions of the GNU General Public License can be used, that proxy's | |
| 581 | public statement of acceptance of a version permanently authorizes you | |
| 582 | to choose that version for the Program. | |
| 583 | ||
| 584 | Later license versions may give you additional or different | |
| 585 | permissions. However, no additional obligations are imposed on any | |
| 586 | author or copyright holder as a result of your choosing to follow a | |
| 587 | later version. | |
| 588 | ||
| 589 | 15. Disclaimer of Warranty. | |
| 590 | ||
| 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY | |
| 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT | |
| 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY | |
| 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, | |
| 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR | |
| 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM | |
| 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF | |
| 598 | ALL 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 | |
| 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS | |
| 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY | |
| 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE | |
| 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF | |
| 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD | |
| 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), | |
| 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF | |
| 610 | SUCH DAMAGES. | |
| 611 | ||
| 612 | 17. Interpretation of Sections 15 and 16. | |
| 613 | ||
| 614 | If the disclaimer of warranty and limitation of liability provided | |
| 615 | above cannot be given local legal effect according to their terms, | |
| 616 | reviewing courts shall apply local law that most closely approximates | |
| 617 | an absolute waiver of all civil liability in connection with the | |
| 618 | Program, unless a warranty or assumption of liability accompanies a | |
| 619 | copy 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 | |
| 626 | possible use to the public, the best way to achieve this is to make it | |
| 627 | free 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 | |
| 630 | to attach them to the start of each source file to most effectively | |
| 631 | state the exclusion of warranty; and each file should have at least | |
| 632 | the "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 | ||
| 650 | Also 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 | |
| 653 | notice 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 | ||
| 660 | The hypothetical commands `show w' and `show c' should show the appropriate | |
| 661 | parts of the General Public License. Of course, your program's commands | |
| 662 | might 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, | |
| 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. | |
| 666 | For 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 | |
| 670 | into proprietary programs. If your program is a subroutine library, you | |
| 671 | may consider it more useful to permit linking proprietary applications with | |
| 672 | the library. If this is what you want to do, use the GNU Lesser General | |
| 673 | Public License instead of this License. But first, please read | |
| 674 | <https://www.gnu.org/licenses/why-not-lgpl.html>. |
| … | … | @@ -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 | [](https://kodi.tv) | |
| 8 | [](LICENSE) | |
| 9 | [](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 | ||
| 60 | The recommended installation method is via the official repository to ensure you receive automatic updates. | |
| 61 | ||
| 62 | 1. **Download the Repository**: [Sam-Max Repository](https://github.com/Sam-Max/repository.jacktook) | |
| 63 | 2. **Install via Kodi**: `Add-ons > Install from zip file` | |
| 64 | 3. **Install Jacktook**: `Add-ons > Install from repository > Sam-Max Repository > Video add-ons > Jacktook` | |
| 65 | ||
| 66 | --- | |
| 67 | ||
| 68 | ## ⚙️ Configuration & Usage | |
| 69 | ||
| 70 | ### 🚀 Stremio Integration | |
| 71 | Jacktook 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 | |
| 77 | To 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 | |
| 83 | Translate subtitles and metadata instantly. | |
| 84 | 1. Get a free API key at [DeepL API](https://www.deepl.com/pro-api). | |
| 85 | 2. Enter the key in Jacktook settings. | |
| 86 | 3. Enjoy up to 500,000 characters of free translation per month. | |
| 87 | ||
| 88 | ### 🧩 TMDB Helper | |
| 89 | For 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 | ||
| 95 | If 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 | |  |  | | |
| 110 | | **Settings** | **Extras** | | |
| 111 | |  |  | | |
| 112 | ||
| 113 | --- | |
| 114 | ||
| 115 | ## ☕ Support | |
| 116 | ||
| 117 | If you enjoy using **Jacktook** and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements. | |
| 118 | ||
| 119 | [](https://ko-fi.com/sammax09) | |
| 120 | ||
| 121 | --- | |
| 122 | ||
| 123 | ## ⚖️ Disclaimer | |
| 124 | ||
| 125 | This addon is a meta-search tool. It does not host or scrape torrent websites directly. Users are responsible for the content they access and should comply with local copyright laws. | |
| 126 | ||
| 127 | --- | |
| 128 | ||
| 129 | ## 🤝 Collaborators | |
| 130 | ||
| 131 | A 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"> | |
| 145 | Made with ❤️ for the Kodi Community | |
| 146 | </div> |
| … | … | @@ -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> |
| … | … | @@ -0,0 +1,7 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | ||
| 3 | from lib.router import addon_router | |
| 4 | from lib.utils.kodi.utils import load_saved_view_properties | |
| 5 | ||
| 6 | load_saved_view_properties() | |
| 7 | addon_router() |
| … | … | @@ -0,0 +1,7 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | ||
| 3 | from lib.router import addon_router | |
| 4 | from lib.utils.kodi.utils import load_saved_view_properties | |
| 5 | ||
| 6 | load_saved_view_properties() | |
| 7 | addon_router() |
| … | … | @@ -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 |
| … | … | @@ -0,0 +1,93 @@ |
| 1 | import json | |
| 2 | from lib.domain.torrent import TorrentStream | |
| 3 | from lib.gui.resolver_window import ResolverWindow | |
| 4 | from lib.utils.kodi.utils import ADDON_PATH | |
| 5 | from lib.downloader import handle_download_file | |
| 6 | ||
| 7 | def 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 | ||
| 50 | def 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 |
| … | … | @@ -0,0 +1,189 @@ |
| 1 | import requests | |
| 2 | from lib.api.debrid.base import DebridClient, ProviderException | |
| 3 | from lib.jacktook.utils import kodilog | |
| 4 | from lib.utils.kodi.utils import dialog_ok, set_setting, translation | |
| 5 | from lib.services.debrid.auth import run_alldebrid_auth | |
| 6 | ||
| 7 | ||
| 8 | class 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 |
| … | … | @@ -0,0 +1,183 @@ |
| 1 | import requests | |
| 2 | import traceback | |
| 3 | from abc import ABC, abstractmethod | |
| 4 | from json.decoder import JSONDecodeError | |
| 5 | ||
| 6 | from lib.utils.kodi.utils import kodilog, notification | |
| 7 | from typing import Optional, Dict, Any | |
| 8 | ||
| 9 | ||
| 10 | class 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 | ||
| 170 | class 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) |
| … | … | @@ -0,0 +1,124 @@ |
| 1 | from lib.api.debrid.base import DebridClient | |
| 2 | from lib.jacktook.utils import kodilog | |
| 3 | from lib.utils.kodi.utils import dialog_ok, set_setting, translation | |
| 4 | from lib.services.debrid.auth import run_debrider_auth | |
| 5 | ||
| 6 | ||
| 7 | class 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 |
| … | … | @@ -0,0 +1,63 @@ |
| 1 | from lib.api.debrid.base import DebridClient, ProviderException | |
| 2 | ||
| 3 | ||
| 4 | class 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") |
| … | … | @@ -0,0 +1,185 @@ |
| 1 | from lib.api.debrid.base import DebridClient, ProviderException | |
| 2 | from lib.services.debrid.auth import run_premiumize_auth | |
| 3 | from lib.utils.kodi.utils import dialog_ok, translation | |
| 4 | ||
| 5 | ||
| 6 | class 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 |
| … | … | @@ -0,0 +1,329 @@ |
| 1 | from lib.api.debrid.base import DebridClient, ProviderException | |
| 2 | from base64 import b64encode, b64decode | |
| 3 | from lib.utils.kodi.utils import ( | |
| 4 | dialog_ok, | |
| 5 | kodilog, | |
| 6 | set_setting, | |
| 7 | translation, | |
| 8 | ) | |
| 9 | from lib.services.debrid.auth import run_realdebrid_auth | |
| 10 | from lib.services.debrid.download import run_realdebrid_download | |
| 11 | ||
| 12 | ||
| 13 | class 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 | ) |
| … | … | @@ -0,0 +1,232 @@ |
| 1 | from lib.api.debrid.base import DebridClient, ProviderException | |
| 2 | from lib.utils.kodi.utils import ( | |
| 3 | dialog_ok, | |
| 4 | kodilog, | |
| 5 | notification, | |
| 6 | set_setting, | |
| 7 | translation, | |
| 8 | ) | |
| 9 | import datetime | |
| 10 | import requests | |
| 11 | from requests.adapters import HTTPAdapter | |
| 12 | from lib.services.debrid.auth import run_torbox_auth | |
| 13 | ||
| 14 | ||
| 15 | class 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 |
| … | … | @@ -0,0 +1,136 @@ |
| 1 | import requests | |
| 2 | from lib.utils.kodi.utils import get_setting | |
| 3 | ||
| 4 | API_KEY = "a7ad21743fd710fccb738232f2fbdcfc" | |
| 5 | CLIENT_KEY = "fe073550acf157bdb8a4217f215c0882" | |
| 6 | BASE_URL = "https://webservice.fanart.tv/v3/{media_type}/{media_id}" | |
| 7 | TIMEOUT = 3.0 | |
| 8 | ||
| 9 | DEFAULT_FANART = { | |
| 10 | "poster2": "", | |
| 11 | "fanart2": "", | |
| 12 | "banner": "", | |
| 13 | "clearart": "", | |
| 14 | "clearlogo": "", | |
| 15 | "landscape": "", | |
| 16 | "discart": "", | |
| 17 | "fanart_added": False, | |
| 18 | } | |
| 19 | ||
| 20 | # Shared HTTP session (persistent, connection pooled) | |
| 21 | session = requests.Session() | |
| 22 | session.mount( | |
| 23 | "https://webservice.fanart.tv", requests.adapters.HTTPAdapter(pool_maxsize=100) | |
| 24 | ) | |
| 25 | ||
| 26 | ||
| 27 | def get_fanart(media_type: str, language: str, media_id: str) -> dict: | |
| 28 | """ | |
| 29 | Fetch localized artwork (poster, fanart, clearlogo, etc.) from fanart.tv. | |
| 30 | Supports movies and TV shows, with language fallback: local → English → universal. | |
| 31 | """ | |
| 32 | if not media_id: | |
| 33 | return DEFAULT_FANART.copy() | |
| 34 | ||
| 35 | url = BASE_URL.format(media_type=media_type, media_id=media_id) | |
| 36 | client_key = get_setting("fanart_client_key") or CLIENT_KEY | |
| 37 | headers = {"client-key": client_key, "api-key": API_KEY} | |
| 38 | ||
| 39 | try: | |
| 40 | response = session.get(url, headers=headers, timeout=TIMEOUT) | |
| 41 | data = response.json() | |
| 42 | except Exception: | |
| 43 | return DEFAULT_FANART.copy() | |
| 44 | ||
| 45 | # Handle API errors | |
| 46 | if not data or "error_message" in data: | |
| 47 | return DEFAULT_FANART.copy() | |
| 48 | ||
| 49 | # Convenience alias | |
| 50 | get = data.get | |
| 51 | ||
| 52 | if media_type == "movies": | |
| 53 | return { | |
| 54 | "poster": select_art(get("movieposter"), language), | |
| 55 | "fanart": select_art(get("moviebackground"), language), | |
| 56 | "banner": select_art(get("moviebanner"), language), | |
| 57 | "clearart": select_art( | |
| 58 | get("movieart", []) + get("hdmovieclearart", []), language | |
| 59 | ), | |
| 60 | "clearlogo": select_art( | |
| 61 | get("movielogo", []) + get("hdmovielogo", []), language | |
| 62 | ), | |
| 63 | "landscape": select_art(get("moviethumb"), language), | |
| 64 | "discart": select_art(get("moviedisc"), language), | |
| 65 | "fanart_added": True, | |
| 66 | } | |
| 67 | ||
| 68 | # Default to TV shows | |
| 69 | return { | |
| 70 | "poster": select_art(get("tvposter"), language), | |
| 71 | "fanart": select_art(get("showbackground"), language), | |
| 72 | "banner": select_art(get("tvbanner"), language), | |
| 73 | "clearart": select_art(get("clearart", []) + get("hdclearart", []), language), | |
| 74 | "clearlogo": select_art(get("hdtvlogo", []) + get("clearlogo", []), language), | |
| 75 | "landscape": select_art(get("tvthumb"), language), | |
| 76 | "discart": "", | |
| 77 | "fanart_added": True, | |
| 78 | } | |
| 79 | ||
| 80 | ||
| 81 | # --- Artwork Selector --- | |
| 82 | def select_art(art_list: list, language: str) -> str: | |
| 83 | """ | |
| 84 | Return the best matching artwork URL from a list of art objects. | |
| 85 | Preference order: user's language → English → universal. | |
| 86 | Sorts by number of likes (descending). | |
| 87 | """ | |
| 88 | if not art_list: | |
| 89 | return "" | |
| 90 | ||
| 91 | try: | |
| 92 | # 1. User's language (e.g. 'es') | |
| 93 | matches = [ | |
| 94 | (x["url"], int(x.get("likes", 0))) | |
| 95 | for x in art_list | |
| 96 | if x.get("lang") == language | |
| 97 | ] | |
| 98 | ||
| 99 | # 2. English fallback | |
| 100 | if not matches and language != "en": | |
| 101 | matches = [ | |
| 102 | (x["url"], int(x.get("likes", 0))) | |
| 103 | for x in art_list | |
| 104 | if x.get("lang") == "en" | |
| 105 | ] | |
| 106 | ||
| 107 | # 3. Universal fallback ('00' or no lang) | |
| 108 | if not matches: | |
| 109 | matches = [ | |
| 110 | (x["url"], int(x.get("likes", 0))) | |
| 111 | for x in art_list | |
| 112 | if x.get("lang") in ("00", "", None) | |
| 113 | ] | |
| 114 | ||
| 115 | if matches: | |
| 116 | # Sort by likes descending, return top URL | |
| 117 | matches.sort(key=lambda x: x[1], reverse=True) | |
| 118 | return matches[0][0] | |
| 119 | ||
| 120 | except Exception: | |
| 121 | pass | |
| 122 | ||
| 123 | return "" | |
| 124 | ||
| 125 | ||
| 126 | # --- Helper: Merge Results into Existing Metadata --- | |
| 127 | def add_fanart(media_type: str, language: str, media_id: str, meta: dict) -> dict: | |
| 128 | """ | |
| 129 | Update an existing metadata dictionary with fanart data. | |
| 130 | Always returns a complete dict (never None). | |
| 131 | """ | |
| 132 | try: | |
| 133 | meta.update(get_fanart(media_type, language, media_id)) | |
| 134 | except Exception: | |
| 135 | meta.update(DEFAULT_FANART) | |
| 136 | return meta |
| … | … | @@ -0,0 +1,196 @@ |
| 1 | import requests | |
| 2 | import re | |
| 3 | import html | |
| 4 | from lib.utils.kodi.utils import kodilog | |
| 5 | from lib.db.cached import cache | |
| 6 | from datetime import timedelta | |
| 7 | ||
| 8 | ||
| 9 | def 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 | ||
| 21 | def 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 | ||
| 68 | def 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 | ||
| 113 | def get_imdb_parentsguide(imdb_id): | |
| 114 | """ | |
| 115 | Scrapes the IMDb parents guide page for a given IMDb ID. | |
| 116 | Returns a list of parental guide strings grouped by category. | |
| 117 | """ | |
| 118 | if not imdb_id: | |
| 119 | return [] | |
| 120 | ||
| 121 | cache_key = f"imdb_parentsguide_regex_v1_{imdb_id}" | |
| 122 | cached_data = cache.get(cache_key) | |
| 123 | if cached_data is not None: | |
| 124 | return cached_data | |
| 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 [] |
| … | … | @@ -0,0 +1,152 @@ |
| 1 | from json import dumps | |
| 2 | import requests | |
| 3 | from requests.auth import HTTPBasicAuth | |
| 4 | from urllib.parse import quote | |
| 5 | from lib.utils.kodi.utils import notification | |
| 6 | ||
| 7 | ||
| 8 | class 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 | ||
| 148 | class TorrServerError(Exception): | |
| 149 | def __init__(self, message): | |
| 150 | self.message = message | |
| 151 | super().__init__(self.message) | |
| 152 | notification(self.message) |
| … | … | @@ -0,0 +1,152 @@ |
| 1 | import requests | |
| 2 | from typing import List, Dict, Any, Optional | |
| 3 | from lib.utils.kodi.utils import get_setting | |
| 4 | ||
| 5 | ||
| 6 | class MDblistAPIError(Exception): | |
| 7 | pass | |
| 8 | ||
| 9 | ||
| 10 | class 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}") |
| … | … | @@ -0,0 +1,195 @@ |
| 1 | import json | |
| 2 | import requests | |
| 3 | ||
| 4 | from http import HTTPStatus | |
| 5 | from requests.exceptions import ConnectionError, Timeout | |
| 6 | from lib.api.plex.settings import settings | |
| 7 | from lib.api.plex.models.plex_models import ( | |
| 8 | PlexEpisodeMeta, | |
| 9 | PlexLibrarySection, | |
| 10 | PlexMediaMeta, | |
| 11 | PlexMediaType, | |
| 12 | PlexServer, | |
| 13 | ) | |
| 14 | from lib.api.plex.utils import PlexUnauthorizedError | |
| 15 | from lib.utils.kodi.utils import kodilog | |
| 16 | ||
| 17 | ||
| 18 | def 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 | ||
| 34 | def 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 | ||
| 51 | def 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 | ||
| 65 | def 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 | ||
| 88 | def 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 | ||
| 115 | def 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 | ||
| 126 | def 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 | ||
| 141 | def 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 | ||
| 152 | def 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 | ||
| 179 | def 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") |
| … | … | @@ -0,0 +1,173 @@ |
| 1 | import os | |
| 2 | from datetime import datetime | |
| 3 | from enum import Enum | |
| 4 | from urllib import parse | |
| 5 | from lib.utils.kodi_utils import get_setting | |
| 6 | from lib.utils.utils import Indexer | |
| 7 | ||
| 8 | ||
| 9 | class AuthPin: | |
| 10 | def __init__(self, **kwargs): | |
| 11 | self.id = kwargs["id"] | |
| 12 | self.code = kwargs["code"] | |
| 13 | ||
| 14 | ||
| 15 | class PlexUser: | |
| 16 | def __init__(self, **kwargs): | |
| 17 | self.username = kwargs["username"] | |
| 18 | self.thumb = kwargs["thumb"] | |
| 19 | ||
| 20 | ||
| 21 | class 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 | ||
| 32 | class PlexMediaType(str, Enum): | |
| 33 | show = "show" | |
| 34 | movie = "movie" | |
| 35 | episode = "episode" | |
| 36 | ||
| 37 | ||
| 38 | class 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 | ||
| 45 | class 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 | ||
| 144 | class 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", []) |
| … | … | @@ -0,0 +1,127 @@ |
| 1 | from http import HTTPStatus | |
| 2 | import json | |
| 3 | from time import sleep, time | |
| 4 | import requests | |
| 5 | ||
| 6 | from lib.api.plex.settings import settings | |
| 7 | from lib.api.plex.models.plex_models import AuthPin, PlexUser | |
| 8 | from lib.api.plex.utils import HTTPException, PlexUnauthorizedError | |
| 9 | from lib.utils.kodi.utils import copy2clip, dialog_ok, kodilog, set_setting | |
| 10 | ||
| 11 | import xbmcgui | |
| 12 | ||
| 13 | class 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 |
| … | … | @@ -0,0 +1,10 @@ |
| 1 | from uuid import uuid4 | |
| 2 | ||
| 3 | class Settings(): | |
| 4 | identifier: str = str(uuid4()) | |
| 5 | product_name: str = 'Jacktook' | |
| 6 | plex_requests_timeout: int = 30 | |
| 7 | plex_matching_token: str | |
| 8 | ||
| 9 | ||
| 10 | settings = Settings() |
| … | … | @@ -0,0 +1,16 @@ |
| 1 | ||
| 2 | class PlexUnauthorizedError(BaseException): | |
| 3 | pass | |
| 4 | ||
| 5 | ||
| 6 | class 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 |
| … | … | @@ -0,0 +1,335 @@ |
| 1 | import json | |
| 2 | from typing import List, Optional, Any | |
| 3 | from urllib.parse import urlsplit, urlunsplit | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 24 | def build_addon_base_url(transport_url: str) -> str: | |
| 25 | return normalize_transport_url(transport_url) | |
| 26 | ||
| 27 | ||
| 28 | def 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 | ||
| 52 | def 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 | ||
| 82 | class 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 | ||
| 94 | class 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 | ||
| 108 | class 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 | ||
| 126 | class 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 | ||
| 196 | class 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 | ||
| 221 | class 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 |
| … | … | @@ -0,0 +1,82 @@ |
| 1 | from json import JSONDecodeError | |
| 2 | from requests.exceptions import RequestException, Timeout, TooManyRedirects | |
| 3 | from requests import Session | |
| 4 | from lib.utils.general.utils import USER_AGENT_HEADER | |
| 5 | from lib.utils.kodi.utils import kodilog | |
| 6 | from lib.utils.kodi.settings import get_int_setting | |
| 7 | ||
| 8 | ||
| 9 | class 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", []) |
| … | … | @@ -0,0 +1,271 @@ |
| 1 | from typing import List, Optional, Any, Dict, Union | |
| 2 | from dataclasses import dataclass, field | |
| 3 | ||
| 4 | @dataclass | |
| 5 | class 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 | ||
| 18 | def 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 | |
| 34 | class 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 | |
| 56 | class 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 | |
| 132 | class 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 | |
| 164 | class 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 | |
| 172 | class 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 | |
| 232 | class 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 | ) |
| … | … | @@ -0,0 +1,25 @@ |
| 1 | from .objs.account import Account | |
| 2 | from .objs.auth import Authentication | |
| 3 | from .objs.certification import Certification | |
| 4 | from .objs.change import Change | |
| 5 | from .objs.collection import Collection | |
| 6 | from .objs.company import Company | |
| 7 | from .objs.configuration import Configuration | |
| 8 | from .objs.credit import Credit | |
| 9 | from .objs.discover import Discover | |
| 10 | from .objs.episode import Episode | |
| 11 | from .objs.find import Find | |
| 12 | from .objs.genre import Genre | |
| 13 | from .objs.group import Group | |
| 14 | from .objs.keyword import Keyword | |
| 15 | from .objs.list import List | |
| 16 | from .objs.movie import Movie | |
| 17 | from .objs.network import Network | |
| 18 | from .objs.person import Person | |
| 19 | from .objs.provider import Provider | |
| 20 | from .objs.review import Review | |
| 21 | from .objs.search import Search | |
| 22 | from .objs.season import Season | |
| 23 | from .objs.trending import Trending | |
| 24 | from .objs.tv import TV | |
| 25 | from .tmdb import TMDb |
| … | … | @@ -0,0 +1,132 @@ |
| 1 | # encoding: utf-8 | |
| 2 | import sys | |
| 3 | from .exceptions import TMDbException | |
| 4 | ||
| 5 | ||
| 6 | class 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 |
| … | … | @@ -0,0 +1,2 @@ |
| 1 | class TMDbException(Exception): | |
| 2 | pass |
| … | … | @@ -0,0 +1,177 @@ |
| 1 | import os | |
| 2 | from lib.api.tmdbv3api.exceptions import TMDbException | |
| 3 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 4 | ||
| 5 | ||
| 6 | ||
| 7 | class 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) |
| … | … | @@ -0,0 +1,126 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | from lib.api.tmdbv3api.utils import get_current_date, get_dates, years_tvshows | |
| 3 | ||
| 4 | ||
| 5 | class 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 | ) |
| … | … | @@ -0,0 +1,63 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | class 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 = "" |
| … | … | @@ -0,0 +1,21 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | class 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") |
| … | … | @@ -0,0 +1,54 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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) |
| … | … | @@ -0,0 +1,33 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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") |
| … | … | @@ -0,0 +1,47 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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 | ) |
| … | … | @@ -0,0 +1,54 @@ |
| 1 | import warnings | |
| 2 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 3 | ||
| 4 | ||
| 5 | class 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"]) |
| … | … | @@ -0,0 +1,14 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | class 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) |
| … | … | @@ -0,0 +1,45 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | from lib.api.tmdbv3api.utils import get_dates | |
| 3 | ||
| 4 | try: | |
| 5 | from urllib import urlencode | |
| 6 | except ImportError: | |
| 7 | from urllib.parse import urlencode | |
| 8 | ||
| 9 | ||
| 10 | class 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 | ) |
| … | … | @@ -0,0 +1,157 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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 | ) |
| … | … | @@ -0,0 +1,83 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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") |
| … | … | @@ -0,0 +1,22 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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") |
| … | … | @@ -0,0 +1,15 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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") |
| … | … | @@ -0,0 +1,23 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | class 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") |
| … | … | @@ -0,0 +1,95 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | class 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 | ) |
| … | … | @@ -0,0 +1,348 @@ |
| 1 | import warnings | |
| 2 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 3 | from .find import Find | |
| 4 | from .search import Search | |
| 5 | ||
| 6 | ||
| 7 | class 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 += "®ion=%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 += "®ion=%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 += "®ion=%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 += "®ion=%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) |
| … | … | @@ -0,0 +1,39 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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 | ) |
| … | … | @@ -0,0 +1,151 @@ |
| 1 | import warnings | |
| 2 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 3 | from .search import Search | |
| 4 | ||
| 5 | ||
| 6 | class 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) |
| … | … | @@ -0,0 +1,41 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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 | ) |
| … | … | @@ -0,0 +1,15 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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) |
| … | … | @@ -0,0 +1,146 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | try: | |
| 4 | from urllib import quote | |
| 5 | except ImportError: | |
| 6 | from urllib.parse import quote | |
| 7 | ||
| 8 | ||
| 9 | class 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 += "®ion=%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 += "®ion=%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 += "®ion=%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 | ) |
| … | … | @@ -0,0 +1,133 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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 | ) |
| … | … | @@ -0,0 +1,75 @@ |
| 1 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 2 | ||
| 3 | ||
| 4 | class 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) |
| … | … | @@ -0,0 +1,347 @@ |
| 1 | import warnings | |
| 2 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 3 | from .search import Search | |
| 4 | ||
| 5 | try: | |
| 6 | from urllib import quote | |
| 7 | except ImportError: | |
| 8 | from urllib.parse import quote | |
| 9 | ||
| 10 | ||
| 11 | class 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) |
| … | … | @@ -0,0 +1,210 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | ||
| 3 | import logging | |
| 4 | import os | |
| 5 | import time | |
| 6 | ||
| 7 | import requests | |
| 8 | ||
| 9 | from lib.utils.kodi.utils import kodilog | |
| 10 | ||
| 11 | from .as_obj import AsObj | |
| 12 | from .exceptions import TMDbException | |
| 13 | ||
| 14 | try: | |
| 15 | from functools import lru_cache | |
| 16 | except ImportError: | |
| 17 | from backports.functools_lru_cache import lru_cache | |
| 18 | ||
| 19 | logger = logging.getLogger(__name__) | |
| 20 | ||
| 21 | ||
| 22 | class 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) |
| … | … | @@ -0,0 +1,15 @@ |
| 1 | import datetime | |
| 2 | ||
| 3 | years_tvshows = [{'name': '2024', 'id': 2024}] | |
| 4 | ||
| 5 | def 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 | ||
| 13 | def get_current_date(return_str=True): | |
| 14 | if return_str: return str(datetime.date.today()) | |
| 15 | else: return datetime.date.today() |
| … | … | @@ -0,0 +1,209 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | import ast | |
| 3 | import os | |
| 4 | import time | |
| 5 | import sqlite3 as database | |
| 6 | from lib.utils.kodi.utils import ( | |
| 7 | delete_file, | |
| 8 | dialog_ok, | |
| 9 | get_property, | |
| 10 | notification, | |
| 11 | set_property, | |
| 12 | clear_property, | |
| 13 | translation, | |
| 14 | ) | |
| 15 | ||
| 16 | import xbmcaddon | |
| 17 | from xbmcvfs import translatePath | |
| 18 | ||
| 19 | ||
| 20 | userdata_path = translatePath(xbmcaddon.Addon().getAddonInfo("profile")) | |
| 21 | databases_path = os.path.join(userdata_path, "databases/") | |
| 22 | database_path_raw = os.path.join(userdata_path, "databases") | |
| 23 | trakt_db = translatePath(os.path.join(database_path_raw, "traktcache.db")) | |
| 24 | lists_db = translatePath(os.path.join(database_path_raw, "lists.db")) | |
| 25 | maincache_db = translatePath(os.path.join(database_path_raw, "maincache.db")) | |
| 26 | paginator_db = translatePath(os.path.join(database_path_raw, "paginator.db")) | |
| 27 | database_timeout = 20 | |
| 28 | ||
| 29 | ||
| 30 | database_locations = { | |
| 31 | "trakt_db": trakt_db, | |
| 32 | "lists_db": lists_db, | |
| 33 | "maincache_db": maincache_db, | |
| 34 | "paginator_db": paginator_db, | |
| 35 | } | |
| 36 | ||
| 37 | integrity_check = { | |
| 38 | "trakt_db": ("trakt_data", "watched_status", "progress"), | |
| 39 | "maincache_db": ("maincache",), | |
| 40 | "lists_db": ("lists",), | |
| 41 | } | |
| 42 | ||
| 43 | table_creators = { | |
| 44 | "trakt_db": ( | |
| 45 | "CREATE TABLE IF NOT EXISTS trakt_data (id text unique, data text)", | |
| 46 | "CREATE TABLE IF NOT EXISTS watched \ | |
| 47 | (db_type text not null, media_id text not null, season integer, episode integer, last_played text, title text, unique (db_type, media_id, season, episode))", | |
| 48 | "CREATE TABLE IF NOT EXISTS progress \ | |
| 49 | (db_type text not null, media_id text not null, season integer, episode integer, resume_point text, curr_time text, \ | |
| 50 | last_played text, resume_id integer, title text, unique (db_type, media_id, season, episode))", | |
| 51 | "CREATE TABLE IF NOT EXISTS watched_status (db_type text not null, media_id text not null, status text, unique (db_type, media_id))", | |
| 52 | ), | |
| 53 | "lists_db": ( | |
| 54 | "CREATE TABLE IF NOT EXISTS lists (id text unique, data text, expires integer)", | |
| 55 | ), | |
| 56 | "maincache_db": ( | |
| 57 | "CREATE TABLE IF NOT EXISTS maincache (id text unique, data text, expires integer)", | |
| 58 | ), | |
| 59 | "paginator_db": ( | |
| 60 | "CREATE TABLE IF NOT EXISTS paginated_data (id text unique, page_number integer, data text, total_pages integer)", | |
| 61 | ), | |
| 62 | } | |
| 63 | ||
| 64 | media_prop = "jacktook.%s" | |
| 65 | BASE_GET = "SELECT expires, data FROM %s WHERE id = ?" | |
| 66 | BASE_SET = "INSERT OR REPLACE INTO %s(id, data, expires) VALUES (?, ?, ?)" | |
| 67 | BASE_DELETE = "DELETE FROM %s WHERE id = ?" | |
| 68 | ||
| 69 | ||
| 70 | def make_database(database_name): | |
| 71 | dbcon = database.connect(database_locations[database_name]) | |
| 72 | for command in table_creators[database_name]: | |
| 73 | dbcon.execute(command) | |
| 74 | dbcon.close() | |
| 75 | ||
| 76 | ||
| 77 | def setup_databases(): | |
| 78 | if not os.path.exists(databases_path): | |
| 79 | os.makedirs(databases_path) | |
| 80 | for database_name, database_location in database_locations.items(): | |
| 81 | dbcon = database.connect(database_location) | |
| 82 | for command in table_creators[database_name]: | |
| 83 | dbcon.execute(command) | |
| 84 | ||
| 85 | ||
| 86 | def connect_database(database_name): | |
| 87 | dbcon = database.connect( | |
| 88 | database_locations[database_name], | |
| 89 | timeout=database_timeout, | |
| 90 | isolation_level=None, | |
| 91 | check_same_thread=False, | |
| 92 | ) | |
| 93 | dbcon.execute("PRAGMA synchronous = OFF") | |
| 94 | dbcon.execute("PRAGMA journal_mode = OFF") | |
| 95 | return dbcon | |
| 96 | ||
| 97 | ||
| 98 | def get_timestamp(offset=0): | |
| 99 | # Offset is in HOURS multiply by 3600 to get seconds | |
| 100 | return int(time.time()) + (offset * 3600) | |
| 101 | ||
| 102 | ||
| 103 | def check_databases_integrity(): | |
| 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 | ||
| 135 | class 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) |
| … | … | @@ -0,0 +1,64 @@ |
| 1 | from lib.api.trakt.base_cache import BaseCache, get_timestamp | |
| 2 | ||
| 3 | ||
| 4 | GET_ALL = "SELECT id FROM lists" | |
| 5 | DELETE_ALL = "DELETE FROM lists" | |
| 6 | DELETE_PREFIX = 'DELETE FROM lists WHERE id LIKE "%s%%"' | |
| 7 | CLEAN = "DELETE from lists WHERE CAST(expires AS INT) <= ?" | |
| 8 | ||
| 9 | ||
| 10 | class 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 | ||
| 48 | lists_cache = ListsCache() | |
| 49 | ||
| 50 | ||
| 51 | def 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 |
| … | … | @@ -0,0 +1,69 @@ |
| 1 | from lib.api.trakt.base_cache import BaseCache, get_timestamp | |
| 2 | ||
| 3 | ||
| 4 | GET_ALL = "SELECT id FROM maincache" | |
| 5 | DELETE_ALL = "DELETE FROM maincache" | |
| 6 | LIKE_SELECT = "SELECT id from maincache where id LIKE %s" | |
| 7 | LIKE_DELETE = "DELETE FROM maincache WHERE id LIKE %s" | |
| 8 | CLEAN = "DELETE from maincache WHERE CAST(expires AS INT) <= ?" | |
| 9 | ||
| 10 | ||
| 11 | class 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 | ||
| 53 | main_cache = MainCache() | |
| 54 | ||
| 55 | ||
| 56 | def 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 |
| … | … | @@ -0,0 +1,1694 @@ |
| 1 | import json | |
| 2 | import datetime | |
| 3 | import random | |
| 4 | import time | |
| 5 | from typing import Any, Dict | |
| 6 | import requests | |
| 7 | from lib.utils.general.utils import set_pluging_category | |
| 8 | import xbmc | |
| 9 | ||
| 10 | from lib.api.trakt.lists_cache import lists_cache | |
| 11 | from lib.api.trakt.base_cache import BASE_DELETE, connect_database | |
| 12 | from lib.api.trakt.lists_cache import lists_cache_object | |
| 13 | from lib.api.trakt.main_cache import cache_object | |
| 14 | from lib.api.trakt.main_cache import cache_object | |
| 15 | from 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 | ) | |
| 23 | from lib.api.trakt.trakt_utils import clean_ids, sort_for_article, sort_list | |
| 24 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 25 | from lib.jacktook.utils import ADDON_PATH | |
| 26 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 27 | from 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 | ) | |
| 39 | from lib.utils.kodi.settings import ( | |
| 40 | EMPTY_USER, | |
| 41 | trakt_client, | |
| 42 | trakt_lists_sort_order, | |
| 43 | trakt_secret, | |
| 44 | ) | |
| 45 | ||
| 46 | ||
| 47 | class 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 | ||
| 283 | class 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 | ||
| 429 | class 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 | ||
| 521 | class 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 | ||
| 801 | class 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 | ||
| 848 | class 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 | ||
| 1496 | class 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 | ||
| 1625 | class 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 | ||
| 1646 | class 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 | ||
| 1668 | class 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 | ||
| 1677 | class 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 | ||
| 1690 | class ProviderException(Exception): | |
| 1691 | def __init__(self, message): | |
| 1692 | self.message = message | |
| 1693 | super().__init__(self.message) | |
| 1694 | notification(self.message) |
| … | … | @@ -0,0 +1,301 @@ |
| 1 | from lib.api.trakt.base_cache import connect_database | |
| 2 | ||
| 3 | ||
| 4 | SELECT = "SELECT id FROM trakt_data" | |
| 5 | DELETE = "DELETE FROM trakt_data WHERE id=?" | |
| 6 | DELETE_LIKE = 'DELETE FROM trakt_data WHERE id LIKE "%s"' | |
| 7 | WATCHED_INSERT = "INSERT OR IGNORE INTO watched VALUES (?, ?, ?, ?, ?, ?)" | |
| 8 | WATCHED_DELETE = "DELETE FROM watched WHERE db_type = ?" | |
| 9 | PROGRESS_INSERT = "INSERT OR IGNORE INTO progress VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" | |
| 10 | PROGRESS_DELETE = "DELETE FROM progress WHERE db_type = ?" | |
| 11 | STATUS_INSERT = "INSERT INTO watched_status VALUES (?, ?, ?)" | |
| 12 | STATUS_DELETE = "DELETE FROM watched_status" | |
| 13 | BASE_DELETE = "DELETE FROM %s" | |
| 14 | TC_BASE_GET = "SELECT data FROM trakt_data WHERE id = ?" | |
| 15 | TC_BASE_SET = "INSERT OR REPLACE INTO trakt_data (id, data) VALUES (?, ?)" | |
| 16 | TC_BASE_DELETE = "DELETE FROM trakt_data WHERE id = ?" | |
| 17 | ||
| 18 | ||
| 19 | class 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 | ||
| 54 | trakt_cache = TraktCache() | |
| 55 | ||
| 56 | ||
| 57 | class 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 | ||
| 140 | trakt_watched_cache = TraktWatched() | |
| 141 | ||
| 142 | ||
| 143 | def 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 | ||
| 152 | def 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 | ||
| 168 | def 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 | ||
| 177 | def 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 | ||
| 190 | def 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 | ||
| 199 | def 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 | ||
| 208 | def 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 | ||
| 216 | def clear_trakt_recommendations(): | |
| 217 | try: | |
| 218 | dbcon = connect_database("trakt_db") | |
| 219 | dbcon.execute(DELETE_LIKE % "trakt_recommendations_%") | |
| 220 | except: | |
| 221 | return | |
| 222 | ||
| 223 | ||
| 224 | def clear_trakt_favorites(): | |
| 225 | try: | |
| 226 | dbcon = connect_database("trakt_db") | |
| 227 | dbcon.execute(DELETE_LIKE % "trakt_favorites_%") | |
| 228 | except: | |
| 229 | return | |
| 230 | ||
| 231 | ||
| 232 | def 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 | ||
| 243 | def 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 | } |
| … | … | @@ -0,0 +1,273 @@ |
| 1 | from datetime import datetime | |
| 2 | import random | |
| 3 | import re | |
| 4 | import time | |
| 5 | import json | |
| 6 | import xbmc | |
| 7 | from lib.utils.kodi.utils import action_url_run, kodilog | |
| 8 | from lib.utils.kodi.settings import get_setting | |
| 9 | ||
| 10 | ||
| 11 | def 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 | ||
| 19 | def _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 | ||
| 30 | def 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 | ||
| 60 | def 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 | ||
| 96 | def 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 | ||
| 126 | def 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 | ||
| 156 | def 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 | ||
| 186 | def clean_ids(ids_dict): | |
| 187 | return {k: v for k, v in ids_dict.items() if v not in (None, "", "null")} | |
| 188 | ||
| 189 | ||
| 190 | def 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 | ||
| 200 | def 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 | ||
| 208 | def 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 | ||
| 248 | def 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 | ||
| 256 | def 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 | ||
| 271 | def sort_for_article(_list, _key): | |
| 272 | _list.sort(key=lambda k: re.sub(r"(^the |^a |^an )", "", k[_key].lower())) | |
| 273 | return _list |
| … | … | @@ -0,0 +1,59 @@ |
| 1 | import requests | |
| 2 | from datetime import timedelta | |
| 3 | from lib.db.cached import cache | |
| 4 | from lib.utils.kodi.settings import is_cache_enabled, get_cache_expiration | |
| 5 | ||
| 6 | class 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 |
| … | … | @@ -0,0 +1,198 @@ |
| 1 | import os | |
| 2 | from urllib.parse import unquote, urlparse | |
| 3 | import requests | |
| 4 | from requests.auth import HTTPBasicAuth | |
| 5 | import xml.etree.ElementTree as ET | |
| 6 | from lib.utils.kodi.utils import kodilog | |
| 7 | ||
| 8 | ||
| 9 | VIDEO_EXTS = { | |
| 10 | ".mkv", | |
| 11 | ".mp4", | |
| 12 | ".avi", | |
| 13 | ".mov", | |
| 14 | ".ts", | |
| 15 | ".m2ts", | |
| 16 | ".wmv", | |
| 17 | ".flv", | |
| 18 | ".webm", | |
| 19 | ".iso", | |
| 20 | } | |
| 21 | AUDIO_EXTS = {".mp3", ".flac", ".wav", ".aac", ".m4a", ".ogg", ".wma", ".opus"} | |
| 22 | IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".ico"} | |
| 23 | TEXT_EXTS = {".txt", ".log", ".nfo", ".xml", ".json", ".md", ".srt"} | |
| 24 | ||
| 25 | ||
| 26 | class 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 | } |
| … | … | @@ -0,0 +1,178 @@ |
| 1 | import requests | |
| 2 | ||
| 3 | ||
| 4 | class 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 | |
| 79 | BASE_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 | ||
| 119 | SEARCH = """ | |
| 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 | ||
| 149 | SEARCH_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 | ||
| 172 | def anilist_client(): | |
| 173 | return AniList( | |
| 174 | get_setting("anilist_client_id", "14375"), | |
| 175 | get_setting( | |
| 176 | "anilist_client_secret", "tOJ5CJA9JM2pmJrHM8XaZgnM9XgL7HaLTM3krdML" | |
| 177 | ), | |
| 178 | ) |
| … | … | @@ -0,0 +1,25 @@ |
| 1 | import requests | |
| 2 | ||
| 3 | ||
| 4 | class 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() |
| … | … | @@ -0,0 +1,34 @@ |
| 1 | from abc import ABC, abstractmethod | |
| 2 | from requests import Session | |
| 3 | from typing import Any, List, Optional, Callable | |
| 4 | from lib.domain.torrent import TorrentStream | |
| 5 | from lib.utils.kodi.utils import notification | |
| 6 | ||
| 7 | ||
| 8 | class 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) |
| … | … | @@ -0,0 +1,210 @@ |
| 1 | from datetime import datetime | |
| 2 | from typing import Dict, List, Any, Optional | |
| 3 | ||
| 4 | from lib.api.debrid.alldebrid import AllDebrid | |
| 5 | from lib.api.debrid.base import ProviderException | |
| 6 | from lib.clients.debrid.common import get_file_name, get_packed_release_message | |
| 7 | from lib.utils.kodi.utils import dialog_text, get_setting, kodilog, notification, translation | |
| 8 | from lib.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 | ) | |
| 16 | from lib.domain.torrent import TorrentStream | |
| 17 | import threading | |
| 18 | ||
| 19 | ||
| 20 | class 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)) |
| … | … | @@ -0,0 +1,62 @@ |
| 1 | from typing import Any, Dict, Optional | |
| 2 | ||
| 3 | from lib.api.debrid.base import ProviderException | |
| 4 | from lib.utils.kodi.utils import translation | |
| 5 | from lib.utils.general.utils import supported_video_extensions | |
| 6 | ||
| 7 | ||
| 8 | PACKED_RELEASE_FALLBACK_TEMPLATE = ( | |
| 9 | "{provider} cannot directly play packed releases (.rar/.zip). " | |
| 10 | "Choose a source with video files instead." | |
| 11 | ) | |
| 12 | ||
| 13 | ||
| 14 | def 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 | ||
| 21 | def 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 | ||
| 35 | def 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 | ||
| 43 | def 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 | ||
| 53 | def 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)) |
| … | … | @@ -0,0 +1,168 @@ |
| 1 | from datetime import datetime, timezone | |
| 2 | from typing import Dict, List, Any, Optional | |
| 3 | from lib.api.debrid.base import ProviderException | |
| 4 | from lib.api.debrid.debrider import Debrider | |
| 5 | from lib.clients.debrid.common import get_file_name, get_packed_release_message | |
| 6 | from lib.utils.kodi.utils import dialog_text, get_setting, notification | |
| 7 | from lib.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 | ) | |
| 18 | from lib.domain.torrent import TorrentStream | |
| 19 | ||
| 20 | ||
| 21 | class 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)) |
| … | … | @@ -0,0 +1,133 @@ |
| 1 | from datetime import datetime | |
| 2 | from typing import Dict, List, Any, Optional | |
| 3 | from lib.api.debrid.easydebrid import EasyDebrid | |
| 4 | from lib.clients.debrid.common import get_file_name, get_packed_release_message | |
| 5 | from lib.utils.kodi.utils import dialog_text, get_setting, notification, translation | |
| 6 | from lib.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 | ) | |
| 17 | from lib.domain.torrent import TorrentStream | |
| 18 | ||
| 19 | ||
| 20 | class 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)) |
| … | … | @@ -0,0 +1,126 @@ |
| 1 | import copy | |
| 2 | from typing import Dict, List, Any, Optional | |
| 3 | from lib.api.debrid.premiumize import Premiumize | |
| 4 | from lib.clients.debrid.common import ( | |
| 5 | ensure_direct_playable_file_for_provider, | |
| 6 | get_file_name, | |
| 7 | ) | |
| 8 | from lib.utils.kodi.utils import get_setting, kodilog, notification | |
| 9 | from 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 | ) | |
| 19 | from lib.domain.torrent import TorrentStream | |
| 20 | ||
| 21 | ||
| 22 | class 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 |
| … | … | @@ -0,0 +1,317 @@ |
| 1 | import copy | |
| 2 | import threading | |
| 3 | import time | |
| 4 | from datetime import datetime | |
| 5 | from typing import Any, List, Dict, Optional, cast | |
| 6 | ||
| 7 | from lib.api.debrid.base import ProviderException | |
| 8 | from lib.api.debrid.realdebrid import RealDebrid | |
| 9 | from lib.clients.debrid.common import ( | |
| 10 | ensure_direct_playable_file_for_provider, | |
| 11 | get_file_name, | |
| 12 | ) | |
| 13 | from lib.utils.kodi.logging import kodilog | |
| 14 | from lib.utils.kodi.utils import ( | |
| 15 | get_setting, | |
| 16 | dialog_text, | |
| 17 | translation, | |
| 18 | ) | |
| 19 | from 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 | ) | |
| 29 | from lib.domain.torrent import TorrentStream | |
| 30 | ||
| 31 | ||
| 32 | class LinkNotFoundError(Exception): | |
| 33 | pass | |
| 34 | ||
| 35 | ||
| 36 | class 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")) |
| … | … | @@ -0,0 +1,286 @@ |
| 1 | import copy | |
| 2 | from typing import Dict, List, Any, Optional | |
| 3 | from lib.api.debrid.torbox import Torbox | |
| 4 | from lib.clients.debrid.common import get_file_name, get_packed_release_message | |
| 5 | from lib.utils.kodi.utils import get_setting, notification, dialog_text, kodilog, translation | |
| 6 | from 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 | ) | |
| 16 | from lib.domain.torrent import TorrentStream | |
| 17 | ||
| 18 | EXTENSIONS = supported_video_extensions()[:-1] | |
| 19 | ||
| 20 | ||
| 21 | class TorboxException(Exception): | |
| 22 | def __init__(self, message): | |
| 23 | self.message = message | |
| 24 | super().__init__(self.message) | |
| 25 | ||
| 26 | ||
| 27 | class 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)) |
| … | … | @@ -0,0 +1,244 @@ |
| 1 | import re | |
| 2 | from urllib.parse import quote | |
| 3 | from lib.clients.base import BaseClient, TorrentStream | |
| 4 | from lib.utils.kodi.utils import kodilog | |
| 5 | from lib.utils.general.utils import IndexerType, Indexer | |
| 6 | from typing import List, Optional, Any, Callable, Tuple | |
| 7 | ||
| 8 | VIDEO_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 | ||
| 13 | SEARCH_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 | ||
| 33 | class 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" |
| … | … | @@ -0,0 +1,69 @@ |
| 1 | import re | |
| 2 | from typing import List, Dict, Any, Optional, Callable | |
| 3 | from lib.clients.base import BaseClient, TorrentStream | |
| 4 | from lib.utils.kodi.utils import convert_size_to_bytes, translation | |
| 5 | ||
| 6 | ||
| 7 | class 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 | } |
| … | … | @@ -0,0 +1,85 @@ |
| 1 | import re | |
| 2 | import requests | |
| 3 | ||
| 4 | ||
| 5 | class 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 | ||
| 23 | def 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 |
| … | … | @@ -0,0 +1,95 @@ |
| 1 | import requests | |
| 2 | from datetime import timedelta | |
| 3 | from lib.db.cached import MemoryCache | |
| 4 | from lib.utils.kodi.utils import kodilog | |
| 5 | ||
| 6 | INTRODB_BASE_URL = "http://api.introdb.app" | |
| 7 | INTRODB_TIMEOUT = 5 | |
| 8 | INTRODB_CACHE_EXPIRY = timedelta(hours=24) | |
| 9 | ||
| 10 | _cache = MemoryCache(database="introdb") | |
| 11 | _SENTINEL = "__introdb_none__" | |
| 12 | ||
| 13 | ||
| 14 | def 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 |
| … | … | @@ -0,0 +1,244 @@ |
| 1 | from urllib.parse import quote | |
| 2 | from typing import Any, List, Optional | |
| 3 | import concurrent | |
| 4 | import requests | |
| 5 | ||
| 6 | from lib.clients.base import BaseClient | |
| 7 | from lib.domain.torrent import TorrentStream | |
| 8 | from lib.jacktook.utils import kodilog | |
| 9 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 10 | from lib.utils.general.utils import USER_AGENT_HEADER | |
| 11 | from lib.utils.kodi.settings import get_jackett_timeout | |
| 12 | from lib.utils.kodi.utils import get_setting, notification, translation | |
| 13 | from lib.utils.parsers import xmltodict | |
| 14 | ||
| 15 | ||
| 16 | class 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 | ) |
| … | … | @@ -0,0 +1,130 @@ |
| 1 | from typing import List, Dict, Optional, Any, Callable | |
| 2 | from lib.clients.base import BaseClient, TorrentStream | |
| 3 | from lib.utils.kodi.utils import kodilog, translation | |
| 4 | ||
| 5 | ||
| 6 | class 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 | } |
| … | … | @@ -0,0 +1,189 @@ |
| 1 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 2 | from 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 | ||
| 13 | from 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 | ||
| 25 | from xbmcplugin import addDirectoryItem | |
| 26 | import json | |
| 27 | ||
| 28 | ||
| 29 | def 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 | ||
| 37 | def 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 | ||
| 45 | def 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 | ||
| 57 | def 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 | ||
| 69 | def 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 | ||
| 81 | def 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 | ||
| 99 | def 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 | ||
| 127 | def 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 | ||
| 140 | def 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 | ||
| 177 | def 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") |
| … | … | @@ -0,0 +1,174 @@ |
| 1 | from urllib.parse import quote | |
| 2 | from lib.clients.tmdb.utils.utils import mdblist_get, tmdb_get | |
| 3 | from lib.utils.general.utils import ( | |
| 4 | build_list_item, | |
| 5 | make_listing, | |
| 6 | set_content_type, | |
| 7 | set_pluging_category, | |
| 8 | ) | |
| 9 | from lib.utils.kodi.utils import ( | |
| 10 | ADDON_HANDLE, | |
| 11 | build_url, | |
| 12 | end_of_directory, | |
| 13 | notification, | |
| 14 | show_keyboard, | |
| 15 | translation, | |
| 16 | ) | |
| 17 | ||
| 18 | from xbmcplugin import addDirectoryItem | |
| 19 | ||
| 20 | ||
| 21 | def 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 | ||
| 50 | def 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 | ||
| 70 | def 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 | ||
| 90 | def 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() |
| … | … | @@ -0,0 +1,132 @@ |
| 1 | import re | |
| 2 | from typing import List, Dict, Any, Optional, Callable | |
| 3 | ||
| 4 | from lib.clients.base import BaseClient, TorrentStream | |
| 5 | from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, translation | |
| 6 | from lib.utils.general.utils import ( | |
| 7 | MEDIA_FUSION_DEFAULT_KEY, | |
| 8 | USER_AGENT_HEADER, | |
| 9 | get_cached, | |
| 10 | set_cached, | |
| 11 | ) | |
| 12 | from requests.utils import urlparse | |
| 13 | ||
| 14 | ||
| 15 | class 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 |
| … | … | @@ -0,0 +1,49 @@ |
| 1 | import re | |
| 2 | from lib.clients.base import BaseClient | |
| 3 | from lib.utils.general.utils import USER_AGENT_HEADER | |
| 4 | from lib.utils.kodi.utils import translation | |
| 5 | ||
| 6 | ||
| 7 | class 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 "" |
| … | … | @@ -0,0 +1,47 @@ |
| 1 | from lib.api.plex.media_server import convert_to_plex_id, get_media | |
| 2 | ||
| 3 | ||
| 4 | class 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 |
| … | … | @@ -0,0 +1,193 @@ |
| 1 | from lib.clients.base import BaseClient, TorrentStream | |
| 2 | from lib.jacktook.utils import kodilog | |
| 3 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 4 | from lib.utils.kodi.utils import get_setting, notification, translation | |
| 5 | from lib.utils.kodi.settings import get_prowlarr_timeout | |
| 6 | ||
| 7 | from typing import Dict, List, Optional, Any, Callable | |
| 8 | import concurrent.futures | |
| 9 | ||
| 10 | ||
| 11 | class 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 |
| … | … | @@ -0,0 +1,52 @@ |
| 1 | import json | |
| 2 | import requests | |
| 3 | ||
| 4 | class 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"] |
| … | … | @@ -0,0 +1,302 @@ |
| 1 | from lib.api.stremio.addon_manager import Addon, build_addon_instance_label | |
| 2 | from lib.api.stremio.models import Stream, Meta, MetaPreview | |
| 3 | from lib.clients.base import BaseClient, TorrentStream | |
| 4 | ||
| 5 | from lib.utils.debrid.debrid_utils import process_external_cache | |
| 6 | from lib.utils.general.utils import USER_AGENT_HEADER, IndexerType, info_hash_to_magnet | |
| 7 | from lib.utils.kodi.settings import get_int_setting | |
| 8 | from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, kodilog | |
| 9 | from lib.utils.localization.language_detection import find_languages_in_string | |
| 10 | ||
| 11 | import re | |
| 12 | from typing import List, Dict, Optional, Any | |
| 13 | ||
| 14 | ||
| 15 | EXCLUDED_RD_ADDONS = ["org.nuvio.streams", "org.mycine.addon"] | |
| 16 | ||
| 17 | ||
| 18 | class 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 | ||
| 106 | class 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) |
| … | … | @@ -0,0 +1,517 @@ |
| 1 | import requests | |
| 2 | from datetime import timedelta | |
| 3 | from collections import Counter | |
| 4 | from lib.db.cached import cache | |
| 5 | from lib.utils.general.utils import USER_AGENT_HEADER | |
| 6 | from lib.utils.kodi.utils import ( | |
| 7 | kodilog, | |
| 8 | get_setting, | |
| 9 | set_setting, | |
| 10 | ADDON, | |
| 11 | translation, | |
| 12 | ) | |
| 13 | from lib.utils.kodi.settings import get_int_setting | |
| 14 | from lib.clients.stremio.constants import ( | |
| 15 | STREMIO_ADDONS_KEY, | |
| 16 | STREMIO_ADDONS_CATALOGS_KEY, | |
| 17 | STREMIO_TV_ADDONS_KEY, | |
| 18 | STREMIO_USER_ADDONS, | |
| 19 | excluded_addons, | |
| 20 | encode_selected_ids, | |
| 21 | decode_selected_ids, | |
| 22 | ) | |
| 23 | from lib.clients.stremio.helpers import get_addons, ping_addons, get_addon_merge_key | |
| 24 | from lib.api.stremio.addon_manager import build_addon_instance_key | |
| 25 | import xbmcgui | |
| 26 | ||
| 27 | ||
| 28 | def _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 | ||
| 55 | def _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 | ||
| 79 | def _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 | ||
| 103 | def _show_no_addons_dialog(): | |
| 104 | xbmcgui.Dialog().ok( | |
| 105 | translation(90331), | |
| 106 | translation(90332), | |
| 107 | ) | |
| 108 | ||
| 109 | ||
| 110 | def _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 | ||
| 121 | def _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 | ||
| 134 | def _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 | ||
| 157 | def 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 | ||
| 170 | def 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 | ||
| 204 | def 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 | ||
| 251 | def 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 | ||
| 300 | def 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 | ||
| 426 | def 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 | ||
| 493 | def 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)) |
| … | … | @@ -0,0 +1,109 @@ |
| 1 | from datetime import timedelta | |
| 2 | from lib.api.stremio.api_client import Stremio | |
| 3 | from lib.db.cached import cache | |
| 4 | from lib.utils.kodi.utils import ( | |
| 5 | get_setting, | |
| 6 | kodilog, | |
| 7 | set_setting, | |
| 8 | translation, | |
| 9 | ) | |
| 10 | from lib.clients.stremio.constants import ( | |
| 11 | STREMIO_ADDONS_KEY, | |
| 12 | STREMIO_ADDONS_CATALOGS_KEY, | |
| 13 | STREMIO_USER_ADDONS, | |
| 14 | ) | |
| 15 | from lib.clients.stremio.helpers import merge_addons_lists | |
| 16 | import xbmcgui | |
| 17 | ||
| 18 | ||
| 19 | def 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 | ||
| 37 | def 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 | ||
| 71 | def 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 | ||
| 88 | def 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", "") |
| … | … | @@ -0,0 +1,1021 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | from datetime import datetime, timedelta | |
| 4 | from dataclasses import asdict | |
| 5 | from lib.api.stremio.models import extract_proxy_headers | |
| 6 | from lib.clients.stremio.helpers import ( | |
| 7 | get_addon_by_base_url, | |
| 8 | get_selected_catalogs_addons, | |
| 9 | get_selected_tv_addons, | |
| 10 | ) | |
| 11 | from 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 | ) | |
| 16 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 17 | from lib.utils.general.utils import add_next_button, truncate_text | |
| 18 | from lib.db.cached import cache | |
| 19 | from lib.db.pickle_db import PickleDatabase | |
| 20 | from lib.utils.stremio.catalogs_utils import catalogs_get_cache | |
| 21 | from 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 | ) | |
| 33 | from lib.utils.general.utils import info_hash_to_magnet, IndexerType | |
| 34 | ||
| 35 | from xbmcplugin import addDirectoryItem, setContent | |
| 36 | from lib.utils.kodi.settings import get_cache_expiration | |
| 37 | from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH | |
| 38 | ||
| 39 | ||
| 40 | CATALOG_PAGE_SIZE = 25 | |
| 41 | ||
| 42 | ||
| 43 | def _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 | ||
| 51 | def _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 | ||
| 105 | def clear_stremio_search_history(params): | |
| 106 | cache.clear_list(_stremio_search_history_key(params)) | |
| 107 | _show_search_catalog_history(params) | |
| 108 | ||
| 109 | ||
| 110 | def _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 | ||
| 119 | def _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 | ||
| 152 | def _has_reliable_tmdb_ids(ids): | |
| 153 | return bool((ids or {}).get("tmdb_id")) | |
| 154 | ||
| 155 | ||
| 156 | def _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 | ||
| 186 | def _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 | ||
| 191 | def _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 | ||
| 198 | def _build_stremio_settings_menu_item(): | |
| 199 | return ( | |
| 200 | translation(90116), | |
| 201 | container_update(name="settings"), | |
| 202 | ) | |
| 203 | ||
| 204 | ||
| 205 | def _append_context_menu_items(list_item, context_menu): | |
| 206 | if not context_menu: | |
| 207 | return | |
| 208 | list_item.addContextMenuItems(context_menu, False) | |
| 209 | ||
| 210 | ||
| 211 | def _param_truthy(value): | |
| 212 | return str(value).lower() in ("1", "true", "yes") | |
| 213 | ||
| 214 | ||
| 215 | def 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 | ||
| 306 | def _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 | ||
| 318 | def _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 | ||
| 326 | def 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 | ||
| 388 | def 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 | ||
| 426 | def _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 | ||
| 435 | def 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 | ||
| 441 | def 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 | ||
| 447 | def 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 | ||
| 652 | def 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 | ||
| 709 | def 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 | ||
| 887 | def 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 | ||
| 943 | def 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 | ||
| 986 | def 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() |
| … | … | @@ -0,0 +1,84 @@ |
| 1 | import json | |
| 2 | ||
| 3 | ||
| 4 | def 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 | ||
| 9 | def 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 | ||
| 28 | STREMIO_ADDONS_KEY = "stremio_addons" | |
| 29 | STREMIO_ADDONS_CATALOGS_KEY = "stremio_catalog_addons" | |
| 30 | STREMIO_TV_ADDONS_KEY = "stremio_tv_addons" | |
| 31 | STREMIO_USER_ADDONS = "stremio_user_addons" | |
| 32 | TORRENTIO_PROVIDERS_KEY = "torrentio.providers" | |
| 33 | ||
| 34 | all_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 | ||
| 59 | excluded_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 | } |
| … | … | @@ -0,0 +1,160 @@ |
| 1 | from typing import List, Callable, Optional | |
| 2 | import requests | |
| 3 | import concurrent.futures | |
| 4 | from lib.api.stremio.addon_manager import ( | |
| 5 | Addon, | |
| 6 | AddonManager, | |
| 7 | build_addon_instance_key, | |
| 8 | normalize_transport_url, | |
| 9 | ) | |
| 10 | from lib.api.stremio.api_client import Stremio | |
| 11 | from lib.db.cached import cache | |
| 12 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 13 | from 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 | ) | |
| 20 | def get_addon_merge_key(addon): | |
| 21 | return build_addon_instance_key(addon) | |
| 22 | ||
| 23 | ||
| 24 | def 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 | ||
| 41 | def _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 | ||
| 76 | def 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 | ||
| 104 | def 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 | ||
| 110 | def 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 | ||
| 116 | def 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 | ||
| 122 | def 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 | ||
| 131 | def 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 |
| … | … | @@ -0,0 +1,43 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | from lib.services.webserver import StremioWebServer, get_local_ip | |
| 3 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 4 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 5 | from lib.utils.kodi.utils import ADDON_PATH, kodilog, translation | |
| 6 | ||
| 7 | ||
| 8 | _SERVER_PORT = 8081 | |
| 9 | ||
| 10 | ||
| 11 | def 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") |
| … | … | @@ -0,0 +1,42 @@ |
| 1 | import os | |
| 2 | from datetime import timedelta | |
| 3 | from lib.db.cached import cache | |
| 4 | from lib.utils.kodi.utils import ( | |
| 5 | ADDON_PATH, | |
| 6 | translation, | |
| 7 | ) | |
| 8 | from lib.clients.stremio.constants import ( | |
| 9 | TORRENTIO_PROVIDERS_KEY, | |
| 10 | all_torrentio_providers, | |
| 11 | ) | |
| 12 | import xbmcgui | |
| 13 | ||
| 14 | ||
| 15 | def 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 | ) |
| … | … | @@ -0,0 +1,368 @@ |
| 1 | import traceback | |
| 2 | import requests | |
| 3 | from lib.clients.subtitle.utils import get_deepl_language_code, slugify | |
| 4 | from lib.utils.kodi.utils import ( | |
| 5 | ADDON_PROFILE_PATH, | |
| 6 | get_setting, | |
| 7 | kodilog, | |
| 8 | translation, | |
| 9 | ) | |
| 10 | from os import path as ospath, stat | |
| 11 | from time import sleep | |
| 12 | ||
| 13 | import xbmcgui | |
| 14 | ||
| 15 | ||
| 16 | def show_dialog(title, message): | |
| 17 | # Kodi Yes/No dialog | |
| 18 | dialog = xbmcgui.Dialog() | |
| 19 | return dialog.yesno(title, message) | |
| 20 | ||
| 21 | ||
| 22 | class 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 |
| … | … | @@ -0,0 +1,158 @@ |
| 1 | import requests | |
| 2 | from typing import Callable, List, Optional, Dict, Any | |
| 3 | from lib.clients.subtitle.utils import ( | |
| 4 | get_language_code, | |
| 5 | language_code_to_name, | |
| 6 | slugify_title, | |
| 7 | ) | |
| 8 | from lib.utils.general.utils import USER_AGENT_HEADER | |
| 9 | from lib.utils.kodi.utils import ( | |
| 10 | ADDON_PROFILE_PATH, | |
| 11 | get_setting, | |
| 12 | kodilog, | |
| 13 | translation, | |
| 14 | ) | |
| 15 | import xbmcgui | |
| 16 | import xbmc | |
| 17 | ||
| 18 | ||
| 19 | class 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 |
| … | … | @@ -0,0 +1,138 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | ||
| 4 | from typing import Any, Dict, List, Optional | |
| 5 | from lib.clients.subtitle.deepl import DeepLTranslator | |
| 6 | from lib.clients.subtitle.opensubstremio import OpenSubtitleStremioClient | |
| 7 | from lib.utils.kodi.utils import ( | |
| 8 | ADDON_PROFILE_PATH, | |
| 9 | get_setting, | |
| 10 | kodilog, | |
| 11 | translation, | |
| 12 | ) | |
| 13 | ||
| 14 | import xbmc | |
| 15 | import xbmcgui | |
| 16 | ||
| 17 | ||
| 18 | class 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 | ||
| 36 | class 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 |
| … | … | @@ -0,0 +1,140 @@ |
| 1 | import re | |
| 2 | import unicodedata | |
| 3 | ||
| 4 | ||
| 5 | def 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 | ||
| 11 | def 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 | ||
| 19 | def 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 | ||
| 61 | def 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 | ||
| 101 | def 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})") |
| … | … | @@ -0,0 +1,106 @@ |
| 1 | from lib.clients.tmdb.base import BaseTmdbClient | |
| 2 | from lib.clients.tmdb.utils.utils import ( | |
| 3 | get_tmdb_movie_details, | |
| 4 | get_tmdb_show_details, | |
| 5 | ) | |
| 6 | ||
| 7 | from lib.db.pickle_db import PickleDatabase | |
| 8 | from 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 | ||
| 18 | from 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 | ||
| 28 | class 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) |
| … | … | @@ -0,0 +1,146 @@ |
| 1 | from datetime import datetime | |
| 2 | ||
| 3 | from lib.api.tmdbv3api.as_obj import AsObj | |
| 4 | from lib.utils.general.utils import set_pluging_category | |
| 5 | from 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 | ) | |
| 14 | from lib.clients.tmdb.utils.utils import ( | |
| 15 | add_kodi_dir_item, | |
| 16 | add_tmdb_movie_context_menu, | |
| 17 | add_tmdb_show_context_menu, | |
| 18 | tmdb_get, | |
| 19 | ) | |
| 20 | from lib.api.trakt.trakt_utils import ( | |
| 21 | add_trakt_collection_context_menu, | |
| 22 | add_trakt_custom_list_context_menu, | |
| 23 | add_trakt_favorites_context_menu, | |
| 24 | add_trakt_watchlist_context_menu, | |
| 25 | add_trakt_watched_context_menu, | |
| 26 | is_trakt_auth, | |
| 27 | ) | |
| 28 | ||
| 29 | ||
| 30 | class 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") |
| … | … | @@ -0,0 +1,197 @@ |
| 1 | from lib.clients.tmdb.utils.collections_utils import ( | |
| 2 | POPULAR_COLLECTIONS, | |
| 3 | TOP_RATED_COLLECTIONS, | |
| 4 | ) | |
| 5 | from lib.clients.tmdb.base import BaseTmdbClient | |
| 6 | from lib.db.pickle_db import PickleDatabase | |
| 7 | from lib.utils.general.utils import ( | |
| 8 | add_next_button, | |
| 9 | execute_thread_pool, | |
| 10 | set_media_infoTag, | |
| 11 | set_pluging_category, | |
| 12 | ) | |
| 13 | ||
| 14 | from 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 | ) | |
| 24 | from lib.clients.tmdb.utils.utils import ( | |
| 25 | tmdb_get, | |
| 26 | add_kodi_dir_item, | |
| 27 | get_tmdb_movie_details, | |
| 28 | ) | |
| 29 | ||
| 30 | ||
| 31 | ||
| 32 | ||
| 33 | class 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")) |
| … | … | @@ -0,0 +1,251 @@ |
| 1 | import json | |
| 2 | from lib.clients.tmdb.base import BaseTmdbClient | |
| 3 | from lib.clients.tmdb.utils.utils import add_kodi_dir_item, tmdb_get | |
| 4 | from 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 | ) | |
| 11 | from 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 | ||
| 22 | class 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() |
| … | … | @@ -0,0 +1,1526 @@ |
| 1 | from concurrent.futures import ThreadPoolExecutor | |
| 2 | from datetime import datetime, timedelta | |
| 3 | import json | |
| 4 | import os | |
| 5 | ||
| 6 | from lib.api.tmdbv3api.as_obj import AsObj | |
| 7 | from lib.api.tmdbv3api.objs.anime import TmdbAnime | |
| 8 | from lib.clients.tmdb.anime import TmdbAnimeClient | |
| 9 | from lib.clients.tmdb.base import BaseTmdbClient | |
| 10 | from lib.clients.tmdb.people_client import PeopleClient | |
| 11 | from lib.api.tmdbv3api.objs.search import Search | |
| 12 | from lib.clients.tmdb.utils.utils import add_kodi_dir_item, tmdb_get | |
| 13 | from lib.db.cached import cache | |
| 14 | from 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 | ||
| 23 | from lib.db.pickle_db import PickleDatabase | |
| 24 | from 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 | ||
| 39 | from lib.utils.views.shows import show_episode_info, show_season_info | |
| 40 | from lib.utils.views.weekly_calendar import is_this_week, parse_date_str | |
| 41 | from lib.utils.views.weekly_calendar import get_episodes_for_show | |
| 42 | ||
| 43 | from lib.utils.general.utils import Anime | |
| 44 | ||
| 45 | ||
| 46 | def _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 | ||
| 57 | import xbmcgui | |
| 58 | import xbmc | |
| 59 | ||
| 60 | ||
| 61 | class 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 | ) |
| … | … | @@ -0,0 +1,140 @@ |
| 1 | import os | |
| 2 | from typing import Iterable, Optional | |
| 3 | ||
| 4 | from lib.api.tmdbv3api.objs.movie import Movie | |
| 5 | from lib.api.tmdbv3api.objs.tv import TV | |
| 6 | from lib.utils.kodi.utils import kodilog | |
| 7 | ||
| 8 | ||
| 9 | YOUTUBE_WATCH_URL = "https://www.youtube.com/watch?v={}" | |
| 10 | ||
| 11 | ||
| 12 | def _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 | ||
| 24 | def 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 | ||
| 63 | def _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 | ||
| 76 | def _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 | ||
| 92 | def 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 |
| … | … | @@ -0,0 +1,157 @@ |
| 1 | TOP_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 | ||
| 61 | POPULAR_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 | ] |
| … | … | @@ -0,0 +1,693 @@ |
| 1 | import json | |
| 2 | from datetime import datetime, timedelta | |
| 3 | import os | |
| 4 | import threading | |
| 5 | from typing import List, Optional | |
| 6 | ||
| 7 | from lib.api.mdblist.mdblist import MDblistAPI | |
| 8 | from lib.api.tmdbv3api.as_obj import AsObj | |
| 9 | from lib.api.tmdbv3api.objs.anime import TmdbAnime | |
| 10 | from lib.api.tmdbv3api.objs.collection import Collection | |
| 11 | from lib.api.tmdbv3api.objs.episode import Episode | |
| 12 | from lib.api.tmdbv3api.objs.find import Find | |
| 13 | from lib.api.tmdbv3api.objs.genre import Genre | |
| 14 | from lib.api.tmdbv3api.objs.movie import Movie | |
| 15 | from lib.api.tmdbv3api.objs.person import Person | |
| 16 | from lib.api.tmdbv3api.objs.search import Search | |
| 17 | from lib.api.tmdbv3api.objs.season import Season | |
| 18 | from lib.api.tmdbv3api.objs.discover import Discover | |
| 19 | from lib.api.tmdbv3api.objs.trending import Trending | |
| 20 | from lib.api.tmdbv3api.objs.tv import TV | |
| 21 | ||
| 22 | from 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 | ) | |
| 32 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 33 | from lib.utils.general.utils import execute_thread_pool | |
| 34 | from urllib import parse | |
| 35 | ||
| 36 | from lib.db.cached import cache | |
| 37 | from xbmcplugin import addDirectoryItem | |
| 38 | ||
| 39 | ||
| 40 | LANGUAGES = [ | |
| 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 | ||
| 111 | FULL_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 | ||
| 147 | NETWORKS = [ | |
| 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 | ||
| 234 | def 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 | ||
| 246 | def 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 | ||
| 255 | def 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 | ||
| 264 | def 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 | ||
| 367 | def 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 | ||
| 404 | def 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 | ||
| 411 | def get_tmdb_movie_details(id: str) -> Optional[AsObj]: | |
| 412 | return tmdb_get("movie_details", id) | |
| 413 | ||
| 414 | ||
| 415 | def get_tmdb_show_details(id: str) -> Optional[AsObj]: | |
| 416 | return tmdb_get("tv_details", id) | |
| 417 | ||
| 418 | ||
| 419 | def 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 | ||
| 441 | def _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 | ||
| 448 | def 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 | ||
| 472 | def 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 | ||
| 568 | def 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 | ||
| 630 | def 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 | ] |
| … | … | @@ -0,0 +1,99 @@ |
| 1 | import re | |
| 2 | from typing import List, Dict, Tuple, Optional, Any, Callable | |
| 3 | ||
| 4 | from lib.clients.base import BaseClient, TorrentStream | |
| 5 | from lib.utils.localization.countries import find_language_by_unicode | |
| 6 | from lib.utils.kodi.utils import convert_size_to_bytes, kodilog, translation | |
| 7 | from lib.utils.general.utils import USER_AGENT_HEADER, unicode_flag_to_country_code | |
| 8 | ||
| 9 | ||
| 10 | class 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 "" |
| … | … | @@ -0,0 +1,77 @@ |
| 1 | import json | |
| 2 | from lib.api.trakt.base_cache import connect_database | |
| 3 | ||
| 4 | ||
| 5 | class 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 | ||
| 77 | paginator_db = Paginator() |
| … | … | @@ -0,0 +1,1633 @@ |
| 1 | import json | |
| 2 | from datetime import datetime, timedelta | |
| 3 | from lib.clients.tmdb.utils.utils import build_play_trailer_context_menu_item, tmdb_get | |
| 4 | from lib.api.trakt.trakt import TraktAPI, TraktLists, TraktMovies, TraktTV | |
| 5 | from 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 | ) | |
| 12 | from lib.clients.trakt.utils import add_kodi_dir_item, extract_ids | |
| 13 | from lib.api.trakt.trakt_utils import add_trakt_collection_context_menu | |
| 14 | from 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 | ) | |
| 23 | from 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 | ) | |
| 43 | from .paginator import paginator_db | |
| 44 | ||
| 45 | ||
| 46 | def _normalize_user_slug(value): | |
| 47 | if value in (None, "", "None"): | |
| 48 | return "" | |
| 49 | return str(value) | |
| 50 | ||
| 51 | ||
| 52 | class 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 | ||
| 75 | class 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 | ||
| 205 | class 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 | ||
| 1035 | class 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) |
| … | … | @@ -0,0 +1,23 @@ |
| 1 | from lib.utils.kodi.utils import ADDON_HANDLE | |
| 2 | ||
| 3 | from xbmcplugin import addDirectoryItem | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 17 | def 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 | } |
| … | … | @@ -0,0 +1,128 @@ |
| 1 | import requests | |
| 2 | from lib.api.webdav.webdav import WebDAVClient | |
| 3 | from 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 | ) | |
| 15 | from xbmcgui import Dialog | |
| 16 | from xbmcplugin import addDirectoryItem, addSortMethod, SORT_METHOD_LABEL_IGNORE_THE, SORT_METHOD_FILE | |
| 17 | ||
| 18 | ||
| 19 | def 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 | ||
| 93 | def 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 | ||
| 108 | def 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 | ||
| 127 | def show_picture_webdav(params): | |
| 128 | show_picture(url=params.get("url")) |
| … | … | @@ -0,0 +1,534 @@ |
| 1 | import re | |
| 2 | import time | |
| 3 | from typing import Dict, List, Optional | |
| 4 | from urllib.parse import parse_qs, urlparse | |
| 5 | ||
| 6 | import requests | |
| 7 | ||
| 8 | from lib.clients.tmdb.trailers import resolve_tmdb_trailer | |
| 9 | from lib.utils.kodi.utils import kodilog | |
| 10 | ||
| 11 | ||
| 12 | WATCH_URL = "https://www.youtube.com/watch" | |
| 13 | PLAYER_URL = "https://www.youtube.com/youtubei/v1/player" | |
| 14 | CACHE_TTL_SECONDS = 300 | |
| 15 | VIDEO_CONTAINER_NEAR_TIE_BITRATE_MARGIN = 250000 | |
| 16 | VIDEO_CONTAINER_NEAR_TIE_RATIO = 0.10 | |
| 17 | WATCH_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 | } | |
| 24 | YOUTUBE_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 | ||
| 86 | def 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 | ||
| 116 | def 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 | ||
| 131 | def _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 | ||
| 139 | def _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 | ||
| 152 | def _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 | ||
| 161 | def _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 | ||
| 166 | def _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 | ||
| 171 | def _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 | ||
| 179 | def _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 | ||
| 206 | def _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 | ||
| 217 | def 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 | ||
| 268 | def 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 | ||
| 356 | def 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 | ||
| 378 | def 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 | ||
| 411 | def 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 | ||
| 447 | def _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 | ||
| 500 | def _is_playable(player_response: Dict) -> bool: | |
| 501 | status = ((player_response.get("playabilityStatus") or {}).get("status") or "").upper() | |
| 502 | return status == "OK" | |
| 503 | ||
| 504 | ||
| 505 | def _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 | ||
| 518 | def _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 | ||
| 530 | def _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 | } |
| … | … | @@ -0,0 +1,126 @@ |
| 1 | import json | |
| 2 | import logging | |
| 3 | from types import SimpleNamespace | |
| 4 | from requests import ConnectTimeout, ReadTimeout | |
| 5 | from requests.exceptions import RequestException | |
| 6 | from lib.clients.base import BaseClient, TorrentStream | |
| 7 | from lib.utils.general.utils import USER_AGENT_HEADER, info_hash_to_magnet | |
| 8 | from typing import List, Optional, Dict, Any, Callable | |
| 9 | from lib.utils.kodi.utils import notification | |
| 10 | ||
| 11 | ||
| 12 | class 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 | ||
| 125 | class RateLimitExceeded(Exception): | |
| 126 | pass |
| … | … | @@ -0,0 +1,65 @@ |
| 1 | import os | |
| 2 | import threading | |
| 3 | from sqlite3 import dbapi2 as db | |
| 4 | from lib.utils.kodi.utils import notification, translation | |
| 5 | ||
| 6 | import xbmcaddon | |
| 7 | from xbmcvfs import translatePath | |
| 8 | ||
| 9 | ||
| 10 | try: | |
| 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 | |
| 21 | except: | |
| 22 | OTAKU_ADDON = None | |
| 23 | mappingDB = None | |
| 24 | ||
| 25 | mappingDB_lock = threading.Lock() | |
| 26 | ||
| 27 | ||
| 28 | def 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 | ||
| 56 | def try_release_lock(lock): | |
| 57 | if lock.locked(): | |
| 58 | lock.release() | |
| 59 | ||
| 60 | ||
| 61 | def _dict_factory(cursor, row): | |
| 62 | d = {} | |
| 63 | for idx, col in enumerate(cursor.description): | |
| 64 | d[col[0]] = row[idx] | |
| 65 | return d |
| … | … | @@ -0,0 +1,429 @@ |
| 1 | import os | |
| 2 | import pickle | |
| 3 | import sqlite3 | |
| 4 | import sys | |
| 5 | import threading | |
| 6 | import traceback | |
| 7 | import re | |
| 8 | ||
| 9 | from base64 import b64encode, b64decode | |
| 10 | from datetime import datetime, timedelta | |
| 11 | from hashlib import sha256 | |
| 12 | ||
| 13 | from lib.jacktook.utils import kodilog | |
| 14 | ||
| 15 | import xbmcaddon | |
| 16 | import xbmc | |
| 17 | import xbmcgui | |
| 18 | ||
| 19 | PY3 = sys.version_info.major >= 3 | |
| 20 | if PY3: | |
| 21 | from xbmcvfs import translatePath | |
| 22 | else: | |
| 23 | from xbmc import translatePath | |
| 24 | ||
| 25 | ADDON_DATA = translatePath( | |
| 26 | xbmcaddon.Addon("plugin.video.jacktook").getAddonInfo("profile") | |
| 27 | ) | |
| 28 | ADDON_ID = xbmcaddon.Addon().getAddonInfo("id") | |
| 29 | ||
| 30 | if not PY3: | |
| 31 | ADDON_DATA = ADDON_DATA.decode("utf-8") | |
| 32 | ||
| 33 | if not os.path.exists(ADDON_DATA): | |
| 34 | os.makedirs(ADDON_DATA) | |
| 35 | ||
| 36 | SQLITE_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 | ||
| 45 | def pickle_hash(obj): | |
| 46 | data = pickle.dumps(obj) | |
| 47 | h = sha256() | |
| 48 | h.update(data) | |
| 49 | return h.hexdigest() | |
| 50 | ||
| 51 | ||
| 52 | class _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 | ||
| 96 | class 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 | ||
| 117 | class 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 | ||
| 214 | class 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 | ||
| 274 | class 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 | ||
| 429 | cache = HybridCache() |
| … | … | @@ -0,0 +1,74 @@ |
| 1 | import os | |
| 2 | import pickle | |
| 3 | import xbmcvfs | |
| 4 | ||
| 5 | from lib.jacktook.utils import kodilog | |
| 6 | from lib.utils.kodi.utils import ADDON_ID | |
| 7 | ||
| 8 | ||
| 9 | class 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 |
| … | … | @@ -0,0 +1,42 @@ |
| 1 | from dataclasses import dataclass, field | |
| 2 | from typing import Dict, List | |
| 3 | ||
| 4 | ||
| 5 | @dataclass | |
| 6 | class 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 |
| … | … | @@ -0,0 +1,332 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | import ssl | |
| 4 | import threading | |
| 5 | import re | |
| 6 | from urllib.request import Request, urlopen | |
| 7 | from urllib.parse import parse_qsl, unquote, urlparse | |
| 8 | from lib.utils.general.utils import set_pluging_category, supported_video_extensions | |
| 9 | from 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 | ) | |
| 22 | from lib.db.cached import MemoryCache | |
| 23 | from lib.gui.custom_progress import CustomProgressDialog | |
| 24 | ||
| 25 | from xbmcplugin import ( | |
| 26 | addDirectoryItems, | |
| 27 | endOfDirectory, | |
| 28 | setContent, | |
| 29 | ) | |
| 30 | import xbmcgui | |
| 31 | import xbmcvfs | |
| 32 | import xbmc | |
| 33 | ||
| 34 | ||
| 35 | cancel_flag_cache = MemoryCache() | |
| 36 | ||
| 37 | ||
| 38 | def 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 | ||
| 57 | def download_video(params): | |
| 58 | data = json.loads(params["data"]) | |
| 59 | download_dir = get_setting("download_dir") | |
| 60 | destination = translatePath(download_dir) | |
| 61 | handle_download_file( | |
| 62 | {"destination": destination, "file_name": data["title"], "url": data["url"]} | |
| 63 | ) | |
| 64 | ||
| 65 | ||
| 66 | class ProgressHandler: | |
| 67 | def update(self, percent: int, message: str): | |
| 68 | pass | |
| 69 | ||
| 70 | def cancelled(self) -> bool: | |
| 71 | return False | |
| 72 | ||
| 73 | def close(self): | |
| 74 | pass | |
| 75 | ||
| 76 | ||
| 77 | class KodiProgressHandler(ProgressHandler): | |
| 78 | def __init__(self, title: str, addon_path: str): | |
| 79 | self.dialog = CustomProgressDialog("custom_progress_dialog.xml", addon_path) | |
| 80 | self.dialog.show_dialog() | |
| 81 | ||
| 82 | def update(self, percent: int, message: str): | |
| 83 | self.dialog.update_progress(percent, message) | |
| 84 | ||
| 85 | def cancelled(self) -> bool: | |
| 86 | return self.dialog.cancelled | |
| 87 | ||
| 88 | def close(self): | |
| 89 | self.dialog.close() | |
| 90 | ||
| 91 | ||
| 92 | class Downloader: | |
| 93 | def __init__( | |
| 94 | self, | |
| 95 | url: str, | |
| 96 | destination: str, | |
| 97 | name: str, | |
| 98 | ): | |
| 99 | self.url = url | |
| 100 | self.destination = destination | |
| 101 | self.name = name | |
| 102 | self.headers = {} | |
| 103 | self.file_size = 0 | |
| 104 | self.monitor = xbmc.Monitor() | |
| 105 | self.progress_handler = None | |
| 106 | self.is_cancelled = False | |
| 107 | self.dest_path = os.path.join(destination, name) | |
| 108 | ||
| 109 | def run(self): | |
| 110 | if not self.url: | |
| 111 | notification("No URL provided for download.") | |
| 112 | return | |
| 113 | thread = threading.Thread(target=self._run, daemon=True) | |
| 114 | thread.start() | |
| 115 | ||
| 116 | def _run(self): | |
| 117 | self._prepare_url() | |
| 118 | if not self._validate_url(): | |
| 119 | notification("Invalid URL for download.") | |
| 120 | return | |
| 121 | self._start_download() | |
| 122 | ||
| 123 | def _prepare_url(self): | |
| 124 | try: | |
| 125 | if "|" in self.url: | |
| 126 | self.headers = dict(parse_qsl(self.url.rsplit("|", 1)[1])) | |
| 127 | self.url = self.url.split("|")[0] | |
| 128 | except Exception as e: | |
| 129 | kodilog(f"[Downloader] Failed to parse headers: {str(e)}") | |
| 130 | ||
| 131 | def _validate_url(self): | |
| 132 | try: | |
| 133 | if not self.headers.get("User-Agent"): | |
| 134 | from lib.utils.general.utils import USER_AGENT_STRING | |
| 135 | ||
| 136 | self.headers["User-Agent"] = USER_AGENT_STRING | |
| 137 | ||
| 138 | request = Request(self.url, headers=self.headers) | |
| 139 | context = ssl.create_default_context() | |
| 140 | context.check_hostname = False | |
| 141 | context.verify_mode = ssl.CERT_NONE | |
| 142 | ||
| 143 | response = urlopen(request, context=context) | |
| 144 | self.file_size = int(response.headers.get("Content-Length", 0)) | |
| 145 | return True | |
| 146 | except Exception as e: | |
| 147 | kodilog(f"[Downloader] Validation error: {str(e)}") | |
| 148 | return False | |
| 149 | ||
| 150 | def _start_download(self): | |
| 151 | downloaded = 0 | |
| 152 | file_mode = "wb" | |
| 153 | ||
| 154 | self.progress_handler = KodiProgressHandler("Downloading", ADDON_PATH) | |
| 155 | ||
| 156 | try: | |
| 157 | # Resume support | |
| 158 | if os.path.exists(self.dest_path): | |
| 159 | downloaded = os.path.getsize(self.dest_path) | |
| 160 | if downloaded < self.file_size: | |
| 161 | self.headers["Range"] = f"bytes={downloaded}-" | |
| 162 | file_mode = "ab" | |
| 163 | elif downloaded >= self.file_size: | |
| 164 | notification(f"File already downloaded: {self.name}") | |
| 165 | return | |
| 166 | ||
| 167 | request = Request(self.url, headers=self.headers) | |
| 168 | context = ssl.create_default_context() | |
| 169 | context.check_hostname = False | |
| 170 | context.verify_mode = ssl.CERT_NONE | |
| 171 | response = urlopen(request, context=context) | |
| 172 | ||
| 173 | with open_file(self.dest_path, file_mode) as file: | |
| 174 | while not self.monitor.abortRequested(): | |
| 175 | chunk = response.read(1024 * 1024) # 1MB chunks | |
| 176 | if not chunk: | |
| 177 | kodilog("No more data to read") | |
| 178 | break | |
| 179 | ||
| 180 | file.write(chunk) | |
| 181 | downloaded += len(chunk) | |
| 182 | ||
| 183 | if self.file_size: | |
| 184 | percent = int((downloaded / self.file_size) * 100) | |
| 185 | else: | |
| 186 | percent = 0 | |
| 187 | ||
| 188 | self.progress_handler.update( | |
| 189 | percent, | |
| 190 | f"{self.name} - {percent}% - {bytes_to_human_readable(downloaded)} / {bytes_to_human_readable(self.file_size)}", | |
| 191 | ) | |
| 192 | ||
| 193 | # Handle cancellation | |
| 194 | if ( | |
| 195 | self.progress_handler.cancelled() | |
| 196 | or cancel_flag_cache.get(self.dest_path) is True | |
| 197 | ): | |
| 198 | cancel_flag_cache.set(self.dest_path, True) | |
| 199 | self.is_cancelled = True | |
| 200 | file.close() | |
| 201 | break | |
| 202 | ||
| 203 | self.monitor.waitForAbort(0.1) | |
| 204 | ||
| 205 | if self.is_cancelled: | |
| 206 | notification(f"Download cancelled: {self.name}") | |
| 207 | else: | |
| 208 | notification(f"Download completed: {self.name}") | |
| 209 | except Exception as e: | |
| 210 | notification(f"Download error: {str(e)}") | |
| 211 | finally: | |
| 212 | self.progress_handler.close() | |
| 213 | ||
| 214 | ||
| 215 | def handle_cancel_download(params): | |
| 216 | file_path = json.loads(params.get("file_path")) | |
| 217 | if file_path: | |
| 218 | cancel_flag_cache.set(file_path, True) | |
| 219 | xbmc.executebuiltin("Container.Refresh") | |
| 220 | else: | |
| 221 | notification("No active download found.") | |
| 222 | ||
| 223 | ||
| 224 | def handle_delete_file(params): | |
| 225 | file_path = json.loads(params.get("file_path")) | |
| 226 | if not xbmcvfs.exists(file_path): | |
| 227 | notification("File not found.") | |
| 228 | return | |
| 229 | try: | |
| 230 | xbmcvfs.delete(file_path) | |
| 231 | notification(f"File Deleted") | |
| 232 | xbmc.executebuiltin("Container.Refresh") | |
| 233 | except Exception as e: | |
| 234 | kodilog(f"Error deleting file: {str(e)}") | |
| 235 | notification(f"Failed to delete file: {str(e)}") | |
| 236 | ||
| 237 | ||
| 238 | def downloads_viewer(params): | |
| 239 | set_pluging_category(translation(90015)) | |
| 240 | translated_path = translatePath(get_setting("download_dir")) | |
| 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 | ||
| 305 | def is_active_download(path): | |
| 306 | cancel_flag = cancel_flag_cache.get(path) | |
| 307 | return cancel_flag is not True | |
| 308 | ||
| 309 | ||
| 310 | def 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}" |
| … | … | @@ -0,0 +1,132 @@ |
| 1 | import threading | |
| 2 | from lib.gui.base_window import BaseWindow | |
| 3 | from lib.utils.kodi.utils import ( | |
| 4 | ADDON_PATH, | |
| 5 | kodilog, | |
| 6 | notification, | |
| 7 | execute_builtin, | |
| 8 | ) | |
| 9 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 10 | import xbmcgui | |
| 11 | ||
| 12 | ||
| 13 | class 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() |
| … | … | @@ -0,0 +1,273 @@ |
| 1 | import abc | |
| 2 | from copy import deepcopy | |
| 3 | import hashlib | |
| 4 | import json | |
| 5 | from typing import Any, Dict, Tuple | |
| 6 | from lib.domain.torrent import TorrentStream | |
| 7 | from lib.utils.debrid.debrid_utils import get_magnet_from_uri, _is_torrent_ready_in_debrid | |
| 8 | from lib.utils.player.utils import resolve_playback_url | |
| 9 | from lib.utils.general.utils import Indexer, IndexerType, truncate_text | |
| 10 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 11 | from lib.utils.kodi.utils import ADDON, kodilog, translation | |
| 12 | import xbmcgui | |
| 13 | ||
| 14 | ||
| 15 | ACTION_PREVIOUS_MENU = 10 | |
| 16 | ACTION_PLAYER_STOP = 13 | |
| 17 | ACTION_NAV_BACK = 92 | |
| 18 | ||
| 19 | ||
| 20 | class BaseWindow(xbmcgui.WindowXMLDialog): | |
| 21 | def __init__(self, xml_file, location, item_information=None, previous_window=None): | |
| 22 | super().__init__(xml_file, location) | |
| 23 | self.item_information = {} | |
| 24 | self.previous_window = previous_window | |
| 25 | self.CACHE_KEY = "" | |
| 26 | self._last_focused_control = (None, None) | |
| 27 | self.action_exitkeys_id = { | |
| 28 | ACTION_PREVIOUS_MENU, | |
| 29 | ACTION_PLAYER_STOP, | |
| 30 | ACTION_NAV_BACK, | |
| 31 | } | |
| 32 | if item_information is None: | |
| 33 | return | |
| 34 | self.add_item_information_to_window(item_information) | |
| 35 | ||
| 36 | def onInit(self): | |
| 37 | pass | |
| 38 | ||
| 39 | def get_cached_focus(self): | |
| 40 | if not self.CACHE_KEY: | |
| 41 | return None, None | |
| 42 | ||
| 43 | cache_prop = self._focus_cache_prop() | |
| 44 | cached_data = xbmcgui.Window(10000).getProperty(cache_prop) | |
| 45 | if not cached_data: | |
| 46 | cached_data = ADDON.getSetting(self.CACHE_KEY) | |
| 47 | if cached_data: | |
| 48 | xbmcgui.Window(10000).setProperty(cache_prop, cached_data) | |
| 49 | ADDON.setSetting(self.CACHE_KEY, "") | |
| 50 | if cached_data: | |
| 51 | try: | |
| 52 | return json.loads(cached_data) | |
| 53 | except json.JSONDecodeError: | |
| 54 | return None, None | |
| 55 | return None, None | |
| 56 | ||
| 57 | def set_cached_focus(self, control_id, item_id): | |
| 58 | if not self.CACHE_KEY: | |
| 59 | return | |
| 60 | cache_data = json.dumps((control_id, item_id)) | |
| 61 | xbmcgui.Window(10000).setProperty(self._focus_cache_prop(), cache_data) | |
| 62 | ||
| 63 | def _focus_cache_prop(self): | |
| 64 | cache_key = str(self.CACHE_KEY or "") | |
| 65 | cache_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest() | |
| 66 | return f"jacktook.focus.{cache_hash}" | |
| 67 | ||
| 68 | def set_default_focus( | |
| 69 | self, control_list=None, control_id=None, control_list_reset=False | |
| 70 | ): | |
| 71 | try: | |
| 72 | # Retrieve cached focus if available | |
| 73 | control_id, item_id = self.get_cached_focus() | |
| 74 | if control_id and item_id: | |
| 75 | control = self.getControl(control_id) | |
| 76 | if isinstance(control, xbmcgui.ControlList): | |
| 77 | control.selectItem(int(item_id)) | |
| 78 | self.setFocus(control) | |
| 79 | return | |
| 80 | ||
| 81 | if control_list and control_list.size() > 0: | |
| 82 | if control_list_reset: | |
| 83 | control_list.selectItem(0) | |
| 84 | self.setFocus(control_list) | |
| 85 | elif control_id: | |
| 86 | control = self.getControl(control_id) | |
| 87 | self.setFocus(control) | |
| 88 | else: | |
| 89 | raise ValueError("Neither valid control list nor control ID provided.") | |
| 90 | except (RuntimeError, ValueError) as e: | |
| 91 | kodilog(f"Could not set focus: {e}") | |
| 92 | if control_id: | |
| 93 | self.setFocusId(control_id) | |
| 94 | ||
| 95 | def getControlList(self, control_id): | |
| 96 | try: | |
| 97 | control = self.getControl(control_id) | |
| 98 | except RuntimeError as e: | |
| 99 | kodilog(f"Control does not exist {control_id}", {e}) | |
| 100 | raise ValueError(f"Control with Id {control_id} does not exist") | |
| 101 | ||
| 102 | if not isinstance(control, xbmcgui.ControlList): | |
| 103 | raise AttributeError( | |
| 104 | f"Control with Id {control_id} should be of type ControlList" | |
| 105 | ) | |
| 106 | return control | |
| 107 | ||
| 108 | def add_item_information_to_window(self, item_information): | |
| 109 | self.item_information = deepcopy(item_information) | |
| 110 | for i in self.item_information: | |
| 111 | value = self.item_information[i] | |
| 112 | if i == "overview": | |
| 113 | value = truncate_text(str(value)) | |
| 114 | try: | |
| 115 | self.setProperty(f"info.{i}", str(value)) | |
| 116 | except UnicodeEncodeError: | |
| 117 | self.setProperty(f"info.{i}", value) | |
| 118 | ||
| 119 | def getControlProgress(self, control_id): | |
| 120 | control = self.getControl(control_id) | |
| 121 | if not isinstance(control, xbmcgui.ControlProgress): | |
| 122 | raise AttributeError( | |
| 123 | f"Control with Id {control_id} should be of type ControlProgress" | |
| 124 | ) | |
| 125 | ||
| 126 | return control | |
| 127 | ||
| 128 | def onClick(self, control_id): | |
| 129 | self.handle_action(7, control_id) | |
| 130 | ||
| 131 | def onAction(self, action): | |
| 132 | action_id = action.getId() | |
| 133 | if action_id in self.action_exitkeys_id: | |
| 134 | if self.previous_window: | |
| 135 | self.previous_window.setProperty("instant_close", "true") | |
| 136 | self.previous_window.close() | |
| 137 | self.close() | |
| 138 | elif action_id != 7: | |
| 139 | self.handle_action(action_id, self.getFocusId()) | |
| 140 | ||
| 141 | def _ensure_playback_info(self, source: TorrentStream): | |
| 142 | url, magnet, is_torrent = self._extract_source_details(source) | |
| 143 | ||
| 144 | if source.addedToDebrid and source.debridType and source.infoHash: | |
| 145 | is_ready = _is_torrent_ready_in_debrid(source.debridType, source.infoHash) | |
| 146 | if not is_ready: | |
| 147 | dialog = xbmcgui.Dialog() | |
| 148 | message = ( | |
| 149 | f"{translation(90696) % source.debridType}\n\n" | |
| 150 | f"{translation(90697)}" | |
| 151 | ) | |
| 152 | choice = dialog.yesno( | |
| 153 | translation(90695), | |
| 154 | message, | |
| 155 | yeslabel=translation(90698), | |
| 156 | nolabel=translation(90699) | |
| 157 | ) | |
| 158 | if not choice: | |
| 159 | return {} | |
| 160 | source.addedToDebrid = False | |
| 161 | source.debridType = "" | |
| 162 | ||
| 163 | source_data = self.prepare_source_data( | |
| 164 | source=source, | |
| 165 | url=url, | |
| 166 | magnet=magnet, | |
| 167 | is_torrent=is_torrent, | |
| 168 | ) | |
| 169 | return resolve_playback_url(source_data) or {} | |
| 170 | ||
| 171 | def _extract_source_details(self, source: TorrentStream) -> Tuple[str, str, bool]: | |
| 172 | url = source.url or "" | |
| 173 | guid = source.guid or "" | |
| 174 | magnet = "" | |
| 175 | indexer = source.indexer | |
| 176 | source_type = source.type | |
| 177 | is_torrent = source.type == IndexerType.TORRENT | |
| 178 | ||
| 179 | kodilog( | |
| 180 | "Source extraction input: indexer={!r}, type={!r}, guid={!r}, url={!r}, infohash={!r}".format( | |
| 181 | indexer, | |
| 182 | source_type, | |
| 183 | summarize_locator_for_log(guid), | |
| 184 | summarize_locator_for_log(url), | |
| 185 | str(source.infoHash or "").lower()[:12], | |
| 186 | ) | |
| 187 | ) | |
| 188 | ||
| 189 | if source_type in (IndexerType.DIRECT, IndexerType.STREMIO_DEBRID): | |
| 190 | return url, magnet, is_torrent | |
| 191 | ||
| 192 | # Prefer magnet in guid | |
| 193 | if guid.startswith("magnet:?"): | |
| 194 | magnet = guid | |
| 195 | # Handle Burst indexer | |
| 196 | elif indexer == Indexer.BURST: | |
| 197 | url = guid | |
| 198 | # Prefer .torrent in guid or url | |
| 199 | elif guid.endswith(".torrent"): | |
| 200 | url = guid | |
| 201 | elif url.endswith(".torrent"): | |
| 202 | pass | |
| 203 | ||
| 204 | # Try to extract magnet from url | |
| 205 | if url.startswith("http") and not url.endswith(".torrent"): | |
| 206 | magnet_candidate, _, torrent_url = get_magnet_from_uri(url) | |
| 207 | if torrent_url: | |
| 208 | url = torrent_url | |
| 209 | elif magnet_candidate: | |
| 210 | magnet = magnet_candidate | |
| 211 | ||
| 212 | # Try to extract magnet from guid if it's a details page | |
| 213 | elif not magnet and guid.startswith("http"): | |
| 214 | magnet_candidate, _, torrent_url = get_magnet_from_uri(guid) | |
| 215 | if magnet_candidate: | |
| 216 | magnet = magnet_candidate | |
| 217 | elif torrent_url: | |
| 218 | url = torrent_url | |
| 219 | ||
| 220 | # --- Fallback: magnet from url --- | |
| 221 | if not magnet and url.startswith("magnet:?"): | |
| 222 | magnet, url = url, "" | |
| 223 | ||
| 224 | # --- Fallback: magnet from infoHash --- | |
| 225 | used_infohash_fallback = False | |
| 226 | has_http_url = bool(url and url.startswith("http")) | |
| 227 | if not magnet and source.infoHash and not has_http_url: | |
| 228 | from lib.utils.general.utils import info_hash_to_magnet | |
| 229 | ||
| 230 | magnet = info_hash_to_magnet(source.infoHash) | |
| 231 | used_infohash_fallback = True | |
| 232 | ||
| 233 | kodilog( | |
| 234 | "Source extraction output: url={!r}, magnet={!r}, is_torrent={}, used_infohash_fallback={}".format( | |
| 235 | summarize_locator_for_log(url), | |
| 236 | summarize_locator_for_log(magnet), | |
| 237 | is_torrent, | |
| 238 | used_infohash_fallback, | |
| 239 | ) | |
| 240 | ) | |
| 241 | ||
| 242 | return url, magnet, is_torrent | |
| 243 | ||
| 244 | def prepare_source_data( | |
| 245 | self, | |
| 246 | source: TorrentStream, | |
| 247 | url: str, | |
| 248 | magnet: str, | |
| 249 | is_torrent: bool, | |
| 250 | pack_select: bool = False, | |
| 251 | ) -> Dict[str, Any]: | |
| 252 | """Prepare the source data dictionary for resolving playback.""" | |
| 253 | return { | |
| 254 | "type": source.type, | |
| 255 | "debrid_type": source.debridType, | |
| 256 | "indexer": source.indexer, | |
| 257 | "url": url, | |
| 258 | "magnet": magnet, | |
| 259 | # getattr: cached results are unpickled, and pickle restores __dict__ | |
| 260 | # directly without applying dataclass defaults — so sources pickled | |
| 261 | # before this field existed genuinely lack the attribute. | |
| 262 | "proxyHeaders": getattr(source, "proxyHeaders", None) or {}, | |
| 263 | "info_hash": source.infoHash, | |
| 264 | "is_torrent": is_torrent, | |
| 265 | "is_pack": pack_select, | |
| 266 | "mode": self.item_information.get("mode"), | |
| 267 | "ids": self.item_information.get("ids"), | |
| 268 | "tv_data": self.item_information.get("tv_data"), | |
| 269 | } | |
| 270 | ||
| 271 | @abc.abstractmethod | |
| 272 | def handle_action(self, action_id, control_id=None): | |
| 273 | pass |
| … | … | @@ -0,0 +1,334 @@ |
| 1 | import json | |
| 2 | from typing import Dict, List | |
| 3 | ||
| 4 | from lib.domain.torrent import TorrentStream | |
| 5 | from lib.gui.custom_progress import CustomProgressDialog | |
| 6 | from lib.gui.play_next_window import PlayNext | |
| 7 | from lib.gui.resolver_window import ResolverWindow | |
| 8 | from lib.gui.resume_window import ResumeDialog | |
| 9 | from lib.utils.kodi.utils import ADDON_PATH, PLAYLIST, kodilog, translation | |
| 10 | from lib.gui.source_select import SourceSelect | |
| 11 | from lib.gui.search_status_window import SearchTaskManager, SearchStatusWindow | |
| 12 | from lib.domain.torrent import TorrentStream | |
| 13 | from concurrent.futures import ThreadPoolExecutor | |
| 14 | ||
| 15 | from xbmcgui import WindowXMLDialog, WindowXML | |
| 16 | import xbmcgui | |
| 17 | import xbmc | |
| 18 | ||
| 19 | ||
| 20 | class CustomWindow(WindowXML): | |
| 21 | def __init__(self, *args, **kwargs): | |
| 22 | super().__init__(*args, **kwargs) | |
| 23 | ||
| 24 | def onInit(self): | |
| 25 | pass | |
| 26 | ||
| 27 | ||
| 28 | class 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 | ||
| 54 | class 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 | ||
| 86 | fake_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 | ||
| 114 | def 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 | ||
| 129 | def 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 | ||
| 214 | def 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 | ||
| 229 | def 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 | ||
| 252 | def 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 | ||
| 266 | def 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 | ||
| 279 | def 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 | ||
| 287 | def 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 | ||
| 302 | def 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 | ||
| 313 | def 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 |
| … | … | @@ -0,0 +1,41 @@ |
| 1 | from lib.utils.kodi.utils import kodilog, translation | |
| 2 | import xbmcgui | |
| 3 | ||
| 4 | ||
| 5 | class 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() |
| … | … | @@ -0,0 +1,917 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | import threading | |
| 4 | from lib.gui.base_window import BaseWindow | |
| 5 | from lib.utils.kodi.utils import ( | |
| 6 | ADDON_PATH, | |
| 7 | kodilog, | |
| 8 | execute_builtin, | |
| 9 | notification, | |
| 10 | get_setting, | |
| 11 | build_url, | |
| 12 | translation, | |
| 13 | ) | |
| 14 | from lib.clients.tmdb.utils.utils import tmdb_get, LANGUAGES | |
| 15 | from lib.api.fanart.fanart import get_fanart | |
| 16 | from lib.api.imdb.imdb_scraper import get_imdb_trivia, get_imdb_goofs | |
| 17 | from lib.utils.general.utils import truncate_text | |
| 18 | import xbmcgui | |
| 19 | import xbmc | |
| 20 | ||
| 21 | ||
| 22 | class 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) |
| … | … | @@ -0,0 +1,28 @@ |
| 1 | import xbmcgui | |
| 2 | from lib.gui.base_window import BaseWindow | |
| 3 | ||
| 4 | class 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() |
| … | … | @@ -0,0 +1,54 @@ |
| 1 | import xbmcgui | |
| 2 | from lib.gui.base_window import BaseWindow | |
| 3 | from lib.utils.kodi.utils import translation | |
| 4 | ||
| 5 | ||
| 6 | class 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() |
| … | … | @@ -0,0 +1,22 @@ |
| 1 | from lib.gui.play_window import PlayWindow | |
| 2 | ||
| 3 | ||
| 4 | from lib.utils.kodi.utils import get_setting | |
| 5 | ||
| 6 | ||
| 7 | class 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() |
| … | … | @@ -0,0 +1,99 @@ |
| 1 | import abc | |
| 2 | from lib.gui.base_window import BaseWindow | |
| 3 | from lib.utils.kodi.utils import kodilog | |
| 4 | import xbmc | |
| 5 | ||
| 6 | ||
| 7 | class 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() |
| … | … | @@ -0,0 +1,85 @@ |
| 1 | import xbmcgui | |
| 2 | from lib.utils.kodi.utils import translation | |
| 3 | ||
| 4 | ||
| 5 | class 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() |
| … | … | @@ -0,0 +1,201 @@ |
| 1 | from typing import Optional, Dict, Any, cast | |
| 2 | from datetime import timedelta | |
| 3 | ||
| 4 | from lib.clients.subtitle.submanager import SubtitleManager | |
| 5 | from lib.db.cached import cache | |
| 6 | from lib.gui.base_window import BaseWindow | |
| 7 | from lib.gui.source_pack_select import SourcePackSelect | |
| 8 | from lib.player import JacktookPLayer | |
| 9 | from lib.utils.debrid.debrid_utils import get_pack_info | |
| 10 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 11 | from lib.utils.kodi.utils import ADDON_PATH, notification, set_property | |
| 12 | from lib.domain.torrent import TorrentStream | |
| 13 | ||
| 14 | ||
| 15 | class 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 | ||
| 188 | class 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) |
| … | … | @@ -0,0 +1,28 @@ |
| 1 | from lib.gui.base_window import BaseWindow | |
| 2 | from lib.utils.kodi.utils import translation | |
| 3 | ||
| 4 | class 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() |
| … | … | @@ -0,0 +1,215 @@ |
| 1 | from dataclasses import dataclass, field | |
| 2 | from typing import List, Optional, Callable, Dict, Any | |
| 3 | from concurrent.futures import Future, ThreadPoolExecutor, as_completed | |
| 4 | import threading | |
| 5 | ||
| 6 | import xbmc | |
| 7 | import xbmcgui | |
| 8 | ||
| 9 | from lib.utils.kodi.utils import kodilog, translation | |
| 10 | from lib.gui.base_window import BaseWindow | |
| 11 | from lib.domain.torrent import TorrentStream | |
| 12 | ||
| 13 | ||
| 14 | STATUS_MAP = { | |
| 15 | "Pending": 90244, | |
| 16 | "In Progress": 90245, | |
| 17 | "Completed": 90246, | |
| 18 | "Failed": 90247, | |
| 19 | "Cancelled": 90248, | |
| 20 | } | |
| 21 | ||
| 22 | ||
| 23 | @dataclass | |
| 24 | class 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 | ||
| 35 | class 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 | ||
| 104 | class 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() |
| … | … | @@ -0,0 +1,92 @@ |
| 1 | from threading import Thread | |
| 2 | ||
| 3 | import xbmc | |
| 4 | import xbmcgui | |
| 5 | ||
| 6 | from lib.utils.kodi.utils import ADDON_PATH, kodilog | |
| 7 | ||
| 8 | ||
| 9 | ACTION_PREVIOUS_MENU = 10 | |
| 10 | ACTION_PLAYER_STOP = 13 | |
| 11 | ACTION_NAV_BACK = 92 | |
| 12 | ||
| 13 | ||
| 14 | class 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() |
| … | … | @@ -0,0 +1,79 @@ |
| 1 | from typing import Optional, Dict, Any | |
| 2 | from lib.gui.source_pack_window import SourcePackWindow | |
| 3 | from lib.utils.general.utils import DebridType | |
| 4 | from lib.domain.torrent import TorrentStream | |
| 5 | from lib.utils.player.utils import resolve_playback_url | |
| 6 | ||
| 7 | ||
| 8 | class 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() |
| … | … | @@ -0,0 +1,37 @@ |
| 1 | import abc | |
| 2 | import xbmcgui | |
| 3 | from lib.gui.base_window import BaseWindow | |
| 4 | ||
| 5 | ||
| 6 | class 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 |
| … | … | @@ -0,0 +1,640 @@ |
| 1 | from typing import List, Optional, Dict | |
| 2 | from lib.domain.torrent import TorrentStream | |
| 3 | from lib.gui.filter_type_window import FilterTypeWindow | |
| 4 | from lib.gui.filter_items_window import FilterWindow | |
| 5 | from lib.gui.base_window import BaseWindow | |
| 6 | from lib.gui.resolver_window import ResolverWindow | |
| 7 | from lib.gui.resume_window import ResumeDialog | |
| 8 | from lib.utils.debrid.debrid_utils import ( | |
| 9 | add_source_to_debrid, | |
| 10 | get_torrent_data_from_uri, | |
| 11 | get_source_status, | |
| 12 | ) | |
| 13 | from lib.utils.kodi.utils import ( | |
| 14 | action_url_run, | |
| 15 | bytes_to_human_readable, | |
| 16 | ADDON_PATH, | |
| 17 | get_setting, | |
| 18 | kodilog, | |
| 19 | notification, | |
| 20 | translatePath, | |
| 21 | translation, | |
| 22 | ) | |
| 23 | from 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 | ) | |
| 34 | from lib.utils.parsers.title_parser import parse_title_info | |
| 35 | from lib.utils.torrent.torrserver_utils import add_source_to_torrserver | |
| 36 | ||
| 37 | import xbmcgui | |
| 38 | import xbmc | |
| 39 | import threading | |
| 40 | ||
| 41 | # For web subtitle upload | |
| 42 | from lib.services.subtitle_server import SubtitleUploadServer, get_local_ip | |
| 43 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 44 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 45 | ||
| 46 | THEMES = { | |
| 47 | "0": {"card_bg": "FF362e33", "card_focus": "992A3E5C", "card_accent": "FF00559D"}, | |
| 48 | "1": {"card_bg": "FF000000", "card_focus": "FF1A1A1A", "card_accent": "FF333333"}, | |
| 49 | "2": {"card_bg": "FF1A0B2E", "card_focus": "994D004D", "card_accent": "FF00F3FF"}, | |
| 50 | "3": {"card_bg": "FF1B261B", "card_focus": "992D402D", "card_accent": "FF7CFC00"}, | |
| 51 | "4": {"card_bg": "FF141414", "card_focus": "992B2510", "card_accent": "FFD4AF37"}, | |
| 52 | } | |
| 53 | ||
| 54 | ||
| 55 | class SourceSelect(BaseWindow): | |
| 56 | def __init__( | |
| 57 | self, | |
| 58 | xml_file: str, | |
| 59 | location: str, | |
| 60 | item_information: Optional[Dict] = None, | |
| 61 | sources: Optional[List[TorrentStream]] = None, | |
| 62 | uncached: Optional[List[TorrentStream]] = None, | |
| 63 | ): | |
| 64 | super().__init__(xml_file, location, item_information=item_information) | |
| 65 | self.uncached_sources: List[TorrentStream] = uncached or [] | |
| 66 | self.position: int = -1 | |
| 67 | self.sources: List[TorrentStream] = sources or [] | |
| 68 | self.list_sources: List[TorrentStream] = [] | |
| 69 | self.item_information: Dict = item_information or {} | |
| 70 | self.playback_info: Optional[Dict] = None | |
| 71 | self.resume: Optional[bool] = None | |
| 72 | self.CACHE_KEY: str = str(self.item_information.get("tv_data", "")) or str( | |
| 73 | self.item_information.get("ids", "") | |
| 74 | ) | |
| 75 | self.setProperty("instant_close", "false") | |
| 76 | self.setProperty("resolving", "false") | |
| 77 | self.filtered_sources: Optional[List[TorrentStream]] = None | |
| 78 | self.filter_applied: bool = False | |
| 79 | self.resolved = False | |
| 80 | ||
| 81 | def onInit(self) -> None: | |
| 82 | theme_index = get_setting("source_select_theme", "0") | |
| 83 | theme = THEMES.get(str(theme_index), THEMES["0"]) | |
| 84 | self.setProperty("style.card_bg", theme["card_bg"]) | |
| 85 | self.setProperty("style.card_focus", theme["card_focus"]) | |
| 86 | self.setProperty("style.card_accent", theme["card_accent"]) | |
| 87 | ||
| 88 | self.display_list: xbmcgui.ControlList = self.getControlList(1000) | |
| 89 | self.populate_qualities_header() | |
| 90 | self.populate_sources_list() | |
| 91 | self.set_default_focus(self.display_list, 1000, control_list_reset=True) | |
| 92 | super().onInit() | |
| 93 | ||
| 94 | def populate_qualities_header(self): | |
| 95 | from collections import Counter | |
| 96 | ||
| 97 | fixed_qualities = [ | |
| 98 | ("[B][COLOR yellow]4k[/COLOR][/B]", "4k"), | |
| 99 | ("[B][COLOR blue]1080p[/COLOR][/B]", "1080p"), | |
| 100 | ("[B][COLOR orange]720p[/COLOR][/B]", "720p"), | |
| 101 | ("[B][COLOR yellow]N/A[/COLOR][/B]", "N/A"), | |
| 102 | ] | |
| 103 | ||
| 104 | qualities = [s.quality for s in self.sources if s.quality] | |
| 105 | counts = Counter(qualities) | |
| 106 | qualities_list = self.getControl(1300) | |
| 107 | qualities_list.reset() | |
| 108 | ||
| 109 | # Set fixed width for 4 items | |
| 110 | self.setProperty("quality_item_width", str(int(800 / 4))) | |
| 111 | ||
| 112 | for display, key in fixed_qualities: | |
| 113 | count = counts.get(display, 0) | |
| 114 | label = f"{display} ({count})" | |
| 115 | list_item = xbmcgui.ListItem(label) | |
| 116 | list_item.setProperty("quality", key) | |
| 117 | qualities_list.addItem(list_item) | |
| 118 | ||
| 119 | def doModal(self) -> bool: | |
| 120 | super().doModal() | |
| 121 | return self.resolved | |
| 122 | ||
| 123 | def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None: | |
| 124 | if action_id == 1 and control_id == 1000: | |
| 125 | self._handle_filter_action() | |
| 126 | elif action_id == 117: # Context menu action | |
| 127 | self._handle_context_menu_action() | |
| 128 | elif control_id == 1300: | |
| 129 | self._handle_quality_select_action() | |
| 130 | elif action_id == 7 and control_id == 1000: # Select action | |
| 131 | self._handle_select_action(control_id) | |
| 132 | ||
| 133 | def _handle_filter_action(self): | |
| 134 | filter_type_popup = FilterTypeWindow("filter_type.xml", ADDON_PATH) | |
| 135 | filter_type_popup.doModal() | |
| 136 | selected_type = filter_type_popup.selected_type | |
| 137 | del filter_type_popup | |
| 138 | ||
| 139 | def get_unique(attr): | |
| 140 | return sorted( | |
| 141 | set(getattr(s, attr) for s in self.sources if getattr(s, attr)) | |
| 142 | ) | |
| 143 | ||
| 144 | filter_map = { | |
| 145 | "quality": { | |
| 146 | "items": lambda: get_unique("quality"), | |
| 147 | "filter": lambda val: [s for s in self.sources if s.quality == val], | |
| 148 | }, | |
| 149 | "provider": { | |
| 150 | "items": lambda: get_unique("provider"), | |
| 151 | "filter": lambda val: [s for s in self.sources if s.provider == val], | |
| 152 | }, | |
| 153 | "type": { | |
| 154 | "items": lambda: get_unique("type"), | |
| 155 | "filter": lambda val: [s for s in self.sources if s.type == val], | |
| 156 | }, | |
| 157 | "indexer": { | |
| 158 | "items": lambda: get_unique("indexer"), | |
| 159 | "filter": lambda val: [s for s in self.sources if s.indexer == val], | |
| 160 | }, | |
| 161 | "language": { | |
| 162 | "items": self._get_all_languages, | |
| 163 | "filter": lambda val: [ | |
| 164 | s | |
| 165 | for s in self.sources | |
| 166 | if val in getattr(s, "languages", []) | |
| 167 | or val in getattr(s, "fullLanguages", []) | |
| 168 | ], | |
| 169 | }, | |
| 170 | } | |
| 171 | ||
| 172 | one_click_filters = { | |
| 173 | "only_torrents": lambda: [s for s in self.sources if s.type == "Torrent"], | |
| 174 | "only_debrid": lambda: [s for s in self.sources if s.type == "Debrid"], | |
| 175 | } | |
| 176 | ||
| 177 | if selected_type in one_click_filters: | |
| 178 | filtered_sources = one_click_filters[selected_type]() | |
| 179 | if not filtered_sources: | |
| 180 | kodilog("No sources found matching the filter") | |
| 181 | return | |
| 182 | self.filtered_sources = filtered_sources | |
| 183 | self.filter_applied = True | |
| 184 | elif selected_type in filter_map: | |
| 185 | items = filter_map[selected_type]["items"]() | |
| 186 | if selected_type == "language" and not items: | |
| 187 | notification(translation(90406)) | |
| 188 | return | |
| 189 | popup = FilterWindow("filter_items.xml", ADDON_PATH, filter=items) | |
| 190 | popup.doModal() | |
| 191 | selected_filter = popup.selected_filter | |
| 192 | del popup | |
| 193 | if selected_filter is not None: | |
| 194 | filtered_sources = filter_map[selected_type]["filter"](selected_filter) | |
| 195 | if not filtered_sources: | |
| 196 | kodilog("No sources found matching the filter") | |
| 197 | return | |
| 198 | self.filtered_sources = filtered_sources | |
| 199 | self.filter_applied = True | |
| 200 | else: | |
| 201 | self.filtered_sources = None | |
| 202 | self.filter_applied = False | |
| 203 | else: | |
| 204 | self.filtered_sources = None | |
| 205 | self.filter_applied = False | |
| 206 | ||
| 207 | self.populate_sources_list() | |
| 208 | self.set_default_focus(self.display_list, 1000, control_list_reset=True) | |
| 209 | ||
| 210 | def _handle_context_menu_action(self): | |
| 211 | self.position = self.display_list.getSelectedPosition() | |
| 212 | selected_source = self.list_sources[self.position] | |
| 213 | ||
| 214 | menu_items = [] | |
| 215 | menu_actions = [] | |
| 216 | ||
| 217 | if selected_source.type == IndexerType.TORRENT: | |
| 218 | menu_items.extend( | |
| 219 | [ | |
| 220 | translation(90365), | |
| 221 | translation(90359), | |
| 222 | translation(90083), | |
| 223 | translation(90744), | |
| 224 | ] | |
| 225 | ) | |
| 226 | menu_actions.extend( | |
| 227 | [ | |
| 228 | "download_to_debrid", | |
| 229 | "add_to_torrserver", | |
| 230 | "download_file", | |
| 231 | "upload_subtitle", | |
| 232 | ] | |
| 233 | ) | |
| 234 | elif selected_source.type in (IndexerType.DIRECT, IndexerType.STREMIO_DEBRID): | |
| 235 | menu_items.extend( | |
| 236 | [translation(90083), translation(90082), translation(90744)] | |
| 237 | ) | |
| 238 | menu_actions.extend( | |
| 239 | ["download_file", "subtitle_download", "upload_subtitle"] | |
| 240 | ) | |
| 241 | else: | |
| 242 | menu_items.extend( | |
| 243 | [ | |
| 244 | translation(90084), | |
| 245 | translation(90083), | |
| 246 | translation(90082), | |
| 247 | translation(90744), | |
| 248 | ] | |
| 249 | ) | |
| 250 | menu_actions.extend( | |
| 251 | [ | |
| 252 | "pack_select", | |
| 253 | "download_file", | |
| 254 | "subtitle_download", | |
| 255 | "upload_subtitle", | |
| 256 | ] | |
| 257 | ) | |
| 258 | ||
| 259 | response = xbmcgui.Dialog().contextmenu(menu_items) | |
| 260 | if response < 0 or response >= len(menu_actions): | |
| 261 | return | |
| 262 | ||
| 263 | action = menu_actions[response] | |
| 264 | kodilog( | |
| 265 | f"SourceSelect context action selected: action={action}, mode={self.item_information.get('mode')}, query={self.item_information.get('query')}, year={self.item_information.get('year')}" | |
| 266 | ) | |
| 267 | if action == "download_to_debrid": | |
| 268 | try: | |
| 269 | self._download_to_debrid(selected_source) | |
| 270 | except Exception as e: | |
| 271 | kodilog(f"SourceSelect _download_to_debrid raised: {e}") | |
| 272 | notification(str(e)) | |
| 273 | elif action == "add_to_torrserver": | |
| 274 | self._add_to_torrserver(selected_source) | |
| 275 | elif action == "download_file": | |
| 276 | self._download_file(selected_source) | |
| 277 | elif action == "subtitle_download": | |
| 278 | self._resolve_item(selected_source, is_subtitle_download=True) | |
| 279 | elif action == "pack_select": | |
| 280 | self._resolve_item(selected_source, pack_select=True) | |
| 281 | elif action == "upload_subtitle": | |
| 282 | self._upload_subtitle(selected_source) | |
| 283 | ||
| 284 | def _handle_quality_select_action(self): | |
| 285 | quality_list = self.getControl(1300) | |
| 286 | selected_item = quality_list.getSelectedItem() | |
| 287 | if selected_item: | |
| 288 | selected_quality = selected_item.getProperty("quality") | |
| 289 | filtered_sources = [ | |
| 290 | s for s in self.sources if selected_quality in s.quality | |
| 291 | ] | |
| 292 | if not filtered_sources: | |
| 293 | kodilog("No sources found matching the filter") | |
| 294 | return | |
| 295 | self.filtered_sources = filtered_sources | |
| 296 | self.filter_applied = True | |
| 297 | self.populate_sources_list() | |
| 298 | ||
| 299 | def _handle_select_action(self, control_id): | |
| 300 | self.position = self.display_list.getSelectedPosition() | |
| 301 | selected_source = self.list_sources[self.position] | |
| 302 | ||
| 303 | control_list = self.getControl(control_id) | |
| 304 | self.set_cached_focus(control_id, control_list.getSelectedPosition()) | |
| 305 | self._resolve_item(selected_source, pack_select=False) | |
| 306 | ||
| 307 | def populate_sources_list(self) -> None: | |
| 308 | self.display_list.reset() | |
| 309 | self.list_sources = ( | |
| 310 | self.filtered_sources | |
| 311 | if self.filter_applied and self.filtered_sources is not None | |
| 312 | else self.sources | |
| 313 | ) | |
| 314 | ||
| 315 | for source in self.list_sources: | |
| 316 | info = parse_title_info(source.title) | |
| 317 | menu_item = xbmcgui.ListItem(label=source.title) | |
| 318 | menu_item.setProperty("title", source.title) | |
| 319 | menu_item.setProperty("display_title", info["clean_title"]) | |
| 320 | menu_item.setProperty("codec", info["codec"]) | |
| 321 | menu_item.setProperty("audio", info["audio"]) | |
| 322 | menu_item.setProperty("hdr_info", info["badges"]) | |
| 323 | menu_item.setProperty("release_group", info["release_group"]) | |
| 324 | kodilog(f"SourceSelect populate_sources_list: source.type={source.type}") | |
| 325 | if source.type in IndexerType.TORRENT: | |
| 326 | if source.addedToDebrid and source.debridType: | |
| 327 | provider_name = source.debridType | |
| 328 | else: | |
| 329 | provider_name = source.type or source.subindexer | |
| 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) |
| … | … | @@ -0,0 +1,63 @@ |
| 1 | from lib.clients.base import BaseClient, TorrentStream | |
| 2 | from .providers import ( | |
| 3 | burst_search, | |
| 4 | burst_search_episode, | |
| 5 | burst_search_movie, | |
| 6 | burst_search_season, | |
| 7 | ) | |
| 8 | from lib.utils.kodi.utils import convert_size_to_bytes, get_setting | |
| 9 | from typing import List, Optional, Dict, Any, Callable | |
| 10 | ||
| 11 | ||
| 12 | class 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 |
| … | … | @@ -0,0 +1,33 @@ |
| 1 | from .utils import NoProvidersError | |
| 2 | from .provider_base import ProviderListener | |
| 3 | from lib.utils.kodi.utils import ADDON_NAME, translation | |
| 4 | from xbmcgui import DialogProgressBG | |
| 5 | ||
| 6 | ||
| 7 | class 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() |
| … | … | @@ -0,0 +1,180 @@ |
| 1 | import base64 | |
| 2 | import json | |
| 3 | import sys | |
| 4 | import threading | |
| 5 | import time | |
| 6 | ||
| 7 | from .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 | ) | |
| 16 | import xbmc | |
| 17 | ||
| 18 | ||
| 19 | __all__ = ["ProviderResult", "Provider"] | |
| 20 | ||
| 21 | ||
| 22 | def 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 | ||
| 30 | def 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 | ||
| 41 | def _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 | ||
| 53 | class 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 | ||
| 63 | class 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 | ||
| 121 | class 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 |
| … | … | @@ -0,0 +1,63 @@ |
| 1 | from lib.jacktook.provider_base import ( | |
| 2 | ProviderResult, | |
| 3 | get_providers, | |
| 4 | send_to_providers, | |
| 5 | ) | |
| 6 | from lib.jacktook.listener import ProviderListenerDialog | |
| 7 | from lib.jacktook.utils import NoProvidersError | |
| 8 | from lib.utils.kodi.utils import kodilog | |
| 9 | ||
| 10 | ||
| 11 | def burst_search(query, silent=False): | |
| 12 | return search("search", query, silent=silent) | |
| 13 | ||
| 14 | ||
| 15 | def burst_search_movie(movie_id, query, silent=False): | |
| 16 | return search("search_movie", movie_id, query, silent=silent) | |
| 17 | ||
| 18 | ||
| 19 | def burst_search_show(show_id, query, silent=False): | |
| 20 | return search("search_show", show_id, query, silent=silent) | |
| 21 | ||
| 22 | ||
| 23 | def burst_search_season(show_id, show_title, season_number, silent=False): | |
| 24 | return search( | |
| 25 | "search_season", show_id, show_title, int(season_number), silent=silent | |
| 26 | ) | |
| 27 | ||
| 28 | ||
| 29 | def 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 | ||
| 40 | def 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 |
| … | … | @@ -0,0 +1,62 @@ |
| 1 | import json | |
| 2 | import xbmc | |
| 3 | import xbmcaddon | |
| 4 | ||
| 5 | from lib.utils.kodi.logging import kodilog | |
| 6 | ||
| 7 | ||
| 8 | ADDON = xbmcaddon.Addon() | |
| 9 | ADDON_PATH = ADDON.getAddonInfo("path") | |
| 10 | ADDON_ID = ADDON.getAddonInfo("id") | |
| 11 | ADDON_VERSION = ADDON.getAddonInfo("version") | |
| 12 | ADDON_NAME = ADDON.getAddonInfo("name") | |
| 13 | ||
| 14 | ||
| 15 | def get_installed_addons(addon_type="", content="unknown", enabled="all"): | |
| 16 | data = execute_json_rpc( | |
| 17 | "Addons.GetAddons", type=addon_type, content=content, enabled=enabled | |
| 18 | ) | |
| 19 | addons = data["result"].get("addons") | |
| 20 | return [(a["addonid"], a["type"]) for a in addons] if addons else [] | |
| 21 | ||
| 22 | ||
| 23 | def 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 | ||
| 30 | def run_script(script_id, *args): | |
| 31 | xbmc.executebuiltin("RunScript({})".format(",".join((script_id,) + args))) | |
| 32 | ||
| 33 | ||
| 34 | def 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 | ||
| 45 | class ResolveTimeoutError(Exception): | |
| 46 | pass | |
| 47 | ||
| 48 | ||
| 49 | class NoProvidersError(Exception): | |
| 50 | pass | |
| 51 | ||
| 52 | ||
| 53 | def assure_str(s): | |
| 54 | return s | |
| 55 | ||
| 56 | ||
| 57 | def str_to_bytes(s): | |
| 58 | return s.encode() | |
| 59 | ||
| 60 | ||
| 61 | def bytes_to_str(b): | |
| 62 | return b.decode() |
| … | … | @@ -0,0 +1 @@ |
| 1 | """Navigation feature modules.""" |
| … | … | @@ -0,0 +1,348 @@ |
| 1 | from threading import Thread | |
| 2 | from typing import Any, Dict, List | |
| 3 | from datetime import timedelta | |
| 4 | ||
| 5 | from xbmcplugin import addDirectoryItem | |
| 6 | ||
| 7 | from lib.api.debrid.alldebrid import AllDebrid | |
| 8 | from lib.api.debrid.debrider import Debrider | |
| 9 | from lib.api.debrid.premiumize import Premiumize | |
| 10 | from lib.api.debrid.realdebrid import RealDebrid | |
| 11 | from lib.api.debrid.torbox import Torbox | |
| 12 | from lib.clients.debrid.alldebrid import AllDebridHelper | |
| 13 | from lib.clients.debrid.debrider import DebriderHelper | |
| 14 | from lib.clients.debrid.realdebrid import RealDebridHelper | |
| 15 | from lib.clients.debrid.torbox import TorboxHelper | |
| 16 | from 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 | ) | |
| 23 | from lib.services.debrid.download import run_realdebrid_download | |
| 24 | from lib.db.cached import cache | |
| 25 | from 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 | ) | |
| 33 | from 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 | ||
| 47 | def _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 | ||
| 53 | def _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 | ||
| 59 | def _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 | ||
| 65 | DEBRID_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 | ||
| 72 | DEBRID_DOWNLOAD_HANDLERS = { | |
| 73 | "RD": _start_realdebrid_download, | |
| 74 | "TB": _start_torbox_download, | |
| 75 | "PM": _start_premiumize_download, | |
| 76 | } | |
| 77 | ||
| 78 | DEBRID_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 | ||
| 86 | DEBRID_CLOUD_CACHE_EXPIRY = timedelta(hours=1) | |
| 87 | ||
| 88 | ||
| 89 | def _cloud_downloads_cache_key(provider: str, page: int = 1) -> str: | |
| 90 | return f"cloud_downloads|{provider}|{page}" | |
| 91 | ||
| 92 | ||
| 93 | def 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 | ||
| 120 | def 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 | ||
| 151 | def real_debrid_info(params): | |
| 152 | finish_action() | |
| 153 | DEBRID_INFO_HANDLERS[DebridType.RD]() | |
| 154 | ||
| 155 | ||
| 156 | def alldebrid_info(params): | |
| 157 | finish_action() | |
| 158 | DEBRID_INFO_HANDLERS[DebridType.AD]() | |
| 159 | ||
| 160 | ||
| 161 | def debrider_info(params): | |
| 162 | finish_action() | |
| 163 | DEBRID_INFO_HANDLERS[DebridType.DB]() | |
| 164 | ||
| 165 | ||
| 166 | def 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 | ||
| 182 | def 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 | ||
| 230 | def 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 | ||
| 288 | def 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 | ||
| 296 | def rd_auth(params): | |
| 297 | rd_client = RealDebrid(token=str(get_setting("real_debrid_token", ""))) | |
| 298 | run_realdebrid_auth(rd_client) | |
| 299 | ||
| 300 | ||
| 301 | def ad_auth(params): | |
| 302 | ad_client = AllDebrid(token=str(get_setting("alldebrid_token", ""))) | |
| 303 | run_alldebrid_auth(ad_client) | |
| 304 | ||
| 305 | ||
| 306 | def rd_remove_auth(params): | |
| 307 | rd_client = RealDebrid(token=str(get_setting("real_debrid_token", ""))) | |
| 308 | rd_client.remove_auth() | |
| 309 | ||
| 310 | ||
| 311 | def ad_remove_auth(params): | |
| 312 | ad_client = AllDebrid(token=str(get_setting("alldebrid_token", ""))) | |
| 313 | ad_client.remove_auth() | |
| 314 | ||
| 315 | ||
| 316 | def debrider_auth(params): | |
| 317 | debrider_client = Debrider(token=str(get_setting("debrider_token"))) | |
| 318 | run_debrider_auth(debrider_client) | |
| 319 | ||
| 320 | ||
| 321 | def debrider_remove_auth(params): | |
| 322 | debrider_client = Debrider(token=str(get_setting("debrider_token"))) | |
| 323 | debrider_client.remove_auth() | |
| 324 | ||
| 325 | ||
| 326 | def pm_auth(params): | |
| 327 | pm_client = Premiumize(token=str(get_setting("premiumize_token"))) | |
| 328 | run_premiumize_auth(pm_client) | |
| 329 | ||
| 330 | ||
| 331 | def pm_remove_auth(params): | |
| 332 | pm_client = Premiumize(token=str(get_setting("premiumize_token", ""))) | |
| 333 | pm_client.remove_auth() | |
| 334 | ||
| 335 | ||
| 336 | def tb_auth(params): | |
| 337 | torbox_client = Torbox(token=str(get_setting("torbox_token"))) | |
| 338 | run_torbox_auth(torbox_client) | |
| 339 | ||
| 340 | ||
| 341 | def tb_remove_auth(params): | |
| 342 | torbox_client = Torbox(token=str(get_setting("torbox_token"))) | |
| 343 | torbox_client.remove_auth() | |
| 344 | ||
| 345 | ||
| 346 | def torbox_info(params): | |
| 347 | finish_action() | |
| 348 | DEBRID_INFO_HANDLERS[DebridType.TB]() |
| … | … | @@ -0,0 +1,261 @@ |
| 1 | from datetime import timedelta | |
| 2 | import os | |
| 3 | ||
| 4 | from xbmcgui import Dialog | |
| 5 | from xbmcplugin import addDirectoryItem, endOfDirectory | |
| 6 | ||
| 7 | from lib.db.cached import cache | |
| 8 | from lib.utils.general.items_menus import history_menu_items, library_menu_items | |
| 9 | from lib.utils.general.utils import build_list_item, clear_history_by_type, set_pluging_category | |
| 10 | from lib.utils.kodi.settings import get_cache_expiration | |
| 11 | from 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 | ) | |
| 26 | from lib.utils.views.last_files import show_last_files | |
| 27 | from lib.utils.views.last_titles import show_last_titles | |
| 28 | from lib.utils.views.weekly_calendar import show_weekly_calendar | |
| 29 | ||
| 30 | ||
| 31 | def _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 | ||
| 45 | def _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 | ||
| 81 | def history_menu(params): | |
| 82 | set_pluging_category(translation(90017)) | |
| 83 | _render_menu(history_menu_items) | |
| 84 | apply_section_view("view.history") | |
| 85 | ||
| 86 | ||
| 87 | def 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 | ||
| 93 | def continue_watching_menu(params): | |
| 94 | from lib.utils.views.continue_watching import show_continue_watching | |
| 95 | ||
| 96 | show_continue_watching() | |
| 97 | ||
| 98 | ||
| 99 | def 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 | ||
| 105 | def 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 | ||
| 112 | def 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 | ||
| 119 | def library_calendar(params): | |
| 120 | show_weekly_calendar(library=True) | |
| 121 | ||
| 122 | ||
| 123 | def remove_from_library(params): | |
| 124 | from lib.utils.views.library import remove_library_item | |
| 125 | ||
| 126 | remove_library_item(params) | |
| 127 | ||
| 128 | ||
| 129 | def 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 | ||
| 144 | def 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 | ||
| 155 | def 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 | ||
| 237 | def clear_history(params): | |
| 238 | clear_history_by_type(type=params.get("type")) | |
| 239 | notification(translation(90114)) | |
| 240 | ||
| 241 | ||
| 242 | def 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 | ||
| 252 | def files_history(params): | |
| 253 | show_last_files() | |
| 254 | ||
| 255 | ||
| 256 | def titles_history(params): | |
| 257 | show_last_titles(params) | |
| 258 | ||
| 259 | ||
| 260 | def titles_calendar(params): | |
| 261 | show_weekly_calendar() |
| … | … | @@ -0,0 +1,1198 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | from lib.api.trakt.trakt import ProviderException, TraktAPI | |
| 4 | from lib.clients.trakt.trakt import TraktClient | |
| 5 | ||
| 6 | from lib.clients.stremio.catalog_menus import list_stremio_catalogs | |
| 7 | from lib.clients.tmdb.tmdb import ( | |
| 8 | TmdbClient, | |
| 9 | ) | |
| 10 | ||
| 11 | from lib.db.cached import RuntimeCache, cache | |
| 12 | ||
| 13 | from lib.downloader import downloads_viewer | |
| 14 | from 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 | ||
| 22 | from lib.player import JacktookPLayer | |
| 23 | from 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 | ) | |
| 49 | from lib.utils.player.utils import resolve_playback_url | |
| 50 | from lib.utils.torrent.torrserver_init import get_torrserver_api | |
| 51 | from lib.utils.torrentio.utils import open_providers_selection | |
| 52 | from lib.utils.kodi.settings import addon_settings | |
| 53 | from lib.utils.kodi.settings_backup import ( | |
| 54 | export_settings_backup as kodi_export_settings_backup, | |
| 55 | factory_reset_action as kodi_factory_reset_action, | |
| 56 | reset_all_settings_action as kodi_reset_all_settings_action, | |
| 57 | restore_settings_backup as kodi_restore_settings_backup, | |
| 58 | ) | |
| 59 | from lib.utils.general.utils import ( | |
| 60 | build_list_item, | |
| 61 | clear_all_cache, | |
| 62 | clear_trakt_db_cache, | |
| 63 | clear_tmdb_cache as utils_clear_tmdb_cache, | |
| 64 | clear_stremio_cache as utils_clear_stremio_cache, | |
| 65 | clear_debrid_cache as utils_clear_debrid_cache, | |
| 66 | clear_mdblist_cache as utils_clear_mdblist_cache, | |
| 67 | clear_database_cache as utils_clear_database_cache, | |
| 68 | make_listing, | |
| 69 | set_content_type, | |
| 70 | set_pluging_category, | |
| 71 | show_log_export_dialog, | |
| 72 | ) | |
| 73 | from lib.clients.youtube_resolver import ( | |
| 74 | extract_video_id, | |
| 75 | resolve_item_trailer, | |
| 76 | ) | |
| 77 | import lib.nav.debrid as debrid_navigation | |
| 78 | import lib.nav.library_history as library_history_navigation | |
| 79 | from lib.utils.general.items_menus import ( | |
| 80 | animation_items, | |
| 81 | anime_items, | |
| 82 | movie_items, | |
| 83 | trakt_movie_discovery_items, | |
| 84 | trakt_movie_library_items, | |
| 85 | trakt_tv_discovery_items, | |
| 86 | trakt_tv_library_items, | |
| 87 | tv_items, | |
| 88 | ) | |
| 89 | ||
| 90 | from lib.updater import updates_check_addon, downgrade_addon_menu | |
| 91 | ||
| 92 | from xbmcgui import ListItem | |
| 93 | from xbmcplugin import ( | |
| 94 | addDirectoryItem, | |
| 95 | setResolvedUrl, | |
| 96 | ) | |
| 97 | ||
| 98 | ||
| 99 | from lib.utils.general.items_menus import ( | |
| 100 | root_menu_items, | |
| 101 | ) | |
| 102 | ||
| 103 | ||
| 104 | def _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 | ||
| 112 | def _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 | ||
| 122 | def _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 | ||
| 144 | def _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 | ||
| 168 | def _tv_menu_entries(): | |
| 169 | return _build_media_menu_entries(tv_items) | |
| 170 | ||
| 171 | ||
| 172 | def _movie_menu_entries(): | |
| 173 | return _build_media_menu_entries(movie_items) | |
| 174 | ||
| 175 | ||
| 176 | def _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 | ||
| 184 | def _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 | ||
| 201 | def _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 | ||
| 218 | def _anime_item_menu_entries(mode): | |
| 219 | return _build_search_item_menu_entries(anime_items, mode=mode) | |
| 220 | ||
| 221 | ||
| 222 | def _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 | ||
| 235 | def 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 | ||
| 263 | def _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 | ||
| 269 | def _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 | ||
| 282 | def 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 | ||
| 303 | def 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 | ||
| 321 | def 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 | ||
| 355 | def 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 | ||
| 365 | def reset_views(params): | |
| 366 | reset_saved_views() | |
| 367 | notification(translation(90712), translation(90724)) | |
| 368 | ||
| 369 | ||
| 370 | def 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 | ||
| 380 | def 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 | ||
| 391 | def 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 | ||
| 407 | def 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 | ||
| 421 | def 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 | ||
| 432 | def 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 | ||
| 443 | def 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 | ||
| 459 | def 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 | ||
| 475 | def direct_menu(params): | |
| 476 | search_direct({"mode": "direct"}) | |
| 477 | ||
| 478 | ||
| 479 | def 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 | ||
| 526 | def 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 | ||
| 587 | def 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 | ||
| 599 | def history_menu(params): | |
| 600 | return library_history_navigation.history_menu(params) | |
| 601 | ||
| 602 | ||
| 603 | def library_menu(params): | |
| 604 | return library_history_navigation.library_menu(params) | |
| 605 | ||
| 606 | ||
| 607 | def continue_watching_menu(params): | |
| 608 | return library_history_navigation.continue_watching_menu(params) | |
| 609 | ||
| 610 | ||
| 611 | def remove_from_continue_watching(params): | |
| 612 | return library_history_navigation.remove_from_continue_watching(params) | |
| 613 | ||
| 614 | ||
| 615 | def library_shows(params): | |
| 616 | return library_history_navigation.library_shows(params) | |
| 617 | ||
| 618 | ||
| 619 | def library_movies(params): | |
| 620 | return library_history_navigation.library_movies(params) | |
| 621 | ||
| 622 | ||
| 623 | def library_calendar(params): | |
| 624 | return library_history_navigation.library_calendar(params) | |
| 625 | ||
| 626 | ||
| 627 | def remove_from_library(params): | |
| 628 | return library_history_navigation.remove_from_library(params) | |
| 629 | ||
| 630 | ||
| 631 | def clear_library(params): | |
| 632 | return library_history_navigation.clear_library(params) | |
| 633 | ||
| 634 | ||
| 635 | def add_to_library(params): | |
| 636 | return library_history_navigation.add_to_library(params) | |
| 637 | ||
| 638 | ||
| 639 | def 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 | ||
| 668 | def 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 | ||
| 675 | def search_direct(params): | |
| 676 | return library_history_navigation.search_direct(params) | |
| 677 | ||
| 678 | ||
| 679 | def search(params): | |
| 680 | from lib.search import run_search_entry | |
| 681 | ||
| 682 | run_search_entry(params) | |
| 683 | ||
| 684 | ||
| 685 | def cloud_details(params): | |
| 686 | return debrid_navigation.cloud_details(params) | |
| 687 | ||
| 688 | ||
| 689 | def cloud(params): | |
| 690 | return debrid_navigation.cloud(params) | |
| 691 | ||
| 692 | ||
| 693 | def real_debrid_info(params): | |
| 694 | return debrid_navigation.real_debrid_info(params) | |
| 695 | ||
| 696 | ||
| 697 | def alldebrid_info(params): | |
| 698 | return debrid_navigation.alldebrid_info(params) | |
| 699 | ||
| 700 | ||
| 701 | def debrider_info(params): | |
| 702 | return debrid_navigation.debrider_info(params) | |
| 703 | ||
| 704 | ||
| 705 | def easynews_info(params): | |
| 706 | return debrid_navigation.easynews_info(params) | |
| 707 | ||
| 708 | ||
| 709 | def get_rd_downloads(params): | |
| 710 | return debrid_navigation.get_rd_downloads(params) | |
| 711 | ||
| 712 | ||
| 713 | def get_tb_downloads(params): | |
| 714 | return debrid_navigation.get_tb_downloads(params) | |
| 715 | ||
| 716 | ||
| 717 | def 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 | ||
| 771 | def 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 | ||
| 782 | def 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 | ||
| 789 | def 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 | ||
| 845 | def 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 | ||
| 854 | def 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 | ||
| 896 | def 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 | ||
| 932 | def 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 | ||
| 974 | def 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 | ||
| 988 | def 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 | ||
| 994 | def 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 | ||
| 1002 | def 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 | ||
| 1013 | def download(magnet, type): | |
| 1014 | return debrid_navigation.download(magnet, type) | |
| 1015 | ||
| 1016 | ||
| 1017 | def downloads_menu(params): | |
| 1018 | downloads_viewer(params) | |
| 1019 | ||
| 1020 | ||
| 1021 | def addon_update(params): | |
| 1022 | updates_check_addon() | |
| 1023 | ||
| 1024 | ||
| 1025 | def downgrade_addon(params): | |
| 1026 | downgrade_addon_menu() | |
| 1027 | ||
| 1028 | ||
| 1029 | def show_changelog(params): | |
| 1030 | dialog_text(translation(90577), file=CHANGELOG_PATH) | |
| 1031 | ||
| 1032 | ||
| 1033 | def 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 | ||
| 1050 | def settings(params): | |
| 1051 | finish_action() | |
| 1052 | addon_settings() | |
| 1053 | clear_cached_settings() | |
| 1054 | ||
| 1055 | ||
| 1056 | def export_settings_backup(params): | |
| 1057 | kodi_export_settings_backup(params) | |
| 1058 | ||
| 1059 | ||
| 1060 | def restore_settings_backup(params): | |
| 1061 | kodi_restore_settings_backup(params) | |
| 1062 | ||
| 1063 | ||
| 1064 | def reset_all_settings(params): | |
| 1065 | kodi_reset_all_settings_action(params) | |
| 1066 | ||
| 1067 | ||
| 1068 | def factory_reset(params): | |
| 1069 | kodi_factory_reset_action(params) | |
| 1070 | ||
| 1071 | ||
| 1072 | def clear_all_cached(params): | |
| 1073 | clear_all_cache() | |
| 1074 | notification(translation(30244)) | |
| 1075 | ||
| 1076 | ||
| 1077 | def clear_trakt_cache(params): | |
| 1078 | clear_trakt_db_cache() | |
| 1079 | ||
| 1080 | ||
| 1081 | def clear_tmdb_cache(params): | |
| 1082 | utils_clear_tmdb_cache() | |
| 1083 | ||
| 1084 | ||
| 1085 | def clear_stremio_cache(params): | |
| 1086 | utils_clear_stremio_cache() | |
| 1087 | ||
| 1088 | ||
| 1089 | def clear_debrid_cache(params): | |
| 1090 | utils_clear_debrid_cache() | |
| 1091 | ||
| 1092 | ||
| 1093 | def clear_mdblist_cache(params): | |
| 1094 | utils_clear_mdblist_cache() | |
| 1095 | ||
| 1096 | ||
| 1097 | def clear_database_cache(params): | |
| 1098 | utils_clear_database_cache() | |
| 1099 | ||
| 1100 | ||
| 1101 | def clear_history(params): | |
| 1102 | return library_history_navigation.clear_history(params) | |
| 1103 | ||
| 1104 | ||
| 1105 | def clear_search_history(params): | |
| 1106 | return library_history_navigation.clear_search_history(params) | |
| 1107 | ||
| 1108 | ||
| 1109 | def kodi_logs(params): | |
| 1110 | show_log_export_dialog(params) | |
| 1111 | ||
| 1112 | ||
| 1113 | def files_history(params): | |
| 1114 | return library_history_navigation.files_history(params) | |
| 1115 | ||
| 1116 | ||
| 1117 | def titles_history(params): | |
| 1118 | return library_history_navigation.titles_history(params) | |
| 1119 | ||
| 1120 | ||
| 1121 | def titles_calendar(params): | |
| 1122 | return library_history_navigation.titles_calendar(params) | |
| 1123 | ||
| 1124 | ||
| 1125 | def rd_auth(params): | |
| 1126 | return debrid_navigation.rd_auth(params) | |
| 1127 | ||
| 1128 | ||
| 1129 | def ad_auth(params): | |
| 1130 | return debrid_navigation.ad_auth(params) | |
| 1131 | ||
| 1132 | ||
| 1133 | def rd_remove_auth(params): | |
| 1134 | return debrid_navigation.rd_remove_auth(params) | |
| 1135 | ||
| 1136 | ||
| 1137 | def ad_remove_auth(params): | |
| 1138 | return debrid_navigation.ad_remove_auth(params) | |
| 1139 | ||
| 1140 | ||
| 1141 | def debrider_auth(params): | |
| 1142 | return debrid_navigation.debrider_auth(params) | |
| 1143 | ||
| 1144 | ||
| 1145 | def debrider_remove_auth(params): | |
| 1146 | return debrid_navigation.debrider_remove_auth(params) | |
| 1147 | ||
| 1148 | ||
| 1149 | def pm_auth(params): | |
| 1150 | return debrid_navigation.pm_auth(params) | |
| 1151 | ||
| 1152 | ||
| 1153 | def pm_remove_auth(params): | |
| 1154 | return debrid_navigation.pm_remove_auth(params) | |
| 1155 | ||
| 1156 | ||
| 1157 | def trakt_auth(params): | |
| 1158 | TraktAPI().auth.trakt_authenticate() | |
| 1159 | ||
| 1160 | ||
| 1161 | def trakt_auth_revoke(params): | |
| 1162 | TraktAPI().auth.trakt_revoke_authentication() | |
| 1163 | ||
| 1164 | ||
| 1165 | def tb_auth(params): | |
| 1166 | return debrid_navigation.tb_auth(params) | |
| 1167 | ||
| 1168 | ||
| 1169 | def tb_remove_auth(params): | |
| 1170 | return debrid_navigation.tb_remove_auth(params) | |
| 1171 | ||
| 1172 | ||
| 1173 | def torbox_info(params): | |
| 1174 | return debrid_navigation.torbox_info(params) | |
| 1175 | ||
| 1176 | ||
| 1177 | def open_burst_config(params): | |
| 1178 | burst_addon_settings() | |
| 1179 | ||
| 1180 | ||
| 1181 | def torrentio_selection(params): | |
| 1182 | open_providers_selection() | |
| 1183 | ||
| 1184 | ||
| 1185 | def test_run_next(params): | |
| 1186 | run_next_mock() | |
| 1187 | ||
| 1188 | ||
| 1189 | def test_source_select(params): | |
| 1190 | source_select_mock() | |
| 1191 | ||
| 1192 | ||
| 1193 | def test_resume_dialog(params): | |
| 1194 | resume_dialog_mock() | |
| 1195 | ||
| 1196 | ||
| 1197 | def test_download_dialog(params): | |
| 1198 | download_dialog_mock() |
| … | … | @@ -0,0 +1,594 @@ |
| 1 | from threading import Thread | |
| 2 | from lib.api.trakt.trakt_utils import is_trakt_auth | |
| 3 | from lib.clients.subtitle.utils import get_language_code | |
| 4 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 5 | from lib.api.trakt.trakt import TraktAPI, TraktLists | |
| 6 | from 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 | ) | |
| 22 | from lib.utils.general.utils import ( | |
| 23 | make_listing, | |
| 24 | set_watched_file, | |
| 25 | ) | |
| 26 | from lib.utils.player.utils import precache_next_episodes | |
| 27 | ||
| 28 | import xbmc | |
| 29 | from xbmc import getCondVisibility as get_visibility | |
| 30 | from xbmcgui import ListItem, Dialog | |
| 31 | from xbmcplugin import setResolvedUrl | |
| 32 | ||
| 33 | from json import dumps as json_dumps, loads | |
| 34 | ||
| 35 | total_time_errors = ("0.0", "", 0.0, None) | |
| 36 | video_fullscreen_check = "Window.IsActive(fullscreenvideo)" | |
| 37 | ||
| 38 | ||
| 39 | class 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() |
| … | … | @@ -0,0 +1,783 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | """ | |
| 3 | Jacktook addon router — lazy-loading dispatcher. | |
| 4 | ||
| 5 | Uses prefix-based routing with lazy imports to avoid loading all addon | |
| 6 | modules on every invocation. Only the modules needed for the current | |
| 7 | action are imported. | |
| 8 | """ | |
| 9 | import sys | |
| 10 | from 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 | ||
| 102 | def _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 | ||
| 114 | def _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 | ||
| 130 | def _is_trakt_action(action): | |
| 131 | return action.startswith("trakt_") or action in _TRAKT_EXTRAS | |
| 132 | ||
| 133 | ||
| 134 | def _is_debrid_action(action): | |
| 135 | return action in _DEBRID_ACTIONS | |
| 136 | ||
| 137 | ||
| 138 | def _is_telegram_action(action): | |
| 139 | return action.startswith(("telegram_", "list_telegram", "list_jackgram")) | |
| 140 | ||
| 141 | ||
| 142 | def _is_torrserver_action(action): | |
| 143 | return action in _TORRSERVER_ACTIONS | |
| 144 | ||
| 145 | ||
| 146 | def _is_webdav_action(action): | |
| 147 | return action in _WEBDAV_ACTIONS | |
| 148 | ||
| 149 | ||
| 150 | def _is_download_action(action): | |
| 151 | return action in _DOWNLOAD_ACTIONS | |
| 152 | ||
| 153 | ||
| 154 | def _is_mdblist_action(action): | |
| 155 | return action in _MDBLIST_ACTIONS | |
| 156 | ||
| 157 | ||
| 158 | def _is_cache_action(action): | |
| 159 | return action.startswith("clear_") | |
| 160 | ||
| 161 | ||
| 162 | def _is_gui_action(action): | |
| 163 | return action in _GUI_ACTIONS | |
| 164 | ||
| 165 | ||
| 166 | def _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 | ||
| 178 | def 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 | ||
| 203 | def _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 | ||
| 281 | def _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 | ||
| 342 | def _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 | ||
| 399 | def _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 | ||
| 446 | def _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 | ||
| 470 | def _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 | ||
| 496 | def _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 | ||
| 513 | def _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 | ||
| 540 | def _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 | ||
| 557 | def _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 | ||
| 586 | def _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 | ||
| 638 | def _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 | ||
| 771 | ROUTE_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 | ) |
| … | … | @@ -0,0 +1,1131 @@ |
| 1 | from concurrent.futures import ThreadPoolExecutor, as_completed | |
| 2 | from typing import List, Optional | |
| 3 | ||
| 4 | from lib.clients.stremio.helpers import ( | |
| 5 | get_addon_by_base_url, | |
| 6 | get_selected_stream_addons, | |
| 7 | ) | |
| 8 | from lib.clients.stremio.constants import STREMIO_ADDONS_KEY | |
| 9 | from lib.clients.stremio.addon_client import StremioAddonClient | |
| 10 | from lib.db.cached import cache | |
| 11 | from lib.domain.torrent import TorrentStream | |
| 12 | from lib.gui.custom_dialogs import source_select | |
| 13 | from lib.player import JacktookPLayer | |
| 14 | from lib.utils.clients.utils import get_client, update_dialog | |
| 15 | from 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 | ) | |
| 30 | from lib.utils.debrid.debrid_utils import check_debrid_cached | |
| 31 | from lib.utils.kodi.settings import auto_play_enabled, get_setting | |
| 32 | from lib.utils.player.utils import resolve_playback_url | |
| 33 | from lib.utils.kodi.utils import ( | |
| 34 | notification, | |
| 35 | cancel_playback, | |
| 36 | kodilog, | |
| 37 | translation, | |
| 38 | close_busy_dialog, | |
| 39 | ADDON_PATH, | |
| 40 | ) | |
| 41 | ||
| 42 | from lib.gui.search_status_window import SearchTaskManager, SearchStatusWindow | |
| 43 | ||
| 44 | import xbmc | |
| 45 | from xbmcgui import Dialog | |
| 46 | ||
| 47 | ||
| 48 | TITLE_LANGUAGE_LOCALIZED_FIRST = "localized_first" | |
| 49 | TITLE_LANGUAGE_ENGLISH_FIRST = "english_first" | |
| 50 | TITLE_LANGUAGE_ENGLISH_ONLY = "english_only" | |
| 51 | ||
| 52 | ||
| 53 | def _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 | ||
| 73 | def _clean_title_candidate(value) -> str: | |
| 74 | if value is None: | |
| 75 | return "" | |
| 76 | return str(value).strip() | |
| 77 | ||
| 78 | ||
| 79 | def _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 | ||
| 96 | def _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 | ||
| 106 | def _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 | ||
| 115 | def _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 | ||
| 136 | def _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 | ||
| 234 | def _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 | ||
| 273 | def _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 | ||
| 325 | def _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 | ||
| 382 | def 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 | ||
| 486 | def _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 | ||
| 548 | def _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 | ||
| 690 | def _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 | ||
| 719 | def _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 | ||
| 874 | def 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 | ||
| 983 | def 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 | ||
| 994 | def 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 | ||
| 1013 | def 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 | ||
| 1046 | def 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 | ||
| 1106 | def 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 | ||
| 1113 | def 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 | ||
| 1123 | def 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 | ) |
| … | … | @@ -0,0 +1,259 @@ |
| 1 | from threading import Thread | |
| 2 | from time import time | |
| 3 | import os | |
| 4 | ||
| 5 | import xbmc | |
| 6 | import xbmcaddon | |
| 7 | import xbmcvfs | |
| 8 | ||
| 9 | from lib.api.trakt.base_cache import setup_databases | |
| 10 | from lib.services.migrations import run_migrations | |
| 11 | from lib.services.preloader import StartupPreloader | |
| 12 | from lib.services.trakt_sync import TraktSyncService | |
| 13 | from lib.updater import updates_check_addon | |
| 14 | from lib.utils.kodi.settings import update_delay | |
| 15 | from 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 | ||
| 29 | first_run_update_prop = "jacktook.first_run_update" | |
| 30 | pause_services_prop = "jacktook.pause_services" | |
| 31 | ||
| 32 | ||
| 33 | class 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 | ||
| 43 | class DatabaseSetup: | |
| 44 | def run(self): | |
| 45 | setup_databases() | |
| 46 | ||
| 47 | ||
| 48 | class 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 | ||
| 74 | def 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 | ||
| 131 | def _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 | ||
| 144 | class 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 | ||
| 152 | class 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 | ||
| 215 | class 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 | ) |
| … | … | @@ -0,0 +1 @@ |
| 1 | """Debrid service orchestration layer.""" |
| … | … | @@ -0,0 +1,292 @@ |
| 1 | from time import time | |
| 2 | ||
| 3 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 4 | from lib.jacktook.utils import ADDON_PATH | |
| 5 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 6 | from lib.utils.general.utils import DebridType | |
| 7 | from lib.utils.kodi.utils import ( | |
| 8 | copy2clip, | |
| 9 | dialog_ok, | |
| 10 | progressDialog, | |
| 11 | set_setting, | |
| 12 | sleep as ksleep, | |
| 13 | translation, | |
| 14 | ) | |
| 15 | ||
| 16 | ||
| 17 | def 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 | ||
| 73 | def 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 | ||
| 135 | def 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 | ||
| 188 | def 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 | ||
| 253 | def 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 |
| … | … | @@ -0,0 +1,118 @@ |
| 1 | from lib.utils.general.utils import supported_video_extensions | |
| 2 | from lib.utils.kodi.utils import dialogyesno, notification, sleep as ksleep, translation | |
| 3 | from xbmcgui import DialogProgress | |
| 4 | ||
| 5 | ||
| 6 | DEBRID_ERROR_STATUS = ("magnet_error", "error", "virus", "dead") | |
| 7 | ||
| 8 | ||
| 9 | def 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 | ||
| 63 | def _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 |
| … | … | @@ -0,0 +1,39 @@ |
| 1 | from lib.utils.kodi.utils import ( | |
| 2 | get_setting, | |
| 3 | set_setting, | |
| 4 | kodilog, | |
| 5 | ) | |
| 6 | ||
| 7 | ||
| 8 | def _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 | ||
| 37 | def run_migrations(): | |
| 38 | _migrate_trakt_credentials() | |
| 39 | kodilog(f"Trakt migration complete") |
| … | … | @@ -0,0 +1,80 @@ |
| 1 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 2 | from threading import Thread | |
| 3 | from concurrent.futures import ThreadPoolExecutor | |
| 4 | from lib.utils.kodi.utils import kodilog, get_setting | |
| 5 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 6 | ||
| 7 | ||
| 8 | class 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) |
| … | … | @@ -0,0 +1,436 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | """ | |
| 3 | Local web server for uploading subtitle files from a browser. | |
| 4 | ||
| 5 | Serves a web UI with drag & drop file upload and exposes an API endpoint | |
| 6 | to receive the subtitle file via HTTP POST. | |
| 7 | """ | |
| 8 | import os | |
| 9 | import socket | |
| 10 | import threading | |
| 11 | import json | |
| 12 | import uuid | |
| 13 | from datetime import datetime | |
| 14 | from http.server import HTTPServer, BaseHTTPRequestHandler | |
| 15 | ||
| 16 | from 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 | |
| 26 | SUBTITLE_EXTENSIONS = {'.srt', '.ass', '.ssa', '.sub', '.vtt', '.txt'} | |
| 27 | ||
| 28 | ||
| 29 | def _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 | ||
| 36 | def _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 | ||
| 42 | class 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">← 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("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'") | |
| 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 | ||
| 329 | class 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 | ||
| 398 | def _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 | ||
| 410 | def 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 | ||
| 427 | def 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" |
| … | … | @@ -0,0 +1,387 @@ |
| 1 | from time import monotonic | |
| 2 | ||
| 3 | import xbmc | |
| 4 | from lib.api.trakt.trakt import TraktAPI | |
| 5 | from lib.api.trakt.lists_cache import lists_cache | |
| 6 | from 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 | ) | |
| 16 | from lib.utils.kodi.utils import get_property_no_fallback, kodilog, get_setting | |
| 17 | ||
| 18 | ||
| 19 | PAUSE_SERVICES_PROP = "jacktook.pause_services" | |
| 20 | DEFAULT_SYNC_INTERVAL_MINUTES = 15 | |
| 21 | WAIT_STEP_SECONDS = 5 | |
| 22 | ||
| 23 | class 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") |
| … | … | @@ -0,0 +1,534 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | """ | |
| 3 | Local web server for managing Stremio addon sources from a phone/browser. | |
| 4 | ||
| 5 | Serves a web UI and exposes small JSON endpoints for CRUD operations | |
| 6 | on the Stremio custom addons stored in the Kodi addon cache. | |
| 7 | """ | |
| 8 | import json | |
| 9 | import os | |
| 10 | import socket | |
| 11 | import threading | |
| 12 | import ipaddress | |
| 13 | from datetime import timedelta | |
| 14 | from http.server import HTTPServer, BaseHTTPRequestHandler | |
| 15 | from urllib.parse import urlsplit, urlunsplit | |
| 16 | ||
| 17 | import requests | |
| 18 | ||
| 19 | from lib.db.cached import cache | |
| 20 | from lib.utils.general.utils import USER_AGENT_HEADER | |
| 21 | from lib.utils.kodi.utils import kodilog | |
| 22 | from 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 | ) | |
| 30 | from 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 | ||
| 45 | def _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 | ||
| 54 | def _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 | ||
| 66 | def _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 | ||
| 110 | def _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 | ||
| 129 | def _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 | ||
| 143 | def _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 | ||
| 166 | def _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 | ||
| 214 | def _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 | ||
| 236 | def _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 | ||
| 257 | def _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 | ||
| 264 | def _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 | ||
| 289 | class 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 | ||
| 490 | class 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 | ||
| 525 | def 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" |
| … | … | @@ -0,0 +1,405 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | import shutil | |
| 3 | import requests | |
| 4 | import os | |
| 5 | from zipfile import ZipFile, BadZipFile | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | import xbmc | |
| 8 | from xbmcvfs import translatePath as translate_path | |
| 9 | ||
| 10 | from 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 | ) | |
| 28 | from lib.utils.general.utils import unzip | |
| 29 | ||
| 30 | ||
| 31 | # ========================= | |
| 32 | # Constants | |
| 33 | # ========================= | |
| 34 | ADDON_ID = "plugin.video.jacktook" | |
| 35 | ADDON_NAME = "Jacktook" | |
| 36 | HEADING = f"{ADDON_NAME} Updater" | |
| 37 | ||
| 38 | PACKAGES_DIR = translate_path("special://home/addons/packages/") | |
| 39 | HOME_ADDONS_DIR = translate_path("special://home/addons/") | |
| 40 | DESTINATION_DIR = translate_path(f"special://home/addons/{ADDON_ID}") | |
| 41 | CHANGELOG_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. | |
| 53 | UPDATE_HOST = "http://192.168.50.38:7221" | |
| 54 | BASE_REPO_URL = f"{UPDATE_HOST}/packages" | |
| 55 | BASE_ZIP_URL = f"{UPDATE_HOST}/zips" | |
| 56 | ||
| 57 | VERSION_FILE = f"{BASE_REPO_URL}/jacktook_version" | |
| 58 | CHANGELOG_FILE = f"{BASE_REPO_URL}/jacktook_changelog" | |
| 59 | ||
| 60 | UPDATE_ACTION_ASK = 0 | |
| 61 | UPDATE_ACTION_NOTIFY = 1 | |
| 62 | UPDATE_ACTION_NONE = 2 | |
| 63 | ||
| 64 | ||
| 65 | # ========================= | |
| 66 | # Helpers | |
| 67 | # ========================= | |
| 68 | def 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 | ||
| 79 | def 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 | ||
| 89 | def 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 | ||
| 102 | def _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 | ||
| 114 | def _read_addon_version_from_xml(xml_path): | |
| 115 | tree = ET.parse(xml_path) | |
| 116 | return tree.getroot().attrib.get("version") | |
| 117 | ||
| 118 | ||
| 119 | def _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 | ||
| 144 | def _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 | ||
| 157 | def 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 | # ========================= | |
| 170 | def 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 | ||
| 222 | def 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 | ||
| 285 | def 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.") |
| … | … | @@ -0,0 +1,116 @@ |
| 1 | from typing import Any, Callable, Dict, Optional | |
| 2 | from lib.utils.kodi.utils import get_setting, notification, translation | |
| 3 | from lib.utils.general.utils import Indexer | |
| 4 | from lib.utils.kodi.settings import get_int_setting | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 14 | def 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 | ||
| 21 | def 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 | ||
| 26 | def 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 | ||
| 39 | def 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 | ||
| 51 | def 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 | ||
| 63 | def 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 | ||
| 74 | def 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 | ||
| 85 | def build_burst_client() -> object: | |
| 86 | from lib.jacktook.client import Burst | |
| 87 | ||
| 88 | return Burst(notification) | |
| 89 | ||
| 90 | ||
| 91 | def 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 | ||
| 103 | CLIENT_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 | ||
| 112 | def get_client(indexer: str) -> Optional[object]: | |
| 113 | builder = CLIENT_BUILDERS.get(indexer) | |
| 114 | if not builder: | |
| 115 | return None | |
| 116 | return builder() |
| … | … | @@ -0,0 +1,618 @@ |
| 1 | import base64 | |
| 2 | from concurrent.futures import ThreadPoolExecutor | |
| 3 | import copy | |
| 4 | import json | |
| 5 | import requests | |
| 6 | from threading import Lock | |
| 7 | from typing import Any, Dict, List, Optional, Tuple | |
| 8 | from urllib.parse import urljoin | |
| 9 | ||
| 10 | from lib.api.debrid.base import ProviderException | |
| 11 | from lib.db.cached import cache | |
| 12 | from lib.clients.debrid.alldebrid import AllDebridHelper | |
| 13 | from lib.clients.debrid.debrider import DebriderHelper | |
| 14 | from lib.clients.debrid.easydebrid import EasyDebridHelper | |
| 15 | from lib.clients.debrid.premiumize import PremiumizeHelper | |
| 16 | from lib.clients.debrid.torbox import TorboxHelper | |
| 17 | from lib.clients.debrid.realdebrid import RealDebridHelper | |
| 18 | from lib.clients.stremio.constants import STREMIO_ADDONS_KEY | |
| 19 | from lib.utils.kodi.utils import get_setting, kodilog, notification, translation | |
| 20 | from lib.utils.torrent.torrserver_utils import extract_torrent_metadata | |
| 21 | from 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 | ) | |
| 38 | from lib.domain.torrent import TorrentStream | |
| 39 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 40 | ||
| 41 | from xbmcgui import Dialog | |
| 42 | from xbmc import LOGDEBUG | |
| 43 | ||
| 44 | ||
| 45 | DEBRID_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 | ||
| 54 | DEBRID_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 | ||
| 62 | PACK_DIRECT_DEBRID_TYPES = {DebridType.RD, DebridType.TB, DebridType.AD} | |
| 63 | ||
| 64 | SUPPORTED_CLOUD_TRANSFER_DEBRIDS = ( | |
| 65 | DebridType.RD, | |
| 66 | DebridType.AD, | |
| 67 | DebridType.TB, | |
| 68 | DebridType.DB, | |
| 69 | ) | |
| 70 | ||
| 71 | ||
| 72 | def _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 | ||
| 90 | def 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 | ||
| 98 | def 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 | ||
| 106 | def 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 | ||
| 124 | def _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 | ||
| 155 | def _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 | ||
| 166 | def _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 | ||
| 177 | def _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 | ||
| 188 | def _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 | ||
| 204 | def 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 | ||
| 242 | def 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 | ||
| 287 | def 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 | ||
| 302 | def 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 | ||
| 310 | def 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 | ||
| 340 | def 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 | ||
| 346 | def 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 | ||
| 363 | def 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 | ||
| 384 | def get_pack_info(debrid_type, info_hash): | |
| 385 | return get_debrid_helper(debrid_type).get_pack_info(info_hash) | |
| 386 | ||
| 387 | ||
| 388 | def 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 | ||
| 410 | def 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 | ||
| 431 | def 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 | ||
| 513 | def 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 | ||
| 518 | def 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 | ||
| 527 | def 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 | ||
| 535 | def is_supported_debrid_type(debrid_type: str) -> bool: | |
| 536 | return debrid_type in DEBRID_HELPERS | |
| 537 | ||
| 538 | ||
| 539 | def 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 |
| … | … | @@ -0,0 +1,83 @@ |
| 1 | from os import path | |
| 2 | ||
| 3 | from lib.jacktook.utils import ADDON_PATH, kodilog | |
| 4 | from lib.utils.kodi.utils import ADDON_PROFILE_PATH | |
| 5 | from lib.vendor.segno import make as segnomake | |
| 6 | import xbmcvfs | |
| 7 | import xbmcgui | |
| 8 | ||
| 9 | ||
| 10 | def 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 | ||
| 26 | class 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() |
| … | … | @@ -0,0 +1,364 @@ |
| 1 | from lib.utils.kodi.utils import translation, get_setting | |
| 2 | from lib.utils.views.continue_watching import has_continue_watching_items | |
| 3 | ||
| 4 | ||
| 5 | trakt_tv_library_items = [ | |
| 6 | {"name": translation(90035), "mode": "tv", "api": "trakt", "query": "trakt_watchlist", "icon": "trakt.png"}, | |
| 7 | {"name": translation(90294), "mode": "tv", "api": "trakt", "query": "trakt_collection", "icon": "trakt.png"}, | |
| 8 | {"name": translation(90295), "mode": "tv", "api": "trakt", "query": "trakt_favorites", "icon": "trakt.png"}, | |
| 9 | {"name": translation(90296), "mode": "tv", "api": "trakt", "query": "trakt_up_next", "icon": "trakt.png"}, | |
| 10 | {"name": translation(90034), "mode": "tv", "api": "trakt", "query": "trakt_watched_history", "icon": "trakt.png"}, | |
| 11 | {"name": translation(90297), "mode": "tv", "api": "trakt", "query": "trakt_my_lists", "icon": "trakt.png"}, | |
| 12 | {"name": translation(90298), "mode": "tv", "api": "trakt", "query": "trakt_liked_lists", "icon": "trakt.png"}, | |
| 13 | {"name": translation(90076), "mode": "tv", "api": "trakt", "query": "trakt_search_lists", "icon": "trakt.png"}, | |
| 14 | {"name": translation(30916), "mode": "tv", "api": "trakt", "query": "trakt_account_info", "icon": "trakt.png"}, | |
| 15 | ] | |
| 16 | ||
| 17 | ||
| 18 | trakt_tv_discovery_items = [ | |
| 19 | {"name": translation(90028), "mode": "tv", "api": "trakt", "query": "trakt_trending", "icon": "trakt.png"}, | |
| 20 | {"name": translation(90029), "mode": "tv", "api": "trakt", "query": "trakt_watched", "icon": "trakt.png"}, | |
| 21 | {"name": translation(90030), "mode": "tv", "api": "trakt", "query": "trakt_favorited", "icon": "trakt.png"}, | |
| 22 | {"name": translation(90033), "mode": "tv", "api": "trakt", "query": "trakt_recommendations", "icon": "trakt.png"}, | |
| 23 | {"name": translation(90032), "mode": "tv", "api": "trakt", "query": "trakt_trending_lists", "icon": "trakt.png"}, | |
| 24 | {"name": translation(90031), "mode": "tv", "api": "trakt", "query": "trakt_popular_lists", "icon": "trakt.png"}, | |
| 25 | {"name": translation(30896), "mode": "tv", "api": "trakt", "query": "trakt_calendar", "icon": "trakt.png"}, | |
| 26 | ] | |
| 27 | ||
| 28 | ||
| 29 | trakt_movie_library_items = [ | |
| 30 | {"name": translation(90035), "mode": "movies", "api": "trakt", "query": "trakt_watchlist", "icon": "trakt.png"}, | |
| 31 | {"name": translation(90294), "mode": "movies", "api": "trakt", "query": "trakt_collection", "icon": "trakt.png"}, | |
| 32 | {"name": translation(90295), "mode": "movies", "api": "trakt", "query": "trakt_favorites", "icon": "trakt.png"}, | |
| 33 | {"name": translation(90200), "mode": "movies", "api": "trakt", "query": "trakt_movies_progress", "icon": "trakt.png"}, | |
| 34 | {"name": translation(90034), "mode": "movies", "api": "trakt", "query": "trakt_watched_history", "icon": "trakt.png"}, | |
| 35 | {"name": translation(90297), "mode": "movies", "api": "trakt", "query": "trakt_my_lists", "icon": "trakt.png"}, | |
| 36 | {"name": translation(90298), "mode": "movies", "api": "trakt", "query": "trakt_liked_lists", "icon": "trakt.png"}, | |
| 37 | {"name": translation(90076), "mode": "movies", "api": "trakt", "query": "trakt_search_lists", "icon": "trakt.png"}, | |
| 38 | {"name": translation(30916), "mode": "movies", "api": "trakt", "query": "trakt_account_info", "icon": "trakt.png"}, | |
| 39 | ] | |
| 40 | ||
| 41 | ||
| 42 | trakt_movie_discovery_items = [ | |
| 43 | {"name": translation(90028), "mode": "movies", "api": "trakt", "query": "trakt_trending", "icon": "trakt.png"}, | |
| 44 | {"name": translation(90036), "mode": "movies", "api": "trakt", "query": "trakt_top10", "icon": "trakt.png"}, | |
| 45 | {"name": translation(90029), "mode": "movies", "api": "trakt", "query": "trakt_watched", "icon": "trakt.png"}, | |
| 46 | {"name": translation(90030), "mode": "movies", "api": "trakt", "query": "trakt_favorited", "icon": "trakt.png"}, | |
| 47 | {"name": translation(90033), "mode": "movies", "api": "trakt", "query": "trakt_recommendations", "icon": "trakt.png"}, | |
| 48 | {"name": translation(90032), "mode": "movies", "api": "trakt", "query": "trakt_trending_lists", "icon": "trakt.png"}, | |
| 49 | {"name": translation(90031), "mode": "movies", "api": "trakt", "query": "trakt_popular_lists", "icon": "trakt.png"}, | |
| 50 | ] | |
| 51 | ||
| 52 | ||
| 53 | tv_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 | ||
| 140 | movie_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 | ||
| 213 | anime_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 | ||
| 286 | animation_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 | ||
| 303 | root_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 | ||
| 336 | history_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 | ||
| 342 | library_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 | ] |
| … | … | @@ -0,0 +1,293 @@ |
| 1 | from enum import Enum | |
| 2 | import re | |
| 3 | from typing import List, Dict, Optional | |
| 4 | ||
| 5 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 6 | from lib.clients.base import TorrentStream | |
| 7 | ||
| 8 | ||
| 9 | class 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 | ||
| 17 | class 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 | ||
| 54 | class SortField(Enum): | |
| 55 | SEEDS = "seeders" | |
| 56 | SIZE = "size" | |
| 57 | DATE = "publishDate" | |
| 58 | QUALITY = "quality" | |
| 59 | CACHED = "isCached" | |
| 60 | ||
| 61 | ||
| 62 | class 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 | ||
| 102 | class 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 | ||
| 148 | class 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 |
| … | … | @@ -0,0 +1,1744 @@ |
| 1 | import os | |
| 2 | import re | |
| 3 | import hashlib | |
| 4 | import unicodedata | |
| 5 | import json | |
| 6 | from email.utils import parsedate_to_datetime | |
| 7 | from enum import Enum | |
| 8 | from urllib.parse import unquote | |
| 9 | import requests | |
| 10 | from typing import Dict, List | |
| 11 | from zipfile import ZipFile | |
| 12 | from concurrent.futures import ThreadPoolExecutor, as_completed | |
| 13 | from datetime import datetime, timedelta | |
| 14 | ||
| 15 | from lib.api.fanart.fanart import get_fanart | |
| 16 | from lib.clients.subtitle.utils import get_language_code | |
| 17 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 18 | from lib.api.trakt.trakt_cache import trakt_watched_cache, trakt_cache | |
| 19 | from lib.api.trakt.lists_cache import lists_cache | |
| 20 | from lib.api.trakt.main_cache import main_cache | |
| 21 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 22 | from lib.utils.general.processors import PostProcessBuilder, PreProcessBuilder | |
| 23 | from lib.clients.base import TorrentStream | |
| 24 | from lib.api.tvdbapi.tvdbapi import TVDBAPI | |
| 25 | from lib.db.cached import cache | |
| 26 | from lib.clients.stremio.constants import ( | |
| 27 | STREMIO_ADDONS_KEY, | |
| 28 | STREMIO_ADDONS_CATALOGS_KEY, | |
| 29 | STREMIO_TV_ADDONS_KEY, | |
| 30 | STREMIO_USER_ADDONS, | |
| 31 | ) | |
| 32 | from lib.db.pickle_db import PickleDatabase | |
| 33 | from 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 | ||
| 56 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 57 | from lib.vendor.torf._magnet import Magnet | |
| 58 | ||
| 59 | from collections import deque | |
| 60 | ||
| 61 | from xbmcgui import Dialog | |
| 62 | from xbmcgui import DialogProgressBG | |
| 63 | from xbmcplugin import addDirectoryItem, setContent, setPluginCategory | |
| 64 | from xbmc import getSupportedMedia | |
| 65 | import xbmc | |
| 66 | ||
| 67 | ||
| 68 | pickle_db = PickleDatabase() | |
| 69 | ||
| 70 | PROVIDER_COLOR_MIN_BRIGHTNESS = 128 | |
| 71 | ||
| 72 | URL_REGEX = r"^(?!\/)(rtmps?:\/\/|mms:\/\/|rtsp:\/\/|https?:\/\/|ftp:\/\/)?([^\/:]+:[^\/@]+@)?(www\.)?(?=[^\/:\s]+\.[^\/:\s]+)([^\/:\s]+\.[^\/:\s]+)(:\d+)?(\/[^#\s]*[\s\S]*)?(\?[^#\s]*)?(#.*)?$" | |
| 73 | ||
| 74 | USER_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 | ||
| 78 | USER_AGENT_STRING = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" | |
| 79 | ||
| 80 | TMDB_POSTER_URL = "http://image.tmdb.org/t/p/w780" | |
| 81 | ||
| 82 | MEDIA_FUSION_DEFAULT_KEY = "eJwBYACf_4hAkZJe85krAoD5hN50-2M0YuyGmgswr-cis3uap4FNnLMvSfOc4e1IcejWJmykujTnWAlQKRi9cct5k3IRqhu-wFBnDoe_QmwMjJI3FnQtFNp2u3jDo23THEEgKXHYqTMrLos=" | |
| 83 | ||
| 84 | dialog_update = {"count": -1, "percent": 50} | |
| 85 | ||
| 86 | UNDESIRED_QUALITIES = ( | |
| 87 | "SD", | |
| 88 | "CAM", | |
| 89 | "TELE", | |
| 90 | "SYNC", | |
| 91 | "TS", | |
| 92 | "HDTV", | |
| 93 | "HDCAM", | |
| 94 | "HDTS", | |
| 95 | "HDTC", | |
| 96 | "HDTVRip", | |
| 97 | ) | |
| 98 | ||
| 99 | video_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 | ||
| 163 | non_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 | ||
| 182 | class Enum: | |
| 183 | @classmethod | |
| 184 | def values(cls): | |
| 185 | return [value for name, value in vars(cls).items() if not name.startswith("_")] | |
| 186 | ||
| 187 | ||
| 188 | class DebridType(Enum): | |
| 189 | RD = "RealDebrid" | |
| 190 | PM = "Premiumize" | |
| 191 | TB = "Torbox" | |
| 192 | ED = "EasyDebrid" | |
| 193 | DB = "Debrider" | |
| 194 | AD = "AllDebrid" | |
| 195 | ||
| 196 | ||
| 197 | class IndexerType(Enum): | |
| 198 | TORRENT = "Torrent" | |
| 199 | DIRECT = "Direct" | |
| 200 | DEBRID = "Debrid" | |
| 201 | STREMIO_DEBRID = "Stremio" | |
| 202 | ||
| 203 | ||
| 204 | class 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 | ||
| 219 | class SearchVariant(Enum): | |
| 220 | DEFAULT = "default" | |
| 221 | TITLE_YEAR = "title_year" | |
| 222 | ORIGINAL_TITLE = "original_title" | |
| 223 | ORIGINAL_TITLE_YEAR = "original_title_year" | |
| 224 | ||
| 225 | ||
| 226 | class Players(Enum): | |
| 227 | JACKTORR = "Jacktorr" | |
| 228 | TORREST = "Torrest" | |
| 229 | ELEMENTUM = "Elementum" | |
| 230 | JACKGRAM = "Jackgram" | |
| 231 | ||
| 232 | ||
| 233 | class 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 | ||
| 247 | class Animation(Enum): | |
| 248 | POPULAR = "Animation_Popular" | |
| 249 | ||
| 250 | ||
| 251 | class Cartoons(Enum): | |
| 252 | SEARCH = "Cartoons_Search" | |
| 253 | POPULAR = "Cartoons_Popular" | |
| 254 | POPULAR_RECENT = "Cartoons_Popular_Recent" | |
| 255 | YEARS = "Cartoons_Years" | |
| 256 | ||
| 257 | ||
| 258 | torrent_clients = [ | |
| 259 | Players.JACKTORR, | |
| 260 | Players.TORREST, | |
| 261 | Players.ELEMENTUM, | |
| 262 | ] | |
| 263 | ||
| 264 | torrent_indexers = [ | |
| 265 | Indexer.PROWLARR, | |
| 266 | Indexer.JACKETT, | |
| 267 | Indexer.TORRENTIO, | |
| 268 | Indexer.PEERFLIX, | |
| 269 | Indexer.ELHOSTED, | |
| 270 | Indexer.BURST, | |
| 271 | ] | |
| 272 | ||
| 273 | ||
| 274 | PROVIDER_COLORS = { | |
| 275 | "debrid": "FF1E90FF", # dodger blue | |
| 276 | "direct": "FFFFA500", # orange | |
| 277 | "stremio": "FF8A2BE2", # blue-violet | |
| 278 | "torrent": "FFFFD700", # gold | |
| 279 | } | |
| 280 | ||
| 281 | ||
| 282 | class 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 | ||
| 300 | def 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 | ||
| 310 | def 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 | ||
| 325 | def is_rd_enabled(): | |
| 326 | return get_setting_fresh("real_debrid_enabled") | |
| 327 | ||
| 328 | ||
| 329 | def is_ad_enabled(): | |
| 330 | return get_setting_fresh("alldebrid_enabled") | |
| 331 | ||
| 332 | ||
| 333 | def is_pm_enabled(): | |
| 334 | return get_setting_fresh("premiumize_enabled") | |
| 335 | ||
| 336 | ||
| 337 | def is_debrider_enabled(): | |
| 338 | return get_setting_fresh("debrider_enabled") | |
| 339 | ||
| 340 | ||
| 341 | def is_tb_enabled(): | |
| 342 | return get_setting_fresh("torbox_enabled") | |
| 343 | ||
| 344 | ||
| 345 | def is_ed_enabled(): | |
| 346 | return get_setting("easydebrid_enabled") | |
| 347 | ||
| 348 | ||
| 349 | def 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 | ||
| 362 | def 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 | ||
| 384 | def 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 | ||
| 402 | def _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 | ||
| 410 | def _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 | ||
| 423 | def _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 | ||
| 437 | def _set_artwork(list_item, data, fanart_data): | |
| 438 | set_listitem_artwork(list_item, data, fanart_data) | |
| 439 | ||
| 440 | ||
| 441 | def _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 | ||
| 461 | def _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 | ||
| 484 | def _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 | ||
| 492 | def _coerce_int(value): | |
| 493 | try: | |
| 494 | return int(value) | |
| 495 | except (TypeError, ValueError): | |
| 496 | return None | |
| 497 | ||
| 498 | ||
| 499 | def _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 | ||
| 515 | def _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 | ||
| 524 | def _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 | ||
| 540 | def _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 | ||
| 577 | def _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 | ||
| 604 | def 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 | ||
| 632 | def 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 | ||
| 646 | def 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 | ||
| 667 | def 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 | ||
| 681 | def 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 | ||
| 707 | def 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 | ||
| 750 | def 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 | ||
| 759 | def tmdb_url(path, size): | |
| 760 | return f"http://image.tmdb.org/t/p/{size}{path}" if path else "" | |
| 761 | ||
| 762 | ||
| 763 | def 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 | ||
| 853 | def 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 | ||
| 877 | def 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 | ||
| 901 | def 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 | ||
| 911 | def 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 | ||
| 917 | def is_in_library(title): | |
| 918 | return pickle_db.get_item("jt:lib", title) is not None | |
| 919 | ||
| 920 | ||
| 921 | def get_fanart_details(tvdb_id="", tmdb_id="", mode="tv"): | |
| 922 | identifier = "{}|{}".format( | |
| 923 | "fanart.tv", tvdb_id if tvdb_id and tvdb_id != "None" else tmdb_id | |
| 924 | ) | |
| 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 | ||
| 949 | def 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 | ||
| 955 | def 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 | ||
| 962 | def get_cached(path, params={}): | |
| 963 | identifier = "{}|{}".format(path, params) | |
| 964 | return cache.get(identifier) | |
| 965 | ||
| 966 | ||
| 967 | def 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 | ||
| 976 | def 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 | ||
| 990 | def 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 | ||
| 1005 | def set_pluging_category(heading: str): | |
| 1006 | setPluginCategory(ADDON_HANDLE, heading) | |
| 1007 | ||
| 1008 | ||
| 1009 | def 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 | ||
| 1024 | def 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 | |
| 1039 | def 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 | ||
| 1066 | def get_colored_languages(languages): | |
| 1067 | if not languages: | |
| 1068 | return "" | |
| 1069 | return " ".join(get_random_color(lang) for lang in languages) | |
| 1070 | ||
| 1071 | ||
| 1072 | def 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 | ||
| 1083 | def 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 | ||
| 1101 | def 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 | ||
| 1106 | def clear_cache_on_update(): | |
| 1107 | clear_history_by_type(update=True) | |
| 1108 | ||
| 1109 | ||
| 1110 | def clear_all_cache(): | |
| 1111 | cache.clean_all() | |
| 1112 | ||
| 1113 | ||
| 1114 | def 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 | ||
| 1122 | def 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 | ||
| 1147 | def clear_database_cache(): | |
| 1148 | clear_history_by_type("all") | |
| 1149 | ||
| 1150 | ||
| 1151 | def 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 | ||
| 1163 | def 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 | ||
| 1173 | def 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 | ||
| 1184 | def 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 | ||
| 1205 | def limit_results(results): | |
| 1206 | limit = int(get_setting("indexers_total_results", 10)) | |
| 1207 | return results[:limit] | |
| 1208 | ||
| 1209 | ||
| 1210 | def get_description_length(): | |
| 1211 | return int(get_setting("indexers_desc_length", 0)) | |
| 1212 | ||
| 1213 | ||
| 1214 | def 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 | ||
| 1226 | def 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 | ||
| 1236 | def 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 | ||
| 1249 | def 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 | ||
| 1268 | def 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 | ||
| 1278 | def 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 | ||
| 1348 | def 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 | ||
| 1357 | def 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 | ||
| 1368 | def 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 | ||
| 1373 | def 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 | ||
| 1388 | def is_video(s): | |
| 1389 | return s.lower().endswith(video_extensions) | |
| 1390 | ||
| 1391 | ||
| 1392 | def get_info_hash_from_magnet(magnet): | |
| 1393 | return Magnet.from_string(magnet).infohash | |
| 1394 | ||
| 1395 | ||
| 1396 | def is_magnet_link(link): | |
| 1397 | if link.startswith("magnet:?"): | |
| 1398 | return link | |
| 1399 | ||
| 1400 | ||
| 1401 | def is_url(url): | |
| 1402 | return bool(re.match(URL_REGEX, url)) | |
| 1403 | ||
| 1404 | ||
| 1405 | def info_hash_to_magnet(info_hash): | |
| 1406 | return f"magnet:?xt=urn:btih:{info_hash}" | |
| 1407 | ||
| 1408 | ||
| 1409 | def get_state_string(state): | |
| 1410 | if 0 <= state <= 9: | |
| 1411 | return translation(30650 + state) | |
| 1412 | return translation(30660) | |
| 1413 | ||
| 1414 | ||
| 1415 | def get_service_host(): | |
| 1416 | return get_jacktorr_setting("service_host") | |
| 1417 | ||
| 1418 | ||
| 1419 | def get_username(): | |
| 1420 | return get_jacktorr_setting("service_login") | |
| 1421 | ||
| 1422 | ||
| 1423 | def get_password(): | |
| 1424 | return get_jacktorr_setting("service_password") | |
| 1425 | ||
| 1426 | ||
| 1427 | def ssl_enabled(): | |
| 1428 | return bool(get_jacktorr_setting("ssl_connection")) | |
| 1429 | ||
| 1430 | ||
| 1431 | def get_port(): | |
| 1432 | return get_jacktorr_setting("service_port") | |
| 1433 | ||
| 1434 | ||
| 1435 | def 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 | ||
| 1450 | def 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 | ||
| 1462 | def 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 | ||
| 1486 | def 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 | ||
| 1504 | def 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 | ||
| 1553 | def 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 | ||
| 1560 | def 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 | ||
| 1576 | def 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 | ||
| 1585 | WEEKDAY_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 | ||
| 1616 | def 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 | ||
| 1681 | def 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 | ||
| 1715 | def 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 | ||
| 1731 | def 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 |
| … | … | @@ -0,0 +1,71 @@ |
| 1 | video_extensions = ( | |
| 2 | '.001', '.3g2', '.3gp', '.asf', '.asx', '.avc', '.avi', '.avs', '.bdm', '.bdmv', '.bin', '.bivx', '.dat', '.divx', | |
| 3 | '.dv', '.dvr-ms', '.evo', '.f4v', '.fli', '.flv', '.h264', '.img', '.iso', '.m2t', '.m2ts', '.m2v', '.m3u8', '.m4v', | |
| 4 | '.mk3d', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.mpl', '.mpls', '.mts', '.nrg', '.nuv', '.ogm', '.ogv', '.pva', | |
| 5 | '.qt', '.rcv', '.rec', '.rmvb', '.sdp', '.svq3', '.tp', '.trp', '.ts', '.ty', '.udf', '.vc1', '.vdr', '.viv', | |
| 6 | '.vob', '.vp3', '.webm', '.wmv', '.xvid') | |
| 7 | ||
| 8 | music_extensions = ( | |
| 9 | '.aac', '.ac3', '.aif', '.aiff', '.ape', '.cmc', '.cmr', '.cue', '.dff', '.dmc', '.dsf', '.dsp', '.dts', '.dtshd', | |
| 10 | '.flac', '.gcm', '.gdm', '.idsp', '.imf', '.m15', '.m4a', '.m4b', '.mac', '.mka', '.mp+', '.mp2', '.mp3', '.mpa', | |
| 11 | '.mpc', '.mpdsp', '.mpp', '.mpt', '.mss', '.oga', '.ogg', '.opus', '.rma', '.rmt', '.rsd', '.sap', '.sfx', '.shn', | |
| 12 | '.spt', '.tak', '.tm2', '.tm8', '.tmc', '.tta', '.uni', '.waa', '.wam', '.wav', '.wma', '.wv', '.wvs', '.xwav') | |
| 13 | ||
| 14 | picture_extensions = ( | |
| 15 | '.apng', '.bmp', '.cbz', '.gif', '.ico', '.jp2', '.jpeg', '.jpg', '.pcx', '.png', '.tga', '.tif', '.tiff', '.webp') | |
| 16 | ||
| 17 | subtitle_extensions = ( | |
| 18 | '.aqt', '.ass', '.idx', '.jss', '.rt', '.smi', '.srt', '.ssa', '.sub', '.text', '.txt', '.utf', '.utf-8', '.utf8') | |
| 19 | ||
| 20 | text_extensions = ( | |
| 21 | '.ada', '.adb', '.ads', '.applescript', '.as', '.asc', '.ascii', '.ascx', '.asm', '.asmx', '.asp', '.aspx', '.atom', | |
| 22 | '.au3', '.awk', '.bas', '.bash', '.bashrc', '.bat', '.bbcolors', '.bcp', '.bdsgroup', '.bdsproj', '.bib', | |
| 23 | '.bowerrc', '.c', '.cbl', '.cc', '.cfc', '.cfg', '.cfm', '.cfml', '.cgi', '.clj', '.cljs', '.cls', '.cmake', '.cmd', | |
| 24 | '.cnf', '.cob', '.code-snippets', '.coffee', '.coffeekup', '.conf', '.cp', '.cpp', '.cpt', '.cpy', '.crt', '.cs', | |
| 25 | '.csh', '.cson', '.csproj', '.csr', '.css', '.csslintrc', '.csv', '.ctl', '.curlrc', '.cxx', '.d', '.dart', '.dfm', | |
| 26 | '.diff', '.dof', '.dpk', '.dpr', '.dproj', '.dtd', '.eco', '.editorconfig', '.ejs', '.el', '.elm', '.emacs', '.eml', | |
| 27 | '.ent', '.erb', '.erl', '.eslintignore', '.eslintrc', '.ex', '.exs', '.f', '.f03', '.f77', '.f90', '.f95', '.fish', | |
| 28 | '.for', '.fpp', '.frm', '.fs', '.fsproj', '.fsx', '.ftn', '.gemrc', '.gemspec', '.gitattributes', '.gitconfig', | |
| 29 | '.gitignore', '.gitkeep', '.gitmodules', '.go', '.gpp', '.gradle', '.groovy', '.groupproj', '.grunit', '.gtmpl', | |
| 30 | '.gvimrc', '.h', '.haml', '.hbs', '.hgignore', '.hh', '.hpp', '.hrl', '.hs', '.hta', '.htaccess', '.htc', '.htm', | |
| 31 | '.html', '.htpasswd', '.hxx', '.iced', '.iml', '.inc', '.inf', '.info', '.ini', '.ino', '.int', '.irbrc', '.itcl', | |
| 32 | '.itermcolors', '.itk', '.jade', '.java', '.jhtm', '.jhtml', '.js', '.jscsrc', '.jshintignore', '.jshintrc', | |
| 33 | '.json', '.json5', '.jsonld', '.jsp', '.jspx', '.jsx', '.ksh', '.less', '.lhs', '.lisp', '.log', '.ls', '.lsp', | |
| 34 | '.lua', '.m', '.m4', '.mak', '.map', '.markdown', '.master', '.md', '.mdown', '.mdwn', '.mdx', '.metadata', '.mht', | |
| 35 | '.mhtml', '.mjs', '.mk', '.mkd', '.mkdn', '.mkdown', '.ml', '.mli', '.mm', '.mxml', '.nfm', '.nfo', '.noon', | |
| 36 | '.npmignore', '.npmrc', '.nuspec', '.nvmrc', '.ops', '.pas', '.pasm', '.patch', '.pbxproj', '.pch', '.pem', '.pg', | |
| 37 | '.php', '.php3', '.php4', '.php5', '.phpt', '.phtml', '.pir', '.pl', '.pm', '.pmc', '.pod', '.pot', '.prettierrc', | |
| 38 | '.properties', '.props', '.pt', '.pug', '.purs', '.py', '.pyx', '.r', '.rake', '.rb', '.rbw', '.rc', '.rdoc', | |
| 39 | '.rdoc_options', '.resx', '.rexx', '.rhtml', '.rjs', '.rlib', '.ron', '.rs', '.rss', '.rst', '.rtf', '.rvmrc', | |
| 40 | '.rxml', '.s', '.sass', '.scala', '.scm', '.scss', '.seestyle', '.sh', '.shtml', '.sln', '.sls', '.spec', '.sql', | |
| 41 | '.sqlite', '.sqlproj', '.srt', '.ss', '.sss', '.st', '.strings', '.sty', '.styl', '.stylus', '.sub', | |
| 42 | '.sublime-build', '.sublime-commands', '.sublime-completions', '.sublime-keymap', '.sublime-macro', '.sublime-menu', | |
| 43 | '.sublime-project', '.sublime-settings', '.sublime-workspace', '.sv', '.svc', '.svg', '.swift', '.t', '.tcl', | |
| 44 | '.tcsh', '.terminal', '.tex', '.text', '.textile', '.tg', '.tk', '.tmlanguage', '.tmpl', '.tmtheme', '.tpl', '.ts', | |
| 45 | '.tsv', '.tsx', '.tt', '.tt2', '.ttml', '.twig', '.txt', '.v', '.vb', '.vbproj', '.vbs', '.vcproj', '.vcxproj', | |
| 46 | '.vh', '.vhd', '.vhdl', '.vim', '.viminfo', '.vimrc', '.vm', '.vue', '.webapp', '.webmanifest', '.wsc', '.x-php', | |
| 47 | '.xaml', '.xht', '.xhtml', '.xml', '.xs', '.xsd', '.xsl', '.xslt', '.y', '.yaml', '.yml', '.zsh', '.zshrc') | |
| 48 | ||
| 49 | ||
| 50 | def _contains_extension(s, extensions): | |
| 51 | return s.lower().endswith(extensions) | |
| 52 | ||
| 53 | ||
| 54 | def is_video(s): | |
| 55 | return _contains_extension(s, video_extensions) | |
| 56 | ||
| 57 | ||
| 58 | def is_music(s): | |
| 59 | return _contains_extension(s, music_extensions) | |
| 60 | ||
| 61 | ||
| 62 | def is_picture(s): | |
| 63 | return _contains_extension(s, picture_extensions) | |
| 64 | ||
| 65 | ||
| 66 | def is_subtitle(s): | |
| 67 | return _contains_extension(s, subtitle_extensions) | |
| 68 | ||
| 69 | ||
| 70 | def is_text(s): | |
| 71 | return _contains_extension(s, text_extensions) |
| … | … | @@ -0,0 +1,44 @@ |
| 1 | from lib.utils.kodi.utils import ( | |
| 2 | action_url_run, | |
| 3 | kodi_play_media, | |
| 4 | translation, | |
| 5 | ) | |
| 6 | import json | |
| 7 | ||
| 8 | ||
| 9 | def 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 |
| … | … | @@ -0,0 +1,46 @@ |
| 1 | import re | |
| 2 | from urllib.parse import urlsplit | |
| 3 | ||
| 4 | import xbmc | |
| 5 | ||
| 6 | ||
| 7 | _ERROR_PREFIX_RE = re.compile( | |
| 8 | r"^(?:\[[^\]]+\]\s*)?(?:error|failed|traceback|exception|crash)\b", | |
| 9 | re.IGNORECASE, | |
| 10 | ) | |
| 11 | ||
| 12 | ||
| 13 | def _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 | ||
| 23 | def kodilog(message, level=None): | |
| 24 | xbmc.log("[###JACKTOOKLOG###] " + str(message), _resolve_log_level(message, level)) | |
| 25 | ||
| 26 | ||
| 27 | def 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] |
| … | … | @@ -0,0 +1,57 @@ |
| 1 | from .utils import get_setting, get_property, ADDON_ID | |
| 2 | import xbmc | |
| 3 | ||
| 4 | ||
| 5 | EMPTY_USER = "unknown_user" | |
| 6 | ||
| 7 | ||
| 8 | def addon_settings(): | |
| 9 | return xbmc.executebuiltin(f"Addon.OpenSettings({ADDON_ID})") | |
| 10 | ||
| 11 | ||
| 12 | def auto_play_enabled(): | |
| 13 | return get_setting("auto_play") | |
| 14 | ||
| 15 | ||
| 16 | def get_int_setting(setting): | |
| 17 | return int(get_setting(setting)) | |
| 18 | ||
| 19 | ||
| 20 | def update_delay(default=45): | |
| 21 | return default | |
| 22 | ||
| 23 | ||
| 24 | def is_cache_enabled(): | |
| 25 | return get_setting("cache_enabled") | |
| 26 | ||
| 27 | ||
| 28 | def cache_clear_update(): | |
| 29 | return get_setting("clear_cache_update") | |
| 30 | ||
| 31 | ||
| 32 | def get_cache_expiration(): | |
| 33 | return get_int_setting("cache_expiration") | |
| 34 | ||
| 35 | ||
| 36 | def get_jackett_timeout(): | |
| 37 | return get_int_setting("jackett_timeout") | |
| 38 | ||
| 39 | ||
| 40 | def get_prowlarr_timeout(): | |
| 41 | return get_int_setting("prowlarr_timeout") | |
| 42 | ||
| 43 | ||
| 44 | def trakt_client(): | |
| 45 | return get_setting("trakt_client", "") | |
| 46 | ||
| 47 | ||
| 48 | def trakt_secret(): | |
| 49 | return get_setting("trakt_secret", "") | |
| 50 | ||
| 51 | ||
| 52 | def trakt_lists_sort_order(setting): | |
| 53 | return int(get_setting("trakt_sort_%s" % setting, "0")) | |
| 54 | ||
| 55 | ||
| 56 | def get_update_action(): | |
| 57 | return int(get_setting("update_action", "0")) |
| … | … | @@ -0,0 +1,523 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | from datetime import datetime, timedelta, timezone | |
| 4 | import xml.etree.ElementTree as ET | |
| 5 | ||
| 6 | import requests | |
| 7 | import xbmcgui | |
| 8 | ||
| 9 | from 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 | ) | |
| 17 | from lib.api.stremio.addon_manager import build_addon_instance_key | |
| 18 | from lib.db.cached import cache | |
| 19 | from lib.db.pickle_db import PickleDatabase | |
| 20 | from 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 | ) | |
| 28 | from 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 | ||
| 45 | BACKUP_VERSION = 1 | |
| 46 | CUSTOM_ADDON_EXPIRY = timedelta(days=365 * 20) | |
| 47 | SETTINGS_XML_PATH = os.path.join(ADDON_PATH, "resources", "settings.xml") | |
| 48 | EXPORT_FILENAME_TEMPLATE = "jacktook-settings-backup-{timestamp}.json" | |
| 49 | RESTORE_SOURCE_FILE = "file" | |
| 50 | RESTORE_SOURCE_URL = "url" | |
| 51 | ||
| 52 | CUSTOM_SELECTION_KEYS = { | |
| 53 | "stream": STREMIO_ADDONS_KEY, | |
| 54 | "catalog": STREMIO_ADDONS_CATALOGS_KEY, | |
| 55 | "tv": STREMIO_TV_ADDONS_KEY, | |
| 56 | } | |
| 57 | ||
| 58 | SENSITIVE_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 | ||
| 77 | IDENTITY_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 | ||
| 87 | AUTH_STATE_SETTING_IDS = { | |
| 88 | "real_debid_authorized", | |
| 89 | "alldebrid_authorized", | |
| 90 | "debrider_authorized", | |
| 91 | "stremio_loggedin", | |
| 92 | "is_trakt_auth", | |
| 93 | } | |
| 94 | ||
| 95 | SCRUBBED_SETTING_IDS = ( | |
| 96 | SENSITIVE_SETTING_IDS | IDENTITY_SETTING_IDS | AUTH_STATE_SETTING_IDS | |
| 97 | ) | |
| 98 | ||
| 99 | EXTRA_DYNAMIC_SETTINGS = { | |
| 100 | "trakt_token": "", | |
| 101 | "trakt_refresh": "", | |
| 102 | "trakt_expires": "", | |
| 103 | } | |
| 104 | ||
| 105 | SENSITIVE_DYNAMIC_SETTING_IDS = set(EXTRA_DYNAMIC_SETTINGS) | |
| 106 | ||
| 107 | ||
| 108 | def _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 | ||
| 116 | def _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 | ||
| 135 | def _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 | ||
| 143 | def _custom_addon_key(addon): | |
| 144 | return build_addon_instance_key(addon) or None | |
| 145 | ||
| 146 | ||
| 147 | def _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 | ||
| 159 | def _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 | ||
| 170 | def 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 | ||
| 205 | def _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 | ||
| 230 | def _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 | ||
| 246 | def _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 | ||
| 260 | def 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 | ||
| 322 | def 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 | ||
| 348 | def _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 | ||
| 362 | def 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 | ||
| 375 | def _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 | ||
| 392 | def 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 | ||
| 408 | def _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 | ||
| 424 | def _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 | ||
| 437 | def _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 | ||
| 449 | def _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 | ||
| 454 | def _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 | ||
| 460 | def 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 | ||
| 494 | def 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 | ||
| 510 | def 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)) |
| … | … | @@ -0,0 +1,775 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | from datetime import date, datetime, timedelta | |
| 3 | import json | |
| 4 | import os | |
| 5 | import re | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | import sys | |
| 8 | import time | |
| 9 | from typing import Any, Union | |
| 10 | ||
| 11 | from urllib.parse import quote, urlencode | |
| 12 | from lib.db.cached import cache | |
| 13 | from lib.utils.kodi.logging import kodilog | |
| 14 | ||
| 15 | import xbmc | |
| 16 | import xbmcaddon | |
| 17 | import xbmcgui | |
| 18 | ||
| 19 | from xbmcgui import Window, ListItem | |
| 20 | from xbmcplugin import addDirectoryItems, setResolvedUrl, endOfDirectory | |
| 21 | from 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 | ||
| 31 | MOVIES_TYPE = "movies" | |
| 32 | SHOWS_TYPE = "tvshows" | |
| 33 | SEASONS_TYPE = "seasons" | |
| 34 | EPISODES_TYPE = "episodes" | |
| 35 | TITLES_TYPE = "titles" | |
| 36 | ||
| 37 | TORREST_ADDON_ID = "plugin.video.torrest" | |
| 38 | JACKTORR_ADDON_ID = "plugin.video.jacktorr" | |
| 39 | ELEMENTUM_ADDON_ID = "plugin.video.elementum" | |
| 40 | YOUTUBE_ADDON_ID = "plugin.video.youtube" | |
| 41 | JACKTOOK_BURST_ADOON_ID = "script.jacktook.burst" | |
| 42 | ||
| 43 | ||
| 44 | def _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 | ||
| 53 | JACKTORR_ADDON = _get_jacktorr_addon() | |
| 54 | ||
| 55 | ||
| 56 | ADDON = xbmcaddon.Addon() | |
| 57 | try: | |
| 58 | ADDON_HANDLE = int(sys.argv[1]) | |
| 59 | except IndexError: | |
| 60 | ADDON_HANDLE = 0 | |
| 61 | ||
| 62 | ADDON_PATH = ADDON.getAddonInfo("path") | |
| 63 | ADDON_PROFILE_PATH = translate_path(ADDON.getAddonInfo("profile")) | |
| 64 | IMAGES_PATH = f"{ADDON_PATH}/resources/images/" | |
| 65 | DEFAULT_LOGO = f"{IMAGES_PATH}tv.png" | |
| 66 | ADDON_ICON = ADDON.getAddonInfo("icon") | |
| 67 | ADDON_ID = ADDON.getAddonInfo("id") | |
| 68 | ADDON_VERSION = ADDON.getAddonInfo("version") | |
| 69 | ADDON_NAME = ADDON.getAddonInfo("name") | |
| 70 | PLAYLIST = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) | |
| 71 | ||
| 72 | CHANGELOG_PATH = translate_path( | |
| 73 | "special://home/addons/plugin.video.jacktook/CHANGELOG.md" | |
| 74 | ) | |
| 75 | ||
| 76 | progressDialog = xbmcgui.DialogProgress() | |
| 77 | ||
| 78 | ||
| 79 | def 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 | ||
| 94 | def _setting_cache_prop(setting_id: str) -> str: | |
| 95 | return f"jacktook.setting.{setting_id}" | |
| 96 | ||
| 97 | ||
| 98 | def _settings_snapshot_prop() -> str: | |
| 99 | return "jacktook.settings" | |
| 100 | ||
| 101 | ||
| 102 | def _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 | ||
| 112 | def _profile_settings_xml_path() -> str: | |
| 113 | return os.path.join(ADDON_PROFILE_PATH, "settings.xml") | |
| 114 | ||
| 115 | ||
| 116 | def 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 | ||
| 135 | def 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 | ||
| 144 | def 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 | ||
| 150 | def 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 | ||
| 157 | def clear_cached_settings(): | |
| 158 | Window(10000).clearProperty(_settings_snapshot_prop()) | |
| 159 | ||
| 160 | ||
| 161 | def _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 | ||
| 172 | def 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 | ||
| 185 | def 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 | ||
| 200 | def set_setting(id, value): | |
| 201 | ADDON.setSetting(id, value) | |
| 202 | set_cached_setting_property(id, value) | |
| 203 | ||
| 204 | ||
| 205 | def get_property_no_fallback(prop: str): | |
| 206 | return Window(10000).getProperty(prop) | |
| 207 | ||
| 208 | ||
| 209 | def 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 | ||
| 220 | def set_property_no_fallback(prop: str, value: Any): | |
| 221 | Window(10000).setProperty(prop, value) | |
| 222 | ||
| 223 | ||
| 224 | def set_property(prop: str, value: Any): | |
| 225 | Window(10000).setProperty(prop, value) | |
| 226 | cache.set(prop, value, timedelta(days=30)) | |
| 227 | ||
| 228 | ||
| 229 | def clear_property(prop): | |
| 230 | return Window(10000).clearProperty(prop) | |
| 231 | ||
| 232 | ||
| 233 | def burst_addon_settings(): | |
| 234 | close_all_dialog() | |
| 235 | xbmc.executebuiltin("Addon.OpenSettings(%s)" % JACKTOOK_BURST_ADOON_ID) | |
| 236 | ||
| 237 | ||
| 238 | def get_kodi_version(): | |
| 239 | build = xbmc.getInfoLabel("System.BuildVersion") | |
| 240 | kodi_version = int(build.split()[0][:2]) | |
| 241 | return kodi_version | |
| 242 | ||
| 243 | ||
| 244 | def is_torrest_addon(): | |
| 245 | return xbmc.getCondVisibility(f"System.HasAddon({TORREST_ADDON_ID})") | |
| 246 | ||
| 247 | ||
| 248 | def is_jacktorr_addon(): | |
| 249 | return xbmc.getCondVisibility(f"System.HasAddon({JACKTORR_ADDON_ID})") | |
| 250 | ||
| 251 | ||
| 252 | def 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 | ||
| 264 | def 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 | ||
| 276 | def is_elementum_addon(): | |
| 277 | return xbmc.getCondVisibility(f"System.HasAddon({ELEMENTUM_ADDON_ID})") | |
| 278 | ||
| 279 | ||
| 280 | def is_burst_addon(): | |
| 281 | return xbmc.getCondVisibility(f"System.HasAddon({JACKTOOK_BURST_ADOON_ID})") | |
| 282 | ||
| 283 | ||
| 284 | def 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 | ||
| 305 | def translation(id_value): | |
| 306 | return ADDON.getLocalizedString(id_value) | |
| 307 | ||
| 308 | ||
| 309 | def logger(message, level=xbmc.LOGINFO): | |
| 310 | xbmc.log("[JACKTOOK] " + str(message), level) | |
| 311 | ||
| 312 | ||
| 313 | def get_url(**kwargs): | |
| 314 | return "{}?{}".format(_URL, urlencode(kwargs)) | |
| 315 | ||
| 316 | ||
| 317 | def set_art(list_item, artwork_url): | |
| 318 | if artwork_url: | |
| 319 | list_item.setArt({"poster": artwork_url, "thumb": artwork_url}) | |
| 320 | ||
| 321 | ||
| 322 | def 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 | ||
| 333 | def 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 | ||
| 342 | def kodi_refresh(): | |
| 343 | execute_builtin('UpdateLibrary(video,special://skin/foo)') | |
| 344 | ||
| 345 | ||
| 346 | def refresh(): | |
| 347 | xbmc.executebuiltin("Container.Refresh") | |
| 348 | ||
| 349 | ||
| 350 | def notification(message, heading=ADDON_NAME, icon=ADDON_ICON, time=2000, sound=True): | |
| 351 | xbmcgui.Dialog().notification(heading, message, icon, time, sound) | |
| 352 | ||
| 353 | ||
| 354 | def dialog_ok(heading, line1, line2="", line3=""): | |
| 355 | return xbmcgui.Dialog().ok(heading, compat(line1=line1, line2=line2, line3=line3)) | |
| 356 | ||
| 357 | ||
| 358 | def 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 | ||
| 372 | def 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 | ||
| 384 | def dialog_select(heading, _list): | |
| 385 | dialog = xbmcgui.Dialog() | |
| 386 | return dialog.select(heading, _list) | |
| 387 | ||
| 388 | ||
| 389 | def close_all_dialog(): | |
| 390 | execute_builtin("Dialog.Close(all,true)") | |
| 391 | ||
| 392 | ||
| 393 | def 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 | ||
| 403 | def container_refresh(): | |
| 404 | execute_builtin("Container.Refresh") | |
| 405 | ||
| 406 | ||
| 407 | def action_url_run(name, **kwargs): | |
| 408 | return "RunPlugin({})".format(build_url(name, **kwargs)) | |
| 409 | ||
| 410 | ||
| 411 | def 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 | ||
| 419 | def 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 | ||
| 425 | def 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 | ||
| 430 | def 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 | ||
| 435 | def kodi_play_media(name, *args, **kwargs): | |
| 436 | return "PlayMedia({})".format(build_url(name, *args, **kwargs)) | |
| 437 | ||
| 438 | ||
| 439 | def show_busy_dialog(): | |
| 440 | execute_builtin("ActivateWindow(busydialognocancel)") | |
| 441 | ||
| 442 | ||
| 443 | def show_picture(url): | |
| 444 | xbmc.executebuiltin('ShowPicture("{}")'.format(url)) | |
| 445 | ||
| 446 | ||
| 447 | def close_busy_dialog(): | |
| 448 | execute_builtin("Dialog.Close(busydialognocancel)") | |
| 449 | execute_builtin("Dialog.Close(busydialog)") | |
| 450 | ||
| 451 | ||
| 452 | def execute_builtin(command, block=False): | |
| 453 | return xbmc.executebuiltin(command, block) | |
| 454 | ||
| 455 | ||
| 456 | def get_visibility(): | |
| 457 | return xbmc.getCondVisibility | |
| 458 | ||
| 459 | ||
| 460 | def delete_file(file): | |
| 461 | xbmc_delete(file) | |
| 462 | ||
| 463 | ||
| 464 | def update_local_addons(): | |
| 465 | execute_builtin("UpdateLocalAddons", True) | |
| 466 | sleep(2500) | |
| 467 | ||
| 468 | ||
| 469 | def 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 | ||
| 495 | def 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 | ||
| 512 | def 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 | ||
| 522 | def convert_size_to_bytes(size_str: str) -> int: | |
| 523 | """Convert size string to bytes.""" | |
| 524 | match = re.match(r"(\d+(?:\.\d+)?)\s*(GB|MB|KB|B)", size_str, re.IGNORECASE) | |
| 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 | ||
| 539 | def sleep(miliseconds): | |
| 540 | xbmc.sleep(miliseconds) | |
| 541 | ||
| 542 | ||
| 543 | def 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 | ||
| 550 | def get_current_view_id(): | |
| 551 | return xbmcgui.Window(xbmcgui.getCurrentWindowId()).getFocusId() | |
| 552 | ||
| 553 | ||
| 554 | SECTION_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 | ||
| 566 | def get_view_setting_key(view_key): | |
| 567 | return "saved_%s" % view_key.replace(".", "_") | |
| 568 | ||
| 569 | ||
| 570 | def get_view_property_key(view_key): | |
| 571 | return "jacktook.%s" % view_key | |
| 572 | ||
| 573 | ||
| 574 | def _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 | ||
| 590 | def _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 | ||
| 606 | def _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 | ||
| 620 | def set_saved_view_property(view_key, view_id): | |
| 621 | set_property_no_fallback(get_view_property_key(view_key), str(view_id)) | |
| 622 | ||
| 623 | ||
| 624 | def 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 | ||
| 633 | def 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 | ||
| 640 | def 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 | ||
| 647 | def 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 | ||
| 654 | def _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 | ||
| 667 | def 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 | ||
| 676 | def 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 | ||
| 684 | def 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 | ||
| 689 | def container_content(): | |
| 690 | return xbmc.getInfoLabel("Container.Content") | |
| 691 | ||
| 692 | ||
| 693 | def 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 | ||
| 713 | def 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 | ||
| 727 | def list_dirs(location): | |
| 728 | return listdir(location) | |
| 729 | ||
| 730 | ||
| 731 | def translatePath(_path): | |
| 732 | return translate_path(_path) | |
| 733 | ||
| 734 | ||
| 735 | def open_file(_file, mode="r"): | |
| 736 | return xbmcvfs_File(_file, mode) | |
| 737 | ||
| 738 | ||
| 739 | def 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 | ||
| 750 | def finish_action(): | |
| 751 | setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True)) | |
| 752 | ||
| 753 | ||
| 754 | def 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 | ||
| 763 | def add_directory_items_batch(items): | |
| 764 | if items: | |
| 765 | addDirectoryItems(ADDON_HANDLE, items) | |
| 766 | ||
| 767 | ||
| 768 | def is_widget(): | |
| 769 | return "jacktook" not in xbmc.getInfoLabel("Container.PluginName") | |
| 770 | ||
| 771 | ||
| 772 | def end_of_directory(cache=True): | |
| 773 | endOfDirectory( | |
| 774 | ADDON_HANDLE, cacheToDisc=False if is_widget() or not cache else True | |
| 775 | ) |
| … | … | @@ -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 | ||
| 1237 | LANGUAGE_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 | ||
| 1261 | def _check_flag(flag, country): | |
| 1262 | if country['flag'] == flag: return True | |
| 1263 | return False | |
| 1264 | ||
| 1265 | def _return_language(country): | |
| 1266 | return country['language']['name'] | |
| 1267 | ||
| 1268 | def _flag_is_minor_than(flag, country): | |
| 1269 | if country['flag'] < flag: return True | |
| 1270 | return False | |
| 1271 | ||
| 1272 | def _binary_search(validate_func, return_func, is_minor_func, array, target, low, high): | |
| 1273 | # array MUST be ordered | |
| 1274 | if low > high: return None | |
| 1275 | else: | |
| 1276 | mid = (low + high)//2 | |
| 1277 | if validate_func(target, array[mid]): | |
| 1278 | return return_func(array[mid]) | |
| 1279 | elif is_minor_func(target, array[mid]): | |
| 1280 | return _binary_search(validate_func,return_func,is_minor_func,array,target,mid+1,high) | |
| 1281 | else: | |
| 1282 | return _binary_search(validate_func,return_func,is_minor_func,array,target,low,mid-1) | |
| 1283 | ||
| 1284 | def find_language_by_unicode(flag_unicode): | |
| 1285 | return _binary_search(_check_flag, _return_language, _flag_is_minor_than, _countries, flag_unicode, 0, len(_countries) - 1) |
| … | … | @@ -0,0 +1,550 @@ |
| 1 | from lib.utils.localization.countries import find_language_by_unicode | |
| 2 | import re | |
| 3 | ||
| 4 | langsSet = { | |
| 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 | } | |
| 268 | language_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 | ||
| 536 | def extract_unicode_flags(text): | |
| 537 | flag_pattern = re.compile(r"[\U0001F1E6-\U0001F1FF]{2}") | |
| 538 | flags = flag_pattern.findall(text) | |
| 539 | return flags | |
| 540 | ||
| 541 | ||
| 542 | def 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) |
| … | … | @@ -0,0 +1,136 @@ |
| 1 | import re | |
| 2 | ||
| 3 | def 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 | ||
| 8 | def 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 | ||
| 41 | def 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 |
| … | … | @@ -0,0 +1,546 @@ |
| 1 | #!/usr/bin/env python | |
| 2 | "Makes working with XML feel like you are working with JSON" | |
| 3 | ||
| 4 | try: | |
| 5 | from defusedexpat import pyexpat as expat | |
| 6 | except ImportError: | |
| 7 | from xml.parsers import expat | |
| 8 | from xml.sax.saxutils import XMLGenerator | |
| 9 | from xml.sax.xmlreader import AttributesImpl | |
| 10 | try: # pragma no cover | |
| 11 | from cStringIO import StringIO | |
| 12 | except ImportError: # pragma no cover | |
| 13 | try: | |
| 14 | from StringIO import StringIO | |
| 15 | except ImportError: | |
| 16 | from io import StringIO | |
| 17 | ||
| 18 | _dict = dict | |
| 19 | import platform | |
| 20 | if tuple(map(int, platform.python_version_tuple()[:2])) < (3, 7): | |
| 21 | from collections import OrderedDict as _dict | |
| 22 | ||
| 23 | from inspect import isgenerator | |
| 24 | ||
| 25 | try: # pragma no cover | |
| 26 | _basestring = basestring | |
| 27 | except NameError: # pragma no cover | |
| 28 | _basestring = str | |
| 29 | try: # pragma no cover | |
| 30 | _unicode = unicode | |
| 31 | except NameError: # pragma no cover | |
| 32 | _unicode = str | |
| 33 | ||
| 34 | __author__ = 'Martin Blech' | |
| 35 | __version__ = '0.13.0' | |
| 36 | __license__ = 'MIT' | |
| 37 | ||
| 38 | ||
| 39 | class ParsingInterrupted(Exception): | |
| 40 | pass | |
| 41 | ||
| 42 | ||
| 43 | class _DictSAXHandler(object): | |
| 44 | def __init__(self, | |
| 45 | item_depth=0, | |
| 46 | item_callback=lambda *args: True, | |
| 47 | xml_attribs=True, | |
| 48 | attr_prefix='@', | |
| 49 | cdata_key='#text', | |
| 50 | force_cdata=False, | |
| 51 | cdata_separator='', | |
| 52 | postprocessor=None, | |
| 53 | dict_constructor=_dict, | |
| 54 | strip_whitespace=True, | |
| 55 | namespace_separator=':', | |
| 56 | namespaces=None, | |
| 57 | force_list=None, | |
| 58 | comment_key='#comment'): | |
| 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 | ||
| 202 | def 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 | ||
| 382 | def _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 | ||
| 397 | def _emit(key, value, content_handler, | |
| 398 | attr_prefix='@', | |
| 399 | cdata_key='#text', | |
| 400 | depth=0, | |
| 401 | preprocessor=None, | |
| 402 | pretty=False, | |
| 403 | newl='\n', | |
| 404 | indent='\t', | |
| 405 | namespace_separator=':', | |
| 406 | namespaces=None, | |
| 407 | full_document=True, | |
| 408 | expand_iter=None): | |
| 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 | ||
| 478 | def 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 | ||
| 521 | if __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 |
| … | … | @@ -0,0 +1,318 @@ |
| 1 | from urllib.parse import quote | |
| 2 | from lib.jacktook.utils import kodilog | |
| 3 | from lib.utils.debrid.debrid_utils import ( | |
| 4 | get_debrid_direct_url, | |
| 5 | get_debrid_pack_direct_url, | |
| 6 | is_supported_debrid_type, | |
| 7 | ) | |
| 8 | from 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 | ) | |
| 17 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 18 | from lib.utils.general.utils import ( | |
| 19 | DebridType, | |
| 20 | IndexerType, | |
| 21 | Indexer, | |
| 22 | Players, | |
| 23 | torrent_clients, | |
| 24 | ) | |
| 25 | from xbmcgui import Dialog | |
| 26 | from xbmc import LOGDEBUG | |
| 27 | from typing import Any, Dict, Optional | |
| 28 | ||
| 29 | ||
| 30 | def 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 | ||
| 50 | def 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 | ||
| 86 | def 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 | ||
| 100 | def 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 | ||
| 135 | def 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 | ||
| 149 | def 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 | ||
| 158 | def 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 | ||
| 167 | def 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 | ||
| 188 | def 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 | ||
| 219 | def 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 | ||
| 240 | def 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 | ||
| 305 | class TorrentException(Exception): | |
| 306 | def __init__( | |
| 307 | self, message: str, status_code: Optional[int] = None, error_content: Any = None | |
| 308 | ): | |
| 309 | self.message = message | |
| 310 | self.status_code = status_code | |
| 311 | self.error_content = error_content | |
| 312 | details = f"{self.message}" | |
| 313 | if self.status_code is not None: | |
| 314 | details += f" (Status code: {self.status_code})" | |
| 315 | if self.error_content is not None: | |
| 316 | details += f"\nError content: {self.error_content}" | |
| 317 | super().__init__(details) | |
| 318 | notification(details) |
| … | … | @@ -0,0 +1,59 @@ |
| 1 | from lib.api.plex.media_server import check_server_connection, get_servers | |
| 2 | from lib.api.plex.plex import PlexApi | |
| 3 | from lib.utils.kodi.utils import get_setting, kodilog, set_setting | |
| 4 | from xbmcgui import Dialog | |
| 5 | ||
| 6 | ||
| 7 | plex = PlexApi() | |
| 8 | ||
| 9 | server_config = {"discoveryUrl": [], "streamingUrl": []} | |
| 10 | ||
| 11 | def 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 | ||
| 19 | def 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 | ||
| 39 | def 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 | ||
| 49 | def 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 | ||
| 58 | def plex_logout(): | |
| 59 | plex.logout() |
| … | … | @@ -0,0 +1,50 @@ |
| 1 | from datetime import timedelta | |
| 2 | from lib.clients.stremio.addon_client import StremioAddonCatalogsClient | |
| 3 | from lib.db.cached import cache | |
| 4 | from lib.utils.kodi.utils import kodilog | |
| 5 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 6 | ||
| 7 | ||
| 8 | def 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 |
| … | … | @@ -0,0 +1,29 @@ |
| 1 | """ | |
| 2 | Lazy TMDb initialization utility. | |
| 3 | ||
| 4 | Provides a function to ensure the TMDb API key and language are | |
| 5 | configured before any TMDB operation. Since TMDb stores configuration | |
| 6 | in environment variables, this only needs to be called once per | |
| 7 | addon invocation. | |
| 8 | """ | |
| 9 | ||
| 10 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 11 | from lib.clients.tmdb.utils.utils import LANGUAGES | |
| 12 | from lib.utils.kodi.utils import get_setting | |
| 13 | ||
| 14 | _tmdb_initialized = False | |
| 15 | ||
| 16 | ||
| 17 | def ensure_tmdb_init(): | |
| 18 | """Initialize TMDb API key and language if not already done.""" | |
| 19 | global _tmdb_initialized | |
| 20 | if _tmdb_initialized: | |
| 21 | return | |
| 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 |
| … | … | @@ -0,0 +1,161 @@ |
| 1 | import collections | |
| 2 | import io | |
| 3 | import itertools | |
| 4 | ||
| 5 | ONE_CHAR = 1 | |
| 6 | INTEGER_START = b'i' | |
| 7 | LIST_START = b'l' | |
| 8 | DICT_START = b'd' | |
| 9 | END = b'e' | |
| 10 | NEGATIVE_SIGN = b'-' | |
| 11 | STRING_LENGTH_SEPARATOR = b':' | |
| 12 | ||
| 13 | __all__ = ['decode', 'DecodingError', 'encode'] | |
| 14 | ||
| 15 | ||
| 16 | class DecodingError(ValueError): | |
| 17 | pass | |
| 18 | ||
| 19 | ||
| 20 | def byte_is_integer(b): | |
| 21 | return b'0' <= b <= b'9' | |
| 22 | ||
| 23 | ||
| 24 | def 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 | ||
| 32 | def 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 | ||
| 38 | def _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 | ||
| 66 | def _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 | ||
| 76 | def _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 | ||
| 84 | list_starter = object() | |
| 85 | dict_starter = object() | |
| 86 | ||
| 87 | ||
| 88 | def 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 | ||
| 132 | def 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)) |
| … | … | @@ -0,0 +1,25 @@ |
| 1 | from lib.api.jacktorr.jacktorr import TorrServer | |
| 2 | from lib.utils.general.utils import ( | |
| 3 | get_service_host, | |
| 4 | get_port, | |
| 5 | get_username, | |
| 6 | get_password, | |
| 7 | ssl_enabled, | |
| 8 | ) | |
| 9 | from lib.utils.kodi.utils import JACKTORR_ADDON | |
| 10 | ||
| 11 | _torrserver_api = None | |
| 12 | ||
| 13 | ||
| 14 | def 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 |
| … | … | @@ -0,0 +1,207 @@ |
| 1 | import hashlib | |
| 2 | from io import BytesIO | |
| 3 | import requests | |
| 4 | from lib.utils.kodi.kodi_formats import is_picture, is_text, is_video, is_music | |
| 5 | from 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 | ) | |
| 16 | from lib.utils.general.utils import ( | |
| 17 | USER_AGENT_HEADER, | |
| 18 | build_list_item, | |
| 19 | ) | |
| 20 | from lib.vendor.bencodepy import bencodepy | |
| 21 | ||
| 22 | from xbmcplugin import addDirectoryItem, endOfDirectory | |
| 23 | from xbmcgui import Dialog | |
| 24 | ||
| 25 | from lib.utils.torrent.torrserver_init import get_torrserver_api | |
| 26 | ||
| 27 | ||
| 28 | def 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 | ||
| 37 | def 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 | ||
| 93 | def 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 | ||
| 114 | def 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 | ||
| 124 | def 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 | ||
| 135 | def 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 | ||
| 180 | def 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 | ||
| 193 | def 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 | ||
| 206 | def convert_info_hash_to_magnet(info_hash: str) -> str: | |
| 207 | return f"magnet:?xt=urn:btih:{info_hash}" |
| … | … | @@ -0,0 +1,77 @@ |
| 1 | from datetime import timedelta | |
| 2 | from lib.db.cached import cache | |
| 3 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 4 | from lib.utils.kodi.utils import translation | |
| 5 | import xbmcgui | |
| 6 | ||
| 7 | ||
| 8 | items = [ | |
| 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 | ||
| 32 | def 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 | ||
| 48 | def 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 | ||
| 66 | def 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 |
| … | … | @@ -0,0 +1,117 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | from lib.jacktook.utils import kodilog | |
| 3 | import json | |
| 4 | import os | |
| 5 | from xbmc import executebuiltin | |
| 6 | from xbmcplugin import endOfDirectory, setContent | |
| 7 | from 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 | ) | |
| 16 | from lib.db.pickle_db import PickleDatabase | |
| 17 | from lib.utils.general.utils import set_pluging_category | |
| 18 | from lib.utils.views.last_files import add_last_files_context_menu, parse_time | |
| 19 | from lib.utils.kodi.utils import end_of_directory | |
| 20 | from lib.utils.general.utils import format_season_episode, truncate_text | |
| 21 | ||
| 22 | ||
| 23 | def 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 | ||
| 32 | def 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 | ||
| 39 | def 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") |
| … | … | @@ -0,0 +1,66 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | from lib.db.pickle_db import PickleDatabase | |
| 4 | from lib.utils.general.utils import ( | |
| 5 | format_season_episode, | |
| 6 | parse_time, | |
| 7 | set_pluging_category, | |
| 8 | ) | |
| 9 | from lib.utils.kodi.last_files_actions import add_last_files_context_menu | |
| 10 | from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH, add_directory_items_batch, apply_section_view, build_url, end_of_directory, make_list_item, translation | |
| 11 | ||
| 12 | ||
| 13 | ||
| 14 | ||
| 15 | pickle_db = PickleDatabase() | |
| 16 | ||
| 17 | ||
| 18 | def 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") |
| … | … | @@ -0,0 +1,117 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | ||
| 4 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 5 | from lib.db.pickle_db import PickleDatabase | |
| 6 | from lib.jacktook.utils import kodilog | |
| 7 | from lib.utils.general.utils import parse_time, set_media_infoTag, set_pluging_category | |
| 8 | from 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 | ||
| 23 | pickle_db = PickleDatabase() | |
| 24 | ||
| 25 | ||
| 26 | def delete_last_title_entry(params): | |
| 27 | pickle_db.delete_item(key="jt:lth", subkey=params.get("title")) | |
| 28 | container_refresh() | |
| 29 | ||
| 30 | ||
| 31 | def 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") |
| … | … | @@ -0,0 +1,219 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | import os | |
| 3 | ||
| 4 | from xbmcgui import ListItem | |
| 5 | from xbmcplugin import addDirectoryItem, setContent | |
| 6 | ||
| 7 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 8 | from lib.db.cached import cache | |
| 9 | from lib.db.pickle_db import PickleDatabase | |
| 10 | from lib.jacktook.utils import kodilog | |
| 11 | from lib.utils.general.utils import remove_from_library, set_media_infoTag, set_pluging_category | |
| 12 | from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH, add_directory_items_batch, apply_section_view, build_url, end_of_directory, make_list_item, notification, translation | |
| 13 | from lib.utils.views.last_titles import parse_time | |
| 14 | ||
| 15 | ||
| 16 | def _library_cache_key(mode): | |
| 17 | return "library_view|{}".format(mode) | |
| 18 | ||
| 19 | ||
| 20 | def _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 | ||
| 28 | def _normalize_library_mode(mode): | |
| 29 | return "tv" if mode == "tv" else "movies" | |
| 30 | ||
| 31 | ||
| 32 | def _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 | ||
| 56 | def _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 | ||
| 65 | def _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 | ||
| 74 | def _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 | ||
| 93 | def _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 | ||
| 98 | def _library_mode_matches(item_mode, mode): | |
| 99 | if mode == "tv": | |
| 100 | return item_mode == "tv" | |
| 101 | return item_mode in ["movie", "movies"] | |
| 102 | ||
| 103 | ||
| 104 | def _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 | ||
| 134 | def _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 | ||
| 144 | def 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 | ||
| 188 | def 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 | ||
| 197 | def 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 |
| … | … | @@ -0,0 +1,178 @@ |
| 1 | import json | |
| 2 | from lib.api.trakt.trakt_utils import add_trakt_watched_context_menu, is_trakt_auth | |
| 3 | from lib.clients.tmdb.utils.utils import ( | |
| 4 | add_tmdb_episode_context_menu, | |
| 5 | add_tmdb_show_context_menu, | |
| 6 | tmdb_get, | |
| 7 | ) | |
| 8 | from 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 | ) | |
| 17 | from lib.utils.general.utils import ( | |
| 18 | execute_thread_pool_collection, | |
| 19 | get_fanart_details, | |
| 20 | set_content_type, | |
| 21 | set_media_infoTag, | |
| 22 | ) | |
| 23 | ||
| 24 | ||
| 25 | ||
| 26 | ||
| 27 | def 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 | ||
| 39 | def 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 | ||
| 75 | def _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 | ||
| 111 | def 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 | ||
| 125 | def show_episode_info(tv_name, season, ids, mode, media_type): | |
| 126 | season_details = tmdb_get( | |
| 127 | "season_details", {"id": ids.get("tmdb_id"), "season": season} | |
| 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 | ||
| 150 | def _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) |
| … | … | @@ -0,0 +1,252 @@ |
| 1 | from datetime import datetime, date, timedelta | |
| 2 | from copy import copy | |
| 3 | import os | |
| 4 | import re | |
| 5 | ||
| 6 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 7 | from lib.db.pickle_db import PickleDatabase | |
| 8 | from lib.jacktook.utils import kodilog | |
| 9 | from 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 | ) | |
| 20 | from lib.utils.general.utils import ( | |
| 21 | execute_thread_pool, | |
| 22 | set_media_infoTag, | |
| 23 | set_pluging_category, | |
| 24 | translate_weekday, | |
| 25 | ) | |
| 26 | from lib.utils.kodi.utils import get_setting | |
| 27 | ||
| 28 | ||
| 29 | ||
| 30 | ||
| 31 | def 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 | ||
| 199 | def 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 | ||
| 232 | def 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 | ||
| 244 | def 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}") |
| … | … | @@ -0,0 +1,9 @@ |
| 1 | """bencode.py - `BTL` backwards compatibility support.""" | |
| 2 | ||
| 3 | # Compatibility with previous versions: | |
| 4 | from bencode.exceptions import BencodeDecodeError as BTFailure # noqa: F401 | |
| 5 | ||
| 6 | ||
| 7 | __all__ = ( | |
| 8 | 'BTFailure' | |
| 9 | ) |
| … | … | @@ -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 | ||
| 15 | from bencode.BTL import BTFailure | |
| 16 | from bencode.exceptions import BencodeDecodeError | |
| 17 | ||
| 18 | from 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 | ||
| 33 | DEFAULT = Bencode( | |
| 34 | encoding='utf-8', | |
| 35 | encoding_fallback='value', | |
| 36 | dict_ordered=True, | |
| 37 | dict_ordered_sort=True | |
| 38 | ) | |
| 39 | ||
| 40 | bdecode = DEFAULT.decode | |
| 41 | bencode = DEFAULT.encode | |
| 42 | bread = DEFAULT.read | |
| 43 | bwrite = DEFAULT.write | |
| 44 | ||
| 45 | decode = bdecode | |
| 46 | encode = bencode |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | """bencode.py - encoder + decode exceptions.""" | |
| 2 | from bencodepy.exceptions import BencodeDecodeError | |
| 3 | ||
| 4 | __all__ = ( | |
| 5 | 'BencodeDecodeError', | |
| 6 | ) |
| … | … | @@ -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 | ||
| 15 | from .common import Bencached | |
| 16 | from .decoder import BencodeDecoder | |
| 17 | from .encoder import BencodeEncoder | |
| 18 | from .exceptions import BencodeDecodeError | |
| 19 | ||
| 20 | try: | |
| 21 | from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any | |
| 22 | except ImportError: | |
| 23 | Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None | |
| 24 | ||
| 25 | try: | |
| 26 | from collections import OrderedDict | |
| 27 | except ImportError: | |
| 28 | OrderedDict = None | |
| 29 | ||
| 30 | try: | |
| 31 | import pathlib | |
| 32 | except 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 | ||
| 50 | class 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 | ||
| 127 | DEFAULT = Bencode() | |
| 128 | ||
| 129 | ||
| 130 | def 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 | ||
| 144 | def 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 | ||
| 158 | def 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 | ||
| 170 | def bwrite(data, # type: Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes] | |
| 171 | fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO] | |
| 172 | ): | |
| 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 | |
| 184 | encode = bencode | |
| 185 | decode = bdecode |
| … | … | @@ -0,0 +1,8 @@ |
| 1 | """bencode.py - common.""" | |
| 2 | ||
| 3 | ||
| 4 | class Bencached(object): | |
| 5 | __slots__ = ['bencoded'] | |
| 6 | ||
| 7 | def __init__(self, s): | |
| 8 | self.bencoded = s |
| … | … | @@ -0,0 +1,30 @@ |
| 1 | """bencode.py - compatibility helpers.""" | |
| 2 | ||
| 3 | import sys | |
| 4 | ||
| 5 | PY2 = sys.version_info[0] == 2 | |
| 6 | PY3 = sys.version_info[0] == 3 | |
| 7 | ||
| 8 | ||
| 9 | def is_binary(s): | |
| 10 | if PY3: | |
| 11 | return isinstance(s, bytes) | |
| 12 | ||
| 13 | return isinstance(s, str) | |
| 14 | ||
| 15 | ||
| 16 | def is_text(s): | |
| 17 | if PY3: | |
| 18 | return isinstance(s, str) | |
| 19 | ||
| 20 | return isinstance(s, unicode) # noqa: F821 | |
| 21 | ||
| 22 | ||
| 23 | def 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)) |
| … | … | @@ -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 | ||
| 15 | from .compat import to_binary | |
| 16 | from .exceptions import BencodeDecodeError | |
| 17 | from collections import OrderedDict | |
| 18 | ||
| 19 | try: | |
| 20 | from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any | |
| 21 | except ImportError: | |
| 22 | Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None | |
| 23 | ||
| 24 | try: | |
| 25 | import pathlib | |
| 26 | except ImportError: | |
| 27 | pathlib = None | |
| 28 | ||
| 29 | ENCODING_FALLBACK_TYPES = ('key', 'value') | |
| 30 | ||
| 31 | ||
| 32 | class 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 |
| … | … | @@ -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 | ||
| 15 | from .common import Bencached | |
| 16 | from .compat import PY2, to_binary | |
| 17 | from collections import deque | |
| 18 | ||
| 19 | try: | |
| 20 | from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any | |
| 21 | except ImportError: | |
| 22 | Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None | |
| 23 | ||
| 24 | try: | |
| 25 | from collections import OrderedDict | |
| 26 | except ImportError: | |
| 27 | OrderedDict = None | |
| 28 | ||
| 29 | try: | |
| 30 | import pathlib | |
| 31 | except ImportError: | |
| 32 | pathlib = None | |
| 33 | ||
| 34 | ||
| 35 | class 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') |
| … | … | @@ -0,0 +1,7 @@ |
| 1 | """bencode.py - exceptions.""" | |
| 2 | ||
| 3 | ||
| 4 | class BencodeDecodeError(Exception): | |
| 5 | """Bencode decode error.""" | |
| 6 | ||
| 7 | pass |
| … | … | @@ -0,0 +1,181 @@ |
| 1 | # | |
| 2 | # Copyright (c) 2016 - 2024 -- Lars Heuer | |
| 3 | # All rights reserved. | |
| 4 | # | |
| 5 | # License: BSD License | |
| 6 | # | |
| 7 | """\ | |
| 8 | QR Code and Micro QR Code implementation. | |
| 9 | ||
| 10 | "QR Code" and "Micro QR Code" are registered trademarks of DENSO WAVE INCORPORATED. | |
| 11 | """ | |
| 12 | import sys | |
| 13 | import io | |
| 14 | from . import encoder | |
| 15 | from .encoder import DataOverflowError | |
| 16 | from . import writers, utils | |
| 17 | ||
| 18 | __version__ = '1.6.6' | |
| 19 | ||
| 20 | __all__ = ('make', 'make_qr', 'make_micro', 'make_sequence', 'QRCode', | |
| 21 | 'QRCodeSequence', 'DataOverflowError') | |
| 22 | ||
| 23 | ||
| 24 | def make(content, error=None, version=None, mode=None, mask=None, encoding=None, | |
| 25 | eci=False, micro=None, boost_error=True): | |
| 26 | return QRCode(encoder.encode(content, error, version, mode, mask, encoding, | |
| 27 | eci, micro, boost_error=boost_error)) | |
| 28 | ||
| 29 | ||
| 30 | ||
| 31 | class 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 | ||
| 157 | class 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}'") |
| … | … | @@ -0,0 +1,784 @@ |
| 1 | # | |
| 2 | # Copyright (c) 2016 - 2024 -- Lars Heuer | |
| 3 | # All rights reserved. | |
| 4 | # | |
| 5 | # License: BSD License | |
| 6 | # | |
| 7 | """\ | |
| 8 | Constants. | |
| 9 | ||
| 10 | Internal module. May change without further warning. | |
| 11 | """ | |
| 12 | from collections import namedtuple | |
| 13 | ||
| 14 | ALPHANUMERIC_CHARS = br'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' | |
| 15 | ||
| 16 | # ISO/IEC 18004:2015(E) -- Table 2 — Mode indicators for QR Code (page 23) | |
| 17 | MODE_NUMERIC = 0x1 | |
| 18 | MODE_ALPHANUMERIC = 0x2 | |
| 19 | MODE_STRUCTURED_APPEND = 0x3 | |
| 20 | MODE_BYTE = 0x4 | |
| 21 | MODE_ECI = 0x7 | |
| 22 | MODE_KANJI = 0x8 | |
| 23 | # Hanzi is not part of ISO/IEC 18004 and must be enabled by the user explicitly | |
| 24 | MODE_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) | |
| 28 | MODE_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) | |
| 37 | MODE_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 | |
| 45 | VERSION_M4 = 0 | |
| 46 | VERSION_M3 = -1 | |
| 47 | VERSION_M2 = -2 | |
| 48 | VERSION_M1 = -3 | |
| 49 | ||
| 50 | MICRO_VERSION_MAPPING = { | |
| 51 | 'M1': VERSION_M1, | |
| 52 | 'M2': VERSION_M2, | |
| 53 | 'M3': VERSION_M3, | |
| 54 | 'M4': VERSION_M4, | |
| 55 | } | |
| 56 | ||
| 57 | MICRO_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) | |
| 61 | ERROR_LEVEL_L = 1 | |
| 62 | ERROR_LEVEL_M = 0 | |
| 63 | ERROR_LEVEL_Q = 3 | |
| 64 | ERROR_LEVEL_H = 2 | |
| 65 | ||
| 66 | ERROR_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 | ||
| 77 | DEFAULT_BYTE_ENCODING = 'iso-8859-1' | |
| 78 | KANJI_ENCODING = 'shift_jis' | |
| 79 | HANZI_ENCODING = 'gb2312' | |
| 80 | ||
| 81 | MODE_MAPPING = { | |
| 82 | 'numeric': MODE_NUMERIC, | |
| 83 | 'alphanumeric': MODE_ALPHANUMERIC, | |
| 84 | 'byte': MODE_BYTE, | |
| 85 | 'kanji': MODE_KANJI, | |
| 86 | 'hanzi': MODE_HANZI, | |
| 87 | } | |
| 88 | ||
| 89 | ERROR_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 | |
| 139 | ECI_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) | |
| 172 | SUPPORTED_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) | |
| 182 | TERMINATOR_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 | ||
| 190 | VERSION_RANGE_01_09 = 1 # Version 1 .. 9 | |
| 191 | VERSION_RANGE_10_26 = 2 # Version 10 .. 26 | |
| 192 | VERSION_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) | |
| 196 | CHAR_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 | |
| 222 | SYMBOL_CAPACITY = { | |
| 223 | VERSION_M1: { | |
| 224 | None: 20}, | |
| 225 | VERSION_M2: { | |
| 226 | ERROR_LEVEL_L: 40, ERROR_LEVEL_M: 32}, | |
| 227 | VERSION_M3: { | |
| 228 | ERROR_LEVEL_L: 84, ERROR_LEVEL_M: 68}, | |
| 229 | VERSION_M4: { | |
| 230 | ERROR_LEVEL_L: 128, ERROR_LEVEL_M: 112, ERROR_LEVEL_Q: 80}, | |
| 231 | 1: {ERROR_LEVEL_L: 152, ERROR_LEVEL_M: 128, ERROR_LEVEL_Q: 104, ERROR_LEVEL_H: 72}, | |
| 232 | 2: {ERROR_LEVEL_L: 272, ERROR_LEVEL_M: 224, ERROR_LEVEL_Q: 176, ERROR_LEVEL_H: 128}, | |
| 233 | 3: {ERROR_LEVEL_L: 440, ERROR_LEVEL_M: 352, ERROR_LEVEL_Q: 272, ERROR_LEVEL_H: 208}, | |
| 234 | 4: {ERROR_LEVEL_L: 640, ERROR_LEVEL_M: 512, ERROR_LEVEL_Q: 384, ERROR_LEVEL_H: 288}, | |
| 235 | 5: {ERROR_LEVEL_L: 864, ERROR_LEVEL_M: 688, ERROR_LEVEL_Q: 496, ERROR_LEVEL_H: 368}, | |
| 236 | 6: {ERROR_LEVEL_L: 1088, ERROR_LEVEL_M: 864, ERROR_LEVEL_Q: 608, ERROR_LEVEL_H: 480}, | |
| 237 | 7: {ERROR_LEVEL_L: 1248, ERROR_LEVEL_M: 992, ERROR_LEVEL_Q: 704, ERROR_LEVEL_H: 528}, | |
| 238 | 8: {ERROR_LEVEL_L: 1552, ERROR_LEVEL_M: 1232, ERROR_LEVEL_Q: 880, ERROR_LEVEL_H: 688}, | |
| 239 | 9: {ERROR_LEVEL_L: 1856, ERROR_LEVEL_M: 1456, ERROR_LEVEL_Q: 1056, ERROR_LEVEL_H: 800}, | |
| 240 | 10: {ERROR_LEVEL_L: 2192, ERROR_LEVEL_M: 1728, ERROR_LEVEL_Q: 1232, ERROR_LEVEL_H: 976}, | |
| 241 | 11: {ERROR_LEVEL_L: 2592, ERROR_LEVEL_M: 2032, ERROR_LEVEL_Q: 1440, ERROR_LEVEL_H: 1120}, | |
| 242 | 12: {ERROR_LEVEL_L: 2960, ERROR_LEVEL_M: 2320, ERROR_LEVEL_Q: 1648, ERROR_LEVEL_H: 1264}, | |
| 243 | 13: {ERROR_LEVEL_L: 3424, ERROR_LEVEL_M: 2672, ERROR_LEVEL_Q: 1952, ERROR_LEVEL_H: 1440}, | |
| 244 | 14: {ERROR_LEVEL_L: 3688, ERROR_LEVEL_M: 2920, ERROR_LEVEL_Q: 2088, ERROR_LEVEL_H: 1576}, | |
| 245 | 15: {ERROR_LEVEL_L: 4184, ERROR_LEVEL_M: 3320, ERROR_LEVEL_Q: 2360, ERROR_LEVEL_H: 1784}, | |
| 246 | 16: {ERROR_LEVEL_L: 4712, ERROR_LEVEL_M: 3624, ERROR_LEVEL_Q: 2600, ERROR_LEVEL_H: 2024}, | |
| 247 | 17: {ERROR_LEVEL_L: 5176, ERROR_LEVEL_M: 4056, ERROR_LEVEL_Q: 2936, ERROR_LEVEL_H: 2264}, | |
| 248 | 18: {ERROR_LEVEL_L: 5768, ERROR_LEVEL_M: 4504, ERROR_LEVEL_Q: 3176, ERROR_LEVEL_H: 2504}, | |
| 249 | 19: {ERROR_LEVEL_L: 6360, ERROR_LEVEL_M: 5016, ERROR_LEVEL_Q: 3560, ERROR_LEVEL_H: 2728}, | |
| 250 | 20: {ERROR_LEVEL_L: 6888, ERROR_LEVEL_M: 5352, ERROR_LEVEL_Q: 3880, ERROR_LEVEL_H: 3080}, | |
| 251 | 21: {ERROR_LEVEL_L: 7456, ERROR_LEVEL_M: 5712, ERROR_LEVEL_Q: 4096, ERROR_LEVEL_H: 3248}, | |
| 252 | 22: {ERROR_LEVEL_L: 8048, ERROR_LEVEL_M: 6256, ERROR_LEVEL_Q: 4544, ERROR_LEVEL_H: 3536}, | |
| 253 | 23: {ERROR_LEVEL_L: 8752, ERROR_LEVEL_M: 6880, ERROR_LEVEL_Q: 4912, ERROR_LEVEL_H: 3712}, | |
| 254 | 24: {ERROR_LEVEL_L: 9392, ERROR_LEVEL_M: 7312, ERROR_LEVEL_Q: 5312, ERROR_LEVEL_H: 4112}, | |
| 255 | 25: {ERROR_LEVEL_L: 10208, ERROR_LEVEL_M: 8000, ERROR_LEVEL_Q: 5744, ERROR_LEVEL_H: 4304}, | |
| 256 | 26: {ERROR_LEVEL_L: 10960, ERROR_LEVEL_M: 8496, ERROR_LEVEL_Q: 6032, ERROR_LEVEL_H: 4768}, | |
| 257 | 27: {ERROR_LEVEL_L: 11744, ERROR_LEVEL_M: 9024, ERROR_LEVEL_Q: 6464, ERROR_LEVEL_H: 5024}, | |
| 258 | 28: {ERROR_LEVEL_L: 12248, ERROR_LEVEL_M: 9544, ERROR_LEVEL_Q: 6968, ERROR_LEVEL_H: 5288}, | |
| 259 | 29: {ERROR_LEVEL_L: 13048, ERROR_LEVEL_M: 10136, ERROR_LEVEL_Q: 7288, ERROR_LEVEL_H: 5608}, | |
| 260 | 30: {ERROR_LEVEL_L: 13880, ERROR_LEVEL_M: 10984, ERROR_LEVEL_Q: 7880, ERROR_LEVEL_H: 5960}, | |
| 261 | 31: {ERROR_LEVEL_L: 14744, ERROR_LEVEL_M: 11640, ERROR_LEVEL_Q: 8264, ERROR_LEVEL_H: 6344}, | |
| 262 | 32: {ERROR_LEVEL_L: 15640, ERROR_LEVEL_M: 12328, ERROR_LEVEL_Q: 8920, ERROR_LEVEL_H: 6760}, | |
| 263 | 33: {ERROR_LEVEL_L: 16568, ERROR_LEVEL_M: 13048, ERROR_LEVEL_Q: 9368, ERROR_LEVEL_H: 7208}, | |
| 264 | 34: {ERROR_LEVEL_L: 17528, ERROR_LEVEL_M: 13800, ERROR_LEVEL_Q: 9848, ERROR_LEVEL_H: 7688}, | |
| 265 | 35: {ERROR_LEVEL_L: 18448, ERROR_LEVEL_M: 14496, ERROR_LEVEL_Q: 10288, ERROR_LEVEL_H: 7888}, | |
| 266 | 36: {ERROR_LEVEL_L: 19472, ERROR_LEVEL_M: 15312, ERROR_LEVEL_Q: 10832, ERROR_LEVEL_H: 8432}, | |
| 267 | 37: {ERROR_LEVEL_L: 20528, ERROR_LEVEL_M: 15936, ERROR_LEVEL_Q: 11408, ERROR_LEVEL_H: 8768}, | |
| 268 | 38: {ERROR_LEVEL_L: 21616, ERROR_LEVEL_M: 16816, ERROR_LEVEL_Q: 12016, ERROR_LEVEL_H: 9136}, | |
| 269 | 39: {ERROR_LEVEL_L: 22496, ERROR_LEVEL_M: 17728, ERROR_LEVEL_Q: 12656, ERROR_LEVEL_H: 9776}, | |
| 270 | 40: {ERROR_LEVEL_L: 23648, ERROR_LEVEL_M: 18672, ERROR_LEVEL_Q: 13328, ERROR_LEVEL_H: 10208} | |
| 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 | |
| 276 | RSYMBOL_CAPACITY = { | |
| 277 | 'R7x43': {ERROR_LEVEL_M: 48, ERROR_LEVEL_H: 24}, | |
| 278 | 'R7x59': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56}, | |
| 279 | 'R7x77': {ERROR_LEVEL_M: 160, ERROR_LEVEL_H: 80}, | |
| 280 | 'R7x99': {ERROR_LEVEL_M: 224, ERROR_LEVEL_H: 112}, | |
| 281 | 'R7x139': {ERROR_LEVEL_M: 352, ERROR_LEVEL_H: 192}, | |
| 282 | 'R9x43': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56}, | |
| 283 | 'R9x59': {ERROR_LEVEL_M: 168, ERROR_LEVEL_H: 88}, | |
| 284 | 'R9x77': {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 136}, | |
| 285 | 'R9x99': {ERROR_LEVEL_M: 336, ERROR_LEVEL_H: 176}, | |
| 286 | 'R9x139': {ERROR_LEVEL_M: 504, ERROR_LEVEL_H: 264}, | |
| 287 | 'R11x27': {ERROR_LEVEL_M: 56, ERROR_LEVEL_H: 40}, | |
| 288 | 'R11x43': {ERROR_LEVEL_M: 152, ERROR_LEVEL_H: 88}, | |
| 289 | 'R11x59': {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 120}, | |
| 290 | 'R11x77': {ERROR_LEVEL_M: 344, ERROR_LEVEL_H: 184}, | |
| 291 | 'R11x99': {ERROR_LEVEL_M: 456, ERROR_LEVEL_H: 232}, | |
| 292 | 'R11x139': {ERROR_LEVEL_M: 672, ERROR_LEVEL_H: 336}, | |
| 293 | 'R13x27': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56}, | |
| 294 | 'R13x43': {ERROR_LEVEL_M: 216, ERROR_LEVEL_H: 104}, | |
| 295 | 'R13x59': {ERROR_LEVEL_M: 304, ERROR_LEVEL_H: 160}, | |
| 296 | 'R13x77': {ERROR_LEVEL_M: 424, ERROR_LEVEL_H: 232}, | |
| 297 | 'R13x99': {ERROR_LEVEL_M: 584, ERROR_LEVEL_H: 280}, | |
| 298 | 'R13x139': {ERROR_LEVEL_M: 848, ERROR_LEVEL_H: 432}, | |
| 299 | 'R15x43': {ERROR_LEVEL_M: 264, ERROR_LEVEL_H: 120}, | |
| 300 | 'R15x59': {ERROR_LEVEL_M: 384, ERROR_LEVEL_H: 208}, | |
| 301 | 'R15x77': {ERROR_LEVEL_M: 536, ERROR_LEVEL_H: 248}, | |
| 302 | 'R15x99': {ERROR_LEVEL_M: 704, ERROR_LEVEL_H: 384}, | |
| 303 | 'R15x139': {ERROR_LEVEL_M: 1016, ERROR_LEVEL_H: 552}, | |
| 304 | 'R17x43': {ERROR_LEVEL_M: 312, ERROR_LEVEL_H: 168}, | |
| 305 | 'R17x59': {ERROR_LEVEL_M: 448, ERROR_LEVEL_H: 224}, | |
| 306 | 'R17x77': {ERROR_LEVEL_M: 624, ERROR_LEVEL_H: 304}, | |
| 307 | 'R17x99': {ERROR_LEVEL_M: 800, ERROR_LEVEL_H: 448}, | |
| 308 | 'R17x139': {ERROR_LEVEL_M: 1216, ERROR_LEVEL_H: 608}, | |
| 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) | |
| 314 | EC = namedtuple('EC', 'num_blocks num_total num_data') | |
| 315 | ||
| 316 | ECC = { | |
| 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) | |
| 547 | FORMAT_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 | ||
| 558 | FORMAT_INFO_MICRO = ( | |
| 559 | 0x4445, 0x4172, 0x4e2b, 0x4b1c, 0x55ae, 0x5099, 0x5fc0, 0x5af7, | |
| 560 | 0x6793, 0x62a4, 0x6dfd, 0x68ca, 0x7678, 0x734f, 0x7c16, 0x7921, | |
| 561 | 0x06de, 0x03e9, 0x0cb0, 0x0987, 0x1735, 0x1202, 0x1d5b, 0x186c, | |
| 562 | 0x2508, 0x203f, 0x2f66, 0x2a51, 0x34e3, 0x31d4, 0x3e8d, 0x3bba, | |
| 563 | ) | |
| 564 | ||
| 565 | FORMAT_INFO_RECT_MICRO_LEFT = ( | |
| 566 | 0x1faB2, 0x1e597, 0x1dBdd, 0x1c4f8, 0x1B86c, 0x1a749, 0x19903, 0x18626, 0x17f0e, 0x1602B, | |
| 567 | 0x15e61, 0x14144, 0x13dd0, 0x122f5, 0x11cBf, 0x1039a, 0x0f1ca, 0x0eeef, 0x0d0a5, 0x0cf80, | |
| 568 | 0x0B314, 0x0ac31, 0x0927B, 0x08d5e, 0x07476, 0x06B53, 0x05519, 0x04a3c, 0x036a8, 0x0298d, | |
| 569 | 0x017c7, 0x008e2, 0x3f367, 0x3ec42, 0x3d208, 0x3cd2d, 0x3B1B9, 0x3ae9c, 0x390d6, 0x38ff3, | |
| 570 | 0x376dB, 0x369fe, 0x357B4, 0x34891, 0x33405, 0x32B20, 0x3156a, 0x30a4f, 0x2f81f, 0x2e73a, | |
| 571 | 0x2d970, 0x2c655, 0x2Bac1, 0x2a5e4, 0x29Bae, 0x2848B, 0x27da3, 0x26286, 0x25ccc, 0x243e9, | |
| 572 | 0x23f7d, 0x22058, 0x21e12, 0x20137 | |
| 573 | ) | |
| 574 | ||
| 575 | FORMAT_INFO_RECT_MICRO_RIGHT = ( | |
| 576 | 0x20a7b, 0x2155e, 0x22b14, 0x23431, 0x248a5, 0x25780, 0x269ca, 0x276ef, 0x28fc7, 0x290e2, | |
| 577 | 0x2aea8, 0x2b18d, 0x2cd19, 0x2d23c, 0x2ec76, 0x2f353, 0x30103, 0x31e26, 0x3206c, 0x33f49, | |
| 578 | 0x343dd, 0x35cf8, 0x362b2, 0x37d97, 0x384bf, 0x39b9a, 0x3a5d0, 0x3baf5, 0x3c661, 0x3d944, | |
| 579 | 0x3e70e, 0x3f82b, 0x003ae, 0x01c8b, 0x022c1, 0x03de4, 0x04170, 0x05e55, 0x0601f, 0x07f3a, | |
| 580 | 0x08612, 0x09937, 0x0a77d, 0x0b858, 0x0c4cc, 0x0dbe9, 0x0e5a3, 0x0fa86, 0x108d6, 0x117f3, | |
| 581 | 0x129b9, 0x1369c, 0x14a08, 0x1552d, 0x16b67, 0x17442, 0x18d6a, 0x1924f, 0x1ac05, 0x1b320, | |
| 582 | 0x1cfb4, 0x1d091, 0x1eedb, 0x1f1fe | |
| 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) | |
| 588 | VERSION_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) | |
| 600 | ALIGNMENT_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) | |
| 644 | RECT_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) | |
| 655 | GEN_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 | |
| 681 | GALIOS_LOG = ( | |
| 682 | 0, 0, 1, 25, 2, 50, 26, 198, 3, 223, 51, 238, 27, 104, 199, 75, 4, 100, | |
| 683 | 224, 14, 52, 141, 239, 129, 28, 193, 105, 248, 200, 8, 76, 113, 5, 138, | |
| 684 | 101, 47, 225, 36, 15, 33, 53, 147, 142, 218, 240, 18, 130, 69, 29, 181, | |
| 685 | 194, 125, 106, 39, 249, 185, 201, 154, 9, 120, 77, 228, 114, 166, 6, 191, | |
| 686 | 139, 98, 102, 221, 48, 253, 226, 152, 37, 179, 16, 145, 34, 136, 54, 208, | |
| 687 | 148, 206, 143, 150, 219, 189, 241, 210, 19, 92, 131, 56, 70, 64, 30, 66, | |
| 688 | 182, 163, 195, 72, 126, 110, 107, 58, 40, 84, 250, 133, 186, 61, 202, 94, | |
| 689 | 155, 159, 10, 21, 121, 43, 78, 212, 229, 172, 115, 243, 167, 87, 7, 112, | |
| 690 | 192, 247, 140, 128, 99, 13, 103, 74, 222, 237, 49, 197, 254, 24, 227, 165, | |
| 691 | 153, 119, 38, 184, 180, 124, 17, 68, 146, 217, 35, 32, 137, 46, 55, 63, | |
| 692 | 209, 91, 149, 188, 207, 205, 144, 135, 151, 178, 220, 252, 190, 97, 242, | |
| 693 | 86, 211, 171, 20, 42, 93, 158, 132, 60, 57, 83, 71, 109, 65, 162, 31, 45, | |
| 694 | 67, 216, 183, 123, 164, 118, 196, 23, 73, 236, 127, 12, 111, 246, 108, | |
| 695 | 161, 59, 82, 41, 157, 85, 170, 251, 96, 134, 177, 187, 204, 62, 90, 203, | |
| 696 | 89, 95, 176, 156, 169, 160, 81, 11, 245, 22, 235, 122, 117, 44, 215, 79, | |
| 697 | 174, 213, 233, 230, 231, 173, 232, 116, 214, 244, 234, 168, 80, 88, 175 | |
| 698 | ) | |
| 699 | ||
| 700 | # GF(256) antilog | |
| 701 | # Inverse of the logarithm table. Maps integer logarithms to members | |
| 702 | # of the field. | |
| 703 | GALIOS_EXP = ([ | |
| 704 | 1, 2, 4, 8, 16, 32, 64, 128, 29, 58, 116, 232, 205, 135, 19, 38, 76, 152, | |
| 705 | 45, 90, 180, 117, 234, 201, 143, 3, 6, 12, 24, 48, 96, 192, 157, 39, 78, | |
| 706 | 156, 37, 74, 148, 53, 106, 212, 181, 119, 238, 193, 159, 35, 70, 140, 5, | |
| 707 | 10, 20, 40, 80, 160, 93, 186, 105, 210, 185, 111, 222, 161, 95, 190, 97, | |
| 708 | 194, 153, 47, 94, 188, 101, 202, 137, 15, 30, 60, 120, 240, 253, 231, 211, | |
| 709 | 187, 107, 214, 177, 127, 254, 225, 223, 163, 91, 182, 113, 226, 217, 175, | |
| 710 | 67, 134, 17, 34, 68, 136, 13, 26, 52, 104, 208, 189, 103, 206, 129, 31, | |
| 711 | 62, 124, 248, 237, 199, 147, 59, 118, 236, 197, 151, 51, 102, 204, 133, 23, | |
| 712 | 46, 92, 184, 109, 218, 169, 79, 158, 33, 66, 132, 21, 42, 84, 168, 77, 154, | |
| 713 | 41, 82, 164, 85, 170, 73, 146, 57, 114, 228, 213, 183, 115, 230, 209, 191, | |
| 714 | 99, 198, 145, 63, 126, 252, 229, 215, 179, 123, 246, 241, 255, 227, 219, | |
| 715 | 171, 75, 150, 49, 98, 196, 149, 55, 110, 220, 165, 87, 174, 65, 130, 25, | |
| 716 | 50, 100, 200, 141, 7, 14, 28, 56, 112, 224, 221, 167, 83, 166, 81, 162, 89, | |
| 717 | 178, 121, 242, 249, 239, 195, 155, 43, 86, 172, 69, 138, 9, 18, 36, 72, | |
| 718 | 144, 61, 122, 244, 245, 247, 243, 251, 235, 203, 139, 11, 22, 44, 88, 176, | |
| 719 | 125, 250, 233, 207, 131, 27, 54, 108, 216, 173, 71, 142] * 2 | |
| 720 | ) | |
| 721 | ||
| 722 | ||
| 723 | # Constants for module types | |
| 724 | ||
| 725 | TYPE_FINDER_PATTERN_LIGHT = 6 | |
| 726 | """\ | |
| 727 | Light finder module | |
| 728 | """ | |
| 729 | TYPE_FINDER_PATTERN_DARK = TYPE_FINDER_PATTERN_LIGHT << 8 | |
| 730 | """\ | |
| 731 | Dark finder module. | |
| 732 | """ | |
| 733 | TYPE_SEPARATOR = 8 | |
| 734 | """\ | |
| 735 | Separator around the finder patterns (light module) | |
| 736 | """ | |
| 737 | TYPE_ALIGNMENT_PATTERN_LIGHT = 10 | |
| 738 | """\ | |
| 739 | Light alignment pattern module. | |
| 740 | """ | |
| 741 | TYPE_ALIGNMENT_PATTERN_DARK = TYPE_ALIGNMENT_PATTERN_LIGHT << 8 | |
| 742 | """\ | |
| 743 | Dark alignment pattern module. | |
| 744 | """ | |
| 745 | TYPE_TIMING_LIGHT = 12 | |
| 746 | """\ | |
| 747 | Light timing pattern module. | |
| 748 | """ | |
| 749 | TYPE_TIMING_DARK = TYPE_TIMING_LIGHT << 8 | |
| 750 | """\ | |
| 751 | Dark timing patten module. | |
| 752 | """ | |
| 753 | TYPE_FORMAT_LIGHT = 14 | |
| 754 | """\ | |
| 755 | Light format information module. | |
| 756 | """ | |
| 757 | TYPE_FORMAT_DARK = TYPE_FORMAT_LIGHT << 8 | |
| 758 | """\ | |
| 759 | Dark format information module. | |
| 760 | """ | |
| 761 | TYPE_VERSION_LIGHT = 16 | |
| 762 | """\ | |
| 763 | Light version information module. | |
| 764 | """ | |
| 765 | TYPE_VERSION_DARK = TYPE_VERSION_LIGHT << 8 | |
| 766 | """\ | |
| 767 | Dark version information module. | |
| 768 | """ | |
| 769 | TYPE_DARKMODULE = 512 | |
| 770 | """\ | |
| 771 | A single dark module which occurs in QR Codes (but not in Micro QR Codes). | |
| 772 | """ | |
| 773 | TYPE_DATA_LIGHT = 4 | |
| 774 | """\ | |
| 775 | Light module in the encoding area (either a data module or an error correction module). | |
| 776 | """ | |
| 777 | TYPE_DATA_DARK = TYPE_DATA_LIGHT << 8 | |
| 778 | """\ | |
| 779 | Dark module in the encoding area (either a data module or an error correction module). | |
| 780 | """ | |
| 781 | TYPE_QUIET_ZONE = 18 | |
| 782 | """\ | |
| 783 | Border of light modules. | |
| 784 | """ |
| … | … | @@ -0,0 +1,1091 @@ |
| 1 | # | |
| 2 | # Copyright (c) 2016 - 2024 -- Lars Heuer | |
| 3 | # All rights reserved. | |
| 4 | # | |
| 5 | # License: BSD License | |
| 6 | # | |
| 7 | """\ | |
| 8 | QR Code and Micro QR Code encoder. | |
| 9 | ||
| 10 | DOES NOT belong to the public API. | |
| 11 | ||
| 12 | "QR Code" and "Micro QR Code" are registered trademarks of DENSO WAVE INCORPORATED. | |
| 13 | """ | |
| 14 | from operator import itemgetter, gt, lt, xor | |
| 15 | from functools import partial, reduce | |
| 16 | from itertools import islice, chain, product | |
| 17 | import re | |
| 18 | import math | |
| 19 | import codecs | |
| 20 | from collections import namedtuple | |
| 21 | from . import consts | |
| 22 | from itertools import zip_longest | |
| 23 | import sys | |
| 24 | _MAX_PENALTY_SCORE = sys.maxsize | |
| 25 | del sys | |
| 26 | ||
| 27 | __all__ = ('encode', 'encode_sequence', 'DataOverflowError') | |
| 28 | ||
| 29 | ||
| 30 | class 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 | ||
| 42 | Code = namedtuple('Code', 'matrix version error mask segments') | |
| 43 | ||
| 44 | ||
| 45 | def 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 | ||
| 76 | def 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 | ||
| 177 | def _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 | ||
| 225 | def 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 | ||
| 242 | def 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 | ||
| 263 | def write_terminator(buff, capacity, ver, length): | |
| 264 | buff.extend([0] * min(capacity - length, consts.TERMINATOR_LENGTH[ver])) | |
| 265 | ||
| 266 | ||
| 267 | def 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 | ||
| 272 | def 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 | ||
| 294 | def 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 | ||
| 307 | def 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 | ||
| 317 | def 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 | ||
| 341 | def 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 | ||
| 369 | def 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 | ||
| 396 | def 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 | ||
| 424 | def 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 | ||
| 465 | def 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 | ||
| 474 | def evaluate_mask(matrix, width, height): | |
| 475 | return sum(mask_scores(matrix, width, height)) | |
| 476 | ||
| 477 | ||
| 478 | def 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 | ||
| 551 | def 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 | ||
| 559 | def 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 | ||
| 575 | def 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 | ||
| 601 | def 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 | ||
| 620 | def 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 | ||
| 638 | def 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 | ||
| 662 | def 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 | ||
| 732 | def 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 | ||
| 764 | def 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 | ||
| 783 | def 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 | ||
| 793 | def 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 | ||
| 810 | def 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 | ||
| 823 | def 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 | ||
| 830 | def 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 | ||
| 837 | def 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 | ||
| 849 | def is_alphanumeric(data): | |
| 850 | return _ALPHANUMERIC_PATTERN.match(data) | |
| 851 | ||
| 852 | ||
| 853 | def 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 | ||
| 865 | def 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 | ||
| 875 | def 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 | ||
| 900 | def calc_matrix_size(ver): | |
| 901 | return ver * 4 + 17 if ver > 0 else (ver + 4) * 2 + 9 | |
| 902 | ||
| 903 | ||
| 904 | def 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 | ||
| 917 | def 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 | ||
| 925 | def 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 | ||
| 932 | def 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 | ||
| 942 | def 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 | ||
| 949 | def 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 | ||
| 981 | class 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 | ||
| 1041 | class _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 | ||
| 1053 | class 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 | ||
| 1082 | class _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)) |
| … | … | @@ -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 | """\ | |
| 9 | Utility functions useful for writers or QR Code objects. | |
| 10 | ||
| 11 | DOES NOT belong to the public API. | |
| 12 | """ | |
| 13 | from itertools import chain, repeat | |
| 14 | from . 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 | ||
| 21 | def 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 | ||
| 32 | def 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 | ||
| 46 | def 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 | ||
| 70 | def 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 | ||
| 81 | def 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 | ||
| 91 | def 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 | ||
| 122 | def 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 | ||
| 151 | def 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 |
| … | … | @@ -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 | |
| 8 | import io | |
| 9 | import re | |
| 10 | import zlib | |
| 11 | import codecs | |
| 12 | import base64 | |
| 13 | import gzip | |
| 14 | from xml.sax.saxutils import quoteattr, escape | |
| 15 | from struct import pack | |
| 16 | from itertools import chain, repeat | |
| 17 | import functools | |
| 18 | from functools import partial | |
| 19 | from functools import reduce | |
| 20 | from operator import itemgetter | |
| 21 | from contextlib import contextmanager | |
| 22 | from collections import defaultdict | |
| 23 | import time | |
| 24 | from . import consts | |
| 25 | from .utils import matrix_to_lines, get_symbol_size, get_border, check_valid_scale, check_valid_border, matrix_iter, matrix_iter_verbose | |
| 26 | from itertools import zip_longest | |
| 27 | from 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 | |
| 34 | CREATOR = 'Segno <https://pypi.org/project/segno/>' | |
| 35 | ||
| 36 | ||
| 37 | @contextmanager | |
| 38 | def 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 | ||
| 55 | def 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 | ||
| 76 | def _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) | |
| 85 | def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl=True, | |
| 86 | svgns=True, title=None, desc=None, svgid=None, svgclass='segno', | |
| 87 | lineclass='qrline', omitsize=False, unit=None, encoding='utf-8', | |
| 88 | svgversion=None, nl=True, draw_transparent=False): | |
| 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 | ||
| 203 | def as_svg_data_uri(matrix, matrix_size, scale=1, border=None, | |
| 204 | xmldecl=False, svgns=True, title=None, | |
| 205 | desc=None, svgid=None, svgclass='segno', | |
| 206 | lineclass='qrline', omitsize=False, unit='', | |
| 207 | encoding='utf-8', svgversion=None, nl=False, | |
| 208 | encode_minimal=False, omit_charset=False, **kw): | |
| 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 | ||
| 219 | def write_svg_debug(matrix, matrix_size, out, scale=15, border=None, | |
| 220 | fallback_color='fuchsia', colormap=None, | |
| 221 | add_legend=True): # pragma: no cover | |
| 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 | ||
| 256 | def 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 | ||
| 309 | def 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') | |
| 316 | def 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 | ||
| 422 | def 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 | ||
| 484 | def 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 | ||
| 494 | def 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 | ||
| 515 | def 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') | |
| 566 | def 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 | ||
| 581 | def 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 | ||
| 600 | def 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 | ||
| 618 | def 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 | ||
| 645 | def 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 | ||
| 669 | def 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 | ||
| 702 | def write_terminal_compact(matrix, matrix_size, out, border=None): | |
| 703 | blocks = {(1, 1): ' ', | |
| 704 | (0, 1): '\u2580', # Upper half block | |
| 705 | (1, 0): '\u2584', # Lower half block | |
| 706 | (0, 0): '\u2588', # Full block | |
| 707 | } | |
| 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 | ||
| 716 | def _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 | ||
| 723 | def _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 | ||
| 746 | def color_to_rgb_hex(color): | |
| 747 | return '#{0:02x}{1:02x}{2:02x}'.format(*_color_to_rgb(color)) # noqa UP030 | |
| 748 | ||
| 749 | ||
| 750 | def _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 | ||
| 759 | def _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 | ||
| 768 | def _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 | ||
| 776 | def _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 | ||
| 810 | def _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 | ||
| 828 | def _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 | ||
| 846 | def _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 | ||
| 1002 | def _make_colormap(matrix_width, matrix_height, dark, light, | |
| 1003 | finder_dark=False, finder_light=False, | |
| 1004 | data_dark=False, data_light=False, | |
| 1005 | version_dark=False, version_light=False, | |
| 1006 | format_dark=False, format_light=False, | |
| 1007 | alignment_dark=False, alignment_light=False, | |
| 1008 | timing_dark=False, timing_light=False, | |
| 1009 | separator=False, dark_module=False, | |
| 1010 | quiet_zone=False): | |
| 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 | ||
| 1059 | def 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) |
| … | … | @@ -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 | """ | |
| 18 | Create and parse torrent files and magnet URIs | |
| 19 | """ | |
| 20 | ||
| 21 | __version__ = '4.2.0' | |
| 22 | ||
| 23 | from ._errors import * | |
| 24 | from ._magnet import Magnet | |
| 25 | from ._stream import TorrentFileStream | |
| 26 | from ._torrent import Torrent | |
| 27 | from ._utils import File, Filepath |
| … | … | @@ -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 | ||
| 15 | import os | |
| 16 | ||
| 17 | ||
| 18 | class 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 | ||
| 26 | class 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 | ||
| 38 | class 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 | ||
| 67 | class MetainfoError(TorfError): | |
| 68 | """Invalid torrent metainfo""" | |
| 69 | def __init__(self, msg): | |
| 70 | super().__init__(f'Invalid metainfo: {msg}', msg) | |
| 71 | ||
| 72 | ||
| 73 | class 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 | ||
| 88 | class 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 | ||
| 109 | class 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 | ||
| 121 | class 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 | ||
| 134 | class 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 | ||
| 146 | class 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 | ||
| 158 | class 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 | ||
| 189 | class 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 | ||
| 257 | class 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 | ||
| 279 | class MemoryError(TorfError, MemoryError): | |
| 280 | """ | |
| 281 | Out of memory | |
| 282 | ||
| 283 | See also :class:`MemoryError`. | |
| 284 | """ | |
| 285 | ||
| 286 | ||
| 287 | class 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 | ||
| 309 | class 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 |
| … | … | @@ -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 | ||
| 15 | import errno | |
| 16 | import logging | |
| 17 | import os | |
| 18 | import queue | |
| 19 | import threading | |
| 20 | from hashlib import sha1 | |
| 21 | from time import monotonic as time_monotonic | |
| 22 | ||
| 23 | from . import _errors as errors | |
| 24 | from ._stream import TorrentFileStream | |
| 25 | ||
| 26 | QUEUE_CLOSED = object() | |
| 27 | ||
| 28 | _debug = logging.getLogger('torf').debug | |
| 29 | ||
| 30 | def _thread_name(): | |
| 31 | return threading.current_thread().name | |
| 32 | ||
| 33 | def _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 | ||
| 41 | class 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 | ||
| 93 | class 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 | ||
| 169 | class 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 | ||
| 301 | class 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 | ||
| 395 | class _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 | ||
| 415 | class _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 | ||
| 443 | class 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 | ||
| 461 | class 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}') |
| … | … | @@ -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 | ||
| 15 | import base64 | |
| 16 | import binascii | |
| 17 | import io | |
| 18 | import re | |
| 19 | import time | |
| 20 | import urllib | |
| 21 | from collections import abc, defaultdict | |
| 22 | ||
| 23 | from . import _errors as error | |
| 24 | from . import _utils as utils | |
| 25 | ||
| 26 | ||
| 27 | class 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})' |
| … | … | @@ -0,0 +1,202 @@ |
| 1 | import errno | |
| 2 | import os | |
| 3 | ||
| 4 | from . import _errors as error | |
| 5 | from . import _generate as generate | |
| 6 | from . import _stream as stream | |
| 7 | ||
| 8 | ||
| 9 | class 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 | ||
| 63 | def 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 | ||
| 92 | def _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 | ||
| 115 | def 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 | ||
| 150 | def 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 | ||
| 176 | class 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 |
| … | … | @@ -0,0 +1,741 @@ |
| 1 | import errno | |
| 2 | import functools | |
| 3 | import hashlib | |
| 4 | import itertools | |
| 5 | import math | |
| 6 | import os | |
| 7 | ||
| 8 | from . import _errors as error | |
| 9 | ||
| 10 | ||
| 11 | class 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 | ||
| 607 | class _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) |
| … | … | @@ -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 | ||
| 15 | import base64 | |
| 16 | import errno | |
| 17 | import hashlib | |
| 18 | import inspect | |
| 19 | import io | |
| 20 | import itertools | |
| 21 | import math | |
| 22 | import os | |
| 23 | import pathlib | |
| 24 | import re | |
| 25 | from collections import abc | |
| 26 | from datetime import datetime | |
| 27 | ||
| 28 | from lib.utils.torrent import flatbencode as bencode | |
| 29 | ||
| 30 | from . import __version__ | |
| 31 | from . import _errors as error | |
| 32 | from . import _generate as generate | |
| 33 | from . import _reuse as reuse | |
| 34 | from . import _utils as utils | |
| 35 | ||
| 36 | _PACKAGE_NAME = __name__.split('.')[0] | |
| 37 | ||
| 38 | # os.sched_getaffinity() is only available on some Unix platforms. | |
| 39 | try: | |
| 40 | NCORES = len(os.sched_getaffinity(0)) | |
| 41 | except AttributeError: | |
| 42 | import multiprocessing | |
| 43 | NCORES = multiprocessing.cpu_count() | |
| 44 | ||
| 45 | DEFAULT_TORRENT_NAME = 'UNNAMED TORRENT' | |
| 46 | ||
| 47 | class 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 |
| … | … | @@ -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 | ||
| 15 | import abc | |
| 16 | import collections | |
| 17 | import contextlib | |
| 18 | import errno | |
| 19 | import fnmatch | |
| 20 | import functools | |
| 21 | import http.client | |
| 22 | import itertools | |
| 23 | import math | |
| 24 | import os | |
| 25 | import pathlib | |
| 26 | import re | |
| 27 | import socket | |
| 28 | import typing | |
| 29 | import urllib.error | |
| 30 | import urllib.parse | |
| 31 | import urllib.request | |
| 32 | from datetime import datetime | |
| 33 | from urllib.parse import quote_plus as urlquote # noqa: F401 | |
| 34 | ||
| 35 | from . import _errors as error | |
| 36 | ||
| 37 | ||
| 38 | def 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 | ||
| 44 | def 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 | ||
| 54 | def 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}$') | |
| 62 | def is_md5sum(value): | |
| 63 | return bool(_md5sum_regex.match(value)) | |
| 64 | ||
| 65 | ||
| 66 | def 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 | ||
| 91 | def 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 | ||
| 119 | def 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 | ||
| 183 | class 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 | ||
| 277 | class 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 | ||
| 357 | class 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 | ||
| 373 | class 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 | ||
| 399 | class 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 | ||
| 444 | def 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 | ||
| 456 | class 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 | ||
| 483 | class 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 | ||
| 503 | class 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 | ||
| 613 | def 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 | ||
| 629 | def 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 | ||
| 650 | class 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 | ||
| 670 | def 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 | ||
| 680 | def 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 | ||
| 731 | def 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 | ||
| 743 | def decode_list(lst): | |
| 744 | lst_dec = [] | |
| 745 | for value in lst: | |
| 746 | lst_dec.append(decode_value(value)) | |
| 747 | return lst_dec | |
| 748 | ||
| 749 | def 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 | ||
| 758 | def 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 | ||
| 767 | def 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 | ||
| 773 | def 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 | ||
| 783 | ENCODE_ALLOWED_TYPES = (bytes, int) | |
| 784 | ENCODE_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 | } |
| … | … | @@ -0,0 +1,4 @@ |
| 1 | pytest | |
| 2 | pytest-mock | |
| 3 | pytest-cov | |
| 4 | requests |
| … | … | @@ -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 | # | |
| 6 | msgid "" | |
| 7 | msgstr "" | |
| 8 | "Report-Msgid-Bugs-To: \n" | |
| 9 | "POT-Creation-Date: 2023-09-19 19:53+0200\n" | |
| 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | |
| 11 | "Language: 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 | |
| 18 | msgctxt "#30000" | |
| 19 | msgid "Players" | |
| 20 | msgstr "" | |
| 21 | ||
| 22 | msgctxt "#30001" | |
| 23 | msgid "Clients" | |
| 24 | msgstr "" | |
| 25 | ||
| 26 | msgctxt "#30002" | |
| 27 | msgid "Torrent Client" | |
| 28 | msgstr "" | |
| 29 | ||
| 30 | msgctxt "#30020" | |
| 31 | msgid "Indexers" | |
| 32 | msgstr "" | |
| 33 | ||
| 34 | msgctxt "#30021" | |
| 35 | msgid "Indexer" | |
| 36 | msgstr "" | |
| 37 | ||
| 38 | msgctxt "#30022" | |
| 39 | msgid "Jackett Configuration:" | |
| 40 | msgstr "" | |
| 41 | ||
| 42 | msgctxt "#30023" | |
| 43 | msgid "Client Key" | |
| 44 | msgstr "" | |
| 45 | ||
| 46 | msgctxt "#30025" | |
| 47 | msgid "Host" | |
| 48 | msgstr "" | |
| 49 | ||
| 50 | msgctxt "#30026" | |
| 51 | msgid "Description length" | |
| 52 | msgstr "" | |
| 53 | ||
| 54 | msgctxt "#30027" | |
| 55 | msgid "Insecured connection" | |
| 56 | msgstr "" | |
| 57 | ||
| 58 | msgctxt "#30028" | |
| 59 | msgid "Sort by" | |
| 60 | msgstr "" | |
| 61 | ||
| 62 | msgctxt "#30029" | |
| 63 | msgid "Total Results" | |
| 64 | msgstr "" | |
| 65 | ||
| 66 | msgctxt "#30030" | |
| 67 | msgid "Timeout" | |
| 68 | msgstr "" | |
| 69 | ||
| 70 | msgctxt "#30031" | |
| 71 | msgid "Timeout" | |
| 72 | msgstr "" | |
| 73 | ||
| 74 | msgctxt "#30050" | |
| 75 | msgid "Prowlarr Configuration:" | |
| 76 | msgstr "" | |
| 77 | ||
| 78 | msgctxt "#30051" | |
| 79 | msgid "Client Key" | |
| 80 | msgstr "" | |
| 81 | ||
| 82 | msgctxt "#30052" | |
| 83 | msgid "Host" | |
| 84 | msgstr "" | |
| 85 | ||
| 86 | msgctxt "#30053" | |
| 87 | msgid "Indexers-Ids" | |
| 88 | msgstr "" | |
| 89 | ||
| 90 | msgctxt "#30054" | |
| 91 | msgid "Anime IndexersIds" | |
| 92 | msgstr "" | |
| 93 | ||
| 94 | msgctxt "#30100" | |
| 95 | msgid "TMDB" | |
| 96 | msgstr "" | |
| 97 | ||
| 98 | msgctxt "#30102" | |
| 99 | msgid "TMDB Configuration" | |
| 100 | msgstr "" | |
| 101 | ||
| 102 | msgctxt "#30103" | |
| 103 | msgid "Client Key" | |
| 104 | msgstr "" | |
| 105 | ||
| 106 | msgctxt "#30104" | |
| 107 | msgid "Clear tmdb cache" | |
| 108 | msgstr "" | |
| 109 | ||
| 110 | msgctxt "#30105" | |
| 111 | msgid "Cache expiration hours" | |
| 112 | msgstr "" | |
| 113 | ||
| 114 | msgctxt "#30106" | |
| 115 | msgid "Clear Cache" | |
| 116 | msgstr "" | |
| 117 | ||
| 118 | msgctxt "#30107" | |
| 119 | msgid "Enable Cache" | |
| 120 | msgstr "" | |
| 121 | ||
| 122 | msgctxt "#30140" | |
| 123 | msgid "AniList" | |
| 124 | msgstr "" | |
| 125 | ||
| 126 | msgctxt "#30142" | |
| 127 | msgid "AniList Configuration:" | |
| 128 | msgstr "" | |
| 129 | ||
| 130 | msgctxt "#30143" | |
| 131 | msgid "Client ID" | |
| 132 | msgstr "" | |
| 133 | ||
| 134 | msgctxt "#30144" | |
| 135 | msgid "Client Secret" | |
| 136 | msgstr "" | |
| 137 | ||
| 138 | msgctxt "#30150" | |
| 139 | msgid "Services" | |
| 140 | msgstr "" | |
| 141 | ||
| 142 | msgctxt "#30152" | |
| 143 | msgid "Real Debrid Configuration" | |
| 144 | msgstr "" | |
| 145 | ||
| 146 | msgctxt "#30154" | |
| 147 | msgid "Token" | |
| 148 | msgstr "" | |
| 149 | ||
| 150 | msgctxt "#30200" | |
| 151 | msgid "Fanart.TV" | |
| 152 | msgstr "" | |
| 153 | ||
| 154 | msgctxt "#30202" | |
| 155 | msgid "Fanart.TV Configuration:" | |
| 156 | msgstr "" | |
| 157 | ||
| 158 | msgctxt "#30203" | |
| 159 | msgid "Client ID" | |
| 160 | msgstr "" | |
| 161 | ||
| 162 | msgctxt "#30221" | |
| 163 | msgid "No Client Key provided for" | |
| 164 | msgstr "" | |
| 165 | ||
| 166 | msgctxt "#30223" | |
| 167 | msgid "You need to configure" | |
| 168 | msgstr "" | |
| 169 | ||
| 170 | msgctxt "#30228" | |
| 171 | msgid "The request to Torrentio failed." | |
| 172 | msgstr "" | |
| 173 | ||
| 174 | msgctxt "#30229" | |
| 175 | msgid "The request to Jackett failed." | |
| 176 | msgstr "" | |
| 177 | ||
| 178 | msgctxt "#30230" | |
| 179 | msgid "The request to Prowlarr failed." | |
| 180 | msgstr "" | |
| 181 | ||
| 182 | msgctxt "#30231" | |
| 183 | msgid "The request to Elhosted failed." | |
| 184 | msgstr "" | |
| 185 | ||
| 186 | msgctxt "#30232" | |
| 187 | msgid "The request to Jackgram failed." | |
| 188 | msgstr "" | |
| 189 | ||
| 190 | msgctxt "#30233" | |
| 191 | msgid "The request to Mediafusion failed." | |
| 192 | msgstr "" | |
| 193 | ||
| 194 | msgctxt "#30234" | |
| 195 | msgid "The request to Peerflix failed." | |
| 196 | msgstr "" | |
| 197 | ||
| 198 | msgctxt "#30235" | |
| 199 | msgid "Stremio" | |
| 200 | msgstr "" | |
| 201 | ||
| 202 | msgctxt "#30236" | |
| 203 | msgid "Stremio add-ons" | |
| 204 | msgstr "" | |
| 205 | ||
| 206 | msgctxt "#30237" | |
| 207 | msgid "Import your config" | |
| 208 | msgstr "" | |
| 209 | ||
| 210 | msgctxt "#30238" | |
| 211 | msgid "Manage add-ons" | |
| 212 | msgstr "" | |
| 213 | ||
| 214 | msgctxt "#30240" | |
| 215 | msgid "TMDB cache cleared" | |
| 216 | msgstr "" | |
| 217 | ||
| 218 | msgctxt "#30241" | |
| 219 | msgid "Search on TMDB:" | |
| 220 | msgstr "" | |
| 221 | ||
| 222 | msgctxt "#30242" | |
| 223 | msgid "Search on AniList:" | |
| 224 | msgstr "" | |
| 225 | ||
| 226 | msgctxt "#30243" | |
| 227 | msgid "Search for a keyword:" | |
| 228 | msgstr "" | |
| 229 | ||
| 230 | msgctxt "#30244" | |
| 231 | msgid "Cache cleared" | |
| 232 | msgstr "" | |
| 233 | ||
| 234 | msgctxt "#30249" | |
| 235 | msgid "Addon Jacktook Burst not found - (script.jacktook.burst)" | |
| 236 | msgstr "" | |
| 237 | ||
| 238 | msgctxt "#30250" | |
| 239 | msgid "Addon Torrest not found - (plugin.video.torrest)" | |
| 240 | msgstr "" | |
| 241 | ||
| 242 | msgctxt "#30251" | |
| 243 | msgid "No sources found to play" | |
| 244 | msgstr "" | |
| 245 | ||
| 246 | msgctxt "#30252" | |
| 247 | msgid "Addon Elementum not found - (plugin.video.elementum)" | |
| 248 | msgstr "" | |
| 249 | ||
| 250 | msgctxt "#30253" | |
| 251 | msgid "Addon Jacktorr not found - (plugin.video.jacktorr)" | |
| 252 | msgstr "" | |
| 253 | ||
| 254 | msgctxt "#30254" | |
| 255 | msgid "Do you want to install Elementum?" | |
| 256 | msgstr "" | |
| 257 | ||
| 258 | msgctxt "#30255" | |
| 259 | msgid "Do you want to install Jacktorr?" | |
| 260 | msgstr "" | |
| 261 | ||
| 262 | msgctxt "#30256" | |
| 263 | msgid "Do you want to install Torrest?" | |
| 264 | msgstr "" | |
| 265 | ||
| 266 | msgctxt "#30257" | |
| 267 | msgid "Do you want to install Jacktook Burst?" | |
| 268 | msgstr "" | |
| 269 | ||
| 270 | msgctxt "#30650" | |
| 271 | msgid "Queued" | |
| 272 | msgstr "" | |
| 273 | ||
| 274 | msgctxt "#30651" | |
| 275 | msgid "Checking" | |
| 276 | msgstr "" | |
| 277 | ||
| 278 | msgctxt "#30652" | |
| 279 | msgid "Finding" | |
| 280 | msgstr "" | |
| 281 | ||
| 282 | msgctxt "#30653" | |
| 283 | msgid "Downloading" | |
| 284 | msgstr "" | |
| 285 | ||
| 286 | msgctxt "#30654" | |
| 287 | msgid "Finished" | |
| 288 | msgstr "" | |
| 289 | ||
| 290 | msgctxt "#30655" | |
| 291 | msgid "Seeding" | |
| 292 | msgstr "" | |
| 293 | ||
| 294 | msgctxt "#30656" | |
| 295 | msgid "Allocating" | |
| 296 | msgstr "" | |
| 297 | ||
| 298 | msgctxt "#30657" | |
| 299 | msgid "Checking resume data" | |
| 300 | msgstr "" | |
| 301 | ||
| 302 | msgctxt "#30658" | |
| 303 | msgid "Paused" | |
| 304 | msgstr "" | |
| 305 | ||
| 306 | msgctxt "#30659" | |
| 307 | msgid "Buffering" | |
| 308 | msgstr "" | |
| 309 | ||
| 310 | msgctxt "#30660" | |
| 311 | msgid "Unknown" | |
| 312 | msgstr "" | |
| 313 | ||
| 314 | msgctxt "#30700" | |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "" | |
| 317 | ||
| 318 | msgctxt "#30701" | |
| 319 | msgid "Stop downloading" | |
| 320 | msgstr "" | |
| 321 | ||
| 322 | msgctxt "#30702" | |
| 323 | msgid "Downloads" | |
| 324 | msgstr "" | |
| 325 | ||
| 326 | msgctxt "#30703" | |
| 327 | msgid "Resume" | |
| 328 | msgstr "" | |
| 329 | ||
| 330 | msgctxt "#30704" | |
| 331 | msgid "Pause" | |
| 332 | msgstr "" | |
| 333 | ||
| 334 | msgctxt "#30705" | |
| 335 | msgid "Remove torrent" | |
| 336 | msgstr "" | |
| 337 | ||
| 338 | msgctxt "#30706" | |
| 339 | msgid "Remove torrent and files" | |
| 340 | msgstr "" | |
| 341 | ||
| 342 | msgctxt "#30707" | |
| 343 | msgid "Torrent status" | |
| 344 | msgstr "" | |
| 345 | ||
| 346 | msgctxt "#30708" | |
| 347 | msgid "Stop" | |
| 348 | msgstr "" | |
| 349 | ||
| 350 | msgctxt "#30709" | |
| 351 | msgid "Drop torrent" | |
| 352 | msgstr "" | |
| 353 | ||
| 354 | msgctxt "#30710" | |
| 355 | msgid "Include Season Packs" | |
| 356 | msgstr "" | |
| 357 | ||
| 358 | msgctxt "#30711" | |
| 359 | msgid "Torrent Settings" | |
| 360 | msgstr "" | |
| 361 | ||
| 362 | msgctxt "#30712" | |
| 363 | msgid "General" | |
| 364 | msgstr "" | |
| 365 | ||
| 366 | msgctxt "#30713" | |
| 367 | msgid "Enable" | |
| 368 | msgstr "" | |
| 369 | ||
| 370 | msgctxt "#30714" | |
| 371 | msgid "Jacktorr" | |
| 372 | msgstr "" | |
| 373 | ||
| 374 | msgctxt "#30715" | |
| 375 | msgid "Torrest" | |
| 376 | msgstr "" | |
| 377 | ||
| 378 | msgctxt "#30716" | |
| 379 | msgid "Elementum" | |
| 380 | msgstr "" | |
| 381 | ||
| 382 | msgctxt "#30717" | |
| 383 | msgid "General" | |
| 384 | msgstr "" | |
| 385 | ||
| 386 | msgctxt "#30718" | |
| 387 | msgid "Auto-Play" | |
| 388 | msgstr "" | |
| 389 | ||
| 390 | msgctxt "#30719" | |
| 391 | msgid "Enable Next Episode Dialog" | |
| 392 | msgstr "" | |
| 393 | ||
| 394 | msgctxt "#30720" | |
| 395 | msgid "Seconds left before show Next Episode Dialog" | |
| 396 | msgstr "" | |
| 397 | ||
| 398 | msgctxt "#30721" | |
| 399 | msgid "Cache Enabled" | |
| 400 | msgstr "" | |
| 401 | ||
| 402 | msgctxt "#30722" | |
| 403 | msgid "Cache Expiration (hours)" | |
| 404 | msgstr "" | |
| 405 | ||
| 406 | msgctxt "#30723" | |
| 407 | msgid "Cache" | |
| 408 | msgstr "" | |
| 409 | ||
| 410 | msgctxt "#30724" | |
| 411 | msgid "Update" | |
| 412 | msgstr "" | |
| 413 | ||
| 414 | msgctxt "#30725" | |
| 415 | msgid "Indexers Configuration" | |
| 416 | msgstr "" | |
| 417 | ||
| 418 | msgctxt "#30726" | |
| 419 | msgid "Sort" | |
| 420 | msgstr "" | |
| 421 | ||
| 422 | msgctxt "#30727" | |
| 423 | msgid "Quality" | |
| 424 | msgstr "" | |
| 425 | ||
| 426 | msgctxt "#30938" | |
| 427 | msgid "Silent Resume" | |
| 428 | msgstr "" | |
| 429 | ||
| 430 | msgctxt "#30939" | |
| 431 | msgid "Auto-resume from last position without asking" | |
| 432 | msgstr "" | |
| 433 | ||
| 434 | msgctxt "#30728" | |
| 435 | msgid "Seeds" | |
| 436 | msgstr "" | |
| 437 | ||
| 438 | msgctxt "#30729" | |
| 439 | msgid "Size" | |
| 440 | msgstr "" | |
| 441 | ||
| 442 | msgctxt "#30730" | |
| 443 | msgid "Cached" | |
| 444 | msgstr "" | |
| 445 | ||
| 446 | msgctxt "#30731" | |
| 447 | msgid "Date" | |
| 448 | msgstr "" | |
| 449 | ||
| 450 | msgctxt "#30732" | |
| 451 | msgid "None" | |
| 452 | msgstr "" | |
| 453 | ||
| 454 | msgctxt "#30733" | |
| 455 | msgid "Torrentio Configuration" | |
| 456 | msgstr "" | |
| 457 | ||
| 458 | msgctxt "#30734" | |
| 459 | msgid "Enable" | |
| 460 | msgstr "" | |
| 461 | ||
| 462 | msgctxt "#30736" | |
| 463 | msgid "Subtitle Language" | |
| 464 | msgstr "" | |
| 465 | ||
| 466 | msgctxt "#30737" | |
| 467 | msgid "GB" | |
| 468 | msgstr "" | |
| 469 | ||
| 470 | msgctxt "#30738" | |
| 471 | msgid "JP" | |
| 472 | msgstr "" | |
| 473 | ||
| 474 | msgctxt "#30739" | |
| 475 | msgid "RU" | |
| 476 | msgstr "" | |
| 477 | ||
| 478 | msgctxt "#30740" | |
| 479 | msgid "IT" | |
| 480 | msgstr "" | |
| 481 | ||
| 482 | msgctxt "#30741" | |
| 483 | msgid "PT" | |
| 484 | msgstr "" | |
| 485 | ||
| 486 | msgctxt "#30742" | |
| 487 | msgid "ES" | |
| 488 | msgstr "" | |
| 489 | ||
| 490 | msgctxt "#30743" | |
| 491 | msgid "MX" | |
| 492 | msgstr "" | |
| 493 | ||
| 494 | msgctxt "#30744" | |
| 495 | msgid "KR" | |
| 496 | msgstr "" | |
| 497 | ||
| 498 | msgctxt "#30745" | |
| 499 | msgid "CN" | |
| 500 | msgstr "" | |
| 501 | ||
| 502 | msgctxt "#30746" | |
| 503 | msgid "TW" | |
| 504 | msgstr "" | |
| 505 | ||
| 506 | msgctxt "#30747" | |
| 507 | msgid "FR" | |
| 508 | msgstr "" | |
| 509 | ||
| 510 | msgctxt "#30748" | |
| 511 | msgid "DE" | |
| 512 | msgstr "" | |
| 513 | ||
| 514 | msgctxt "#30749" | |
| 515 | msgid "NL" | |
| 516 | msgstr "" | |
| 517 | ||
| 518 | msgctxt "#30750" | |
| 519 | msgid "IN" | |
| 520 | msgstr "" | |
| 521 | ||
| 522 | msgctxt "#30751" | |
| 523 | msgid "PL" | |
| 524 | msgstr "" | |
| 525 | ||
| 526 | msgctxt "#30752" | |
| 527 | msgid "LT" | |
| 528 | msgstr "" | |
| 529 | ||
| 530 | msgctxt "#30753" | |
| 531 | msgid "CZ" | |
| 532 | msgstr "" | |
| 533 | ||
| 534 | msgctxt "#30754" | |
| 535 | msgid "SK" | |
| 536 | msgstr "" | |
| 537 | ||
| 538 | msgctxt "#30755" | |
| 539 | msgid "SI" | |
| 540 | msgstr "" | |
| 541 | ||
| 542 | msgctxt "#30756" | |
| 543 | msgid "HU" | |
| 544 | msgstr "" | |
| 545 | ||
| 546 | msgctxt "#30757" | |
| 547 | msgid "RO" | |
| 548 | msgstr "" | |
| 549 | ||
| 550 | msgctxt "#30758" | |
| 551 | msgid "HR" | |
| 552 | msgstr "" | |
| 553 | ||
| 554 | msgctxt "#30759" | |
| 555 | msgid "UA" | |
| 556 | msgstr "" | |
| 557 | ||
| 558 | msgctxt "#30760" | |
| 559 | msgid "GR" | |
| 560 | msgstr "" | |
| 561 | ||
| 562 | msgctxt "#30761" | |
| 563 | msgid "DK" | |
| 564 | msgstr "" | |
| 565 | ||
| 566 | msgctxt "#30762" | |
| 567 | msgid "FI" | |
| 568 | msgstr "" | |
| 569 | ||
| 570 | msgctxt "#30763" | |
| 571 | msgid "SE" | |
| 572 | msgstr "" | |
| 573 | ||
| 574 | msgctxt "#30764" | |
| 575 | msgid "NO" | |
| 576 | msgstr "" | |
| 577 | ||
| 578 | msgctxt "#30765" | |
| 579 | msgid "TR" | |
| 580 | msgstr "" | |
| 581 | ||
| 582 | msgctxt "#30766" | |
| 583 | msgid "Torrentio Providers" | |
| 584 | msgstr "" | |
| 585 | ||
| 586 | msgctxt "#30767" | |
| 587 | msgid "Jackgram Configuration" | |
| 588 | msgstr "" | |
| 589 | ||
| 590 | msgctxt "#30768" | |
| 591 | msgid "Host" | |
| 592 | msgstr "" | |
| 593 | ||
| 594 | msgctxt "#30769" | |
| 595 | msgid "Elfhosted/KnightCrawler Configuration" | |
| 596 | msgstr "" | |
| 597 | ||
| 598 | msgctxt "#30770" | |
| 599 | msgid "Language" | |
| 600 | msgstr "" | |
| 601 | ||
| 602 | msgctxt "#30771" | |
| 603 | msgid "Zilean Configuration" | |
| 604 | msgstr "" | |
| 605 | ||
| 606 | msgctxt "#30772" | |
| 607 | msgid "Zilean Host" | |
| 608 | msgstr "" | |
| 609 | ||
| 610 | msgctxt "#30774" | |
| 611 | msgid "Zilean Timeout" | |
| 612 | msgstr "" | |
| 613 | ||
| 614 | msgctxt "#30775" | |
| 615 | msgid "Jackett Configuration" | |
| 616 | msgstr "" | |
| 617 | ||
| 618 | msgctxt "#30776" | |
| 619 | msgid "Prowlarr Configuration" | |
| 620 | msgstr "" | |
| 621 | ||
| 622 | msgctxt "#30777" | |
| 623 | msgid "MediaFusion configuration" | |
| 624 | msgstr "" | |
| 625 | ||
| 626 | msgctxt "#30779" | |
| 627 | msgid "Manifest Url" | |
| 628 | msgstr "" | |
| 629 | ||
| 630 | msgctxt "#30780" | |
| 631 | msgid "Jacktook Burst Configuration" | |
| 632 | msgstr "" | |
| 633 | ||
| 634 | msgctxt "#30781" | |
| 635 | msgid "Burst Configuration" | |
| 636 | msgstr "" | |
| 637 | ||
| 638 | msgctxt "#30782" | |
| 639 | msgid "User" | |
| 640 | msgstr "" | |
| 641 | ||
| 642 | msgctxt "#30783" | |
| 643 | msgid "Authorize" | |
| 644 | msgstr "" | |
| 645 | ||
| 646 | msgctxt "#30784" | |
| 647 | msgid "Remove Authorization" | |
| 648 | msgstr "" | |
| 649 | ||
| 650 | msgctxt "#30785" | |
| 651 | msgid "Premiumize Configuration" | |
| 652 | msgstr "" | |
| 653 | ||
| 654 | msgctxt "#30786" | |
| 655 | msgid "Enabled" | |
| 656 | msgstr "" | |
| 657 | ||
| 658 | msgctxt "#30787" | |
| 659 | msgid "Token" | |
| 660 | msgstr "" | |
| 661 | ||
| 662 | msgctxt "#30788" | |
| 663 | msgid "TorBox Configuration" | |
| 664 | msgstr "" | |
| 665 | ||
| 666 | msgctxt "#30789" | |
| 667 | msgid "Debrider Configuration" | |
| 668 | msgstr "" | |
| 669 | ||
| 670 | msgctxt "#30790" | |
| 671 | msgid "Uncached" | |
| 672 | msgstr "" | |
| 673 | ||
| 674 | msgctxt "#30791" | |
| 675 | msgid "Show uncached torrents" | |
| 676 | msgstr "" | |
| 677 | ||
| 678 | msgctxt "#30792" | |
| 679 | msgid "Use Kodi Language" | |
| 680 | msgstr "" | |
| 681 | ||
| 682 | msgctxt "#30793" | |
| 683 | msgid "Trakt" | |
| 684 | msgstr "" | |
| 685 | ||
| 686 | msgctxt "#30794" | |
| 687 | msgid "Trakt Configuration" | |
| 688 | msgstr "" | |
| 689 | ||
| 690 | msgctxt "#30795" | |
| 691 | msgid "Expires" | |
| 692 | msgstr "" | |
| 693 | ||
| 694 | msgctxt "#30796" | |
| 695 | msgid "Refresh" | |
| 696 | msgstr "" | |
| 697 | ||
| 698 | msgctxt "#30797" | |
| 699 | msgid "Token" | |
| 700 | msgstr "" | |
| 701 | ||
| 702 | msgctxt "#30798" | |
| 703 | msgid "Trakt Client" | |
| 704 | msgstr "" | |
| 705 | ||
| 706 | msgctxt "#30800" | |
| 707 | msgid "Trakt Secret" | |
| 708 | msgstr "" | |
| 709 | ||
| 710 | msgctxt "#30802" | |
| 711 | msgid "Log out" | |
| 712 | msgstr "" | |
| 713 | ||
| 714 | msgctxt "#30804" | |
| 715 | msgid "Menus" | |
| 716 | msgstr "" | |
| 717 | ||
| 718 | msgctxt "#30805" | |
| 719 | msgid "Show Telegram Menu" | |
| 720 | msgstr "" | |
| 721 | ||
| 722 | msgctxt "#30806" | |
| 723 | msgid "Clear cache when update" | |
| 724 | msgstr "" | |
| 725 | ||
| 726 | msgctxt "#30807" | |
| 727 | msgid "Peerfix Configuration" | |
| 728 | msgstr "" | |
| 729 | ||
| 730 | msgctxt "#30894" | |
| 731 | msgid "Show Changelog" | |
| 732 | msgstr "Show Changelog" | |
| 733 | ||
| 734 | msgctxt "#30895" | |
| 735 | msgid "View the latest changes and updates to the addon." | |
| 736 | msgstr "View the latest changes and updates to the addon." | |
| 737 | ||
| 738 | msgctxt "#30896" | |
| 739 | msgid "Calendar" | |
| 740 | msgstr "Calendar" | |
| 741 | ||
| 742 | msgctxt "#30897" | |
| 743 | msgid "Use broadcast day adjustment" | |
| 744 | msgstr "Use broadcast day adjustment" | |
| 745 | ||
| 746 | msgctxt "#30898" | |
| 747 | msgid "Adjust broadcast day based on manual offset" | |
| 748 | msgstr "Adjust broadcast day based on manual offset" | |
| 749 | ||
| 750 | msgctxt "#30899" | |
| 751 | msgid "Broadcast Offset (Hours)" | |
| 752 | msgstr "Broadcast Offset (Hours)" | |
| 753 | ||
| 754 | msgctxt "#30900" | |
| 755 | msgid "Manual hour adjustment for broadcast day simulation" | |
| 756 | msgstr "Manual hour adjustment for broadcast day simulation" | |
| 757 | ||
| 758 | msgctxt "#30905" | |
| 759 | msgid "Trakt Calendar Previous Days" | |
| 760 | msgstr "Trakt Calendar Previous Days" | |
| 761 | ||
| 762 | msgctxt "#30906" | |
| 763 | msgid "Number of days in the past to show in the Trakt calendar." | |
| 764 | msgstr "Number of days in the past to show in the Trakt calendar." | |
| 765 | ||
| 766 | msgctxt "#30907" | |
| 767 | msgid "Trakt Calendar Future Days" | |
| 768 | msgstr "Trakt Calendar Future Days" | |
| 769 | ||
| 770 | msgctxt "#30908" | |
| 771 | msgid "Number of days in the future to show in the Trakt calendar." | |
| 772 | msgstr "Number of days in the future to show in the Trakt calendar." | |
| 773 | ||
| 774 | msgctxt "#30909" | |
| 775 | msgid "Show All Shows in Calendar" | |
| 776 | msgstr "Show All Shows in Calendar" | |
| 777 | ||
| 778 | msgctxt "#30918" | |
| 779 | msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead." | |
| 780 | msgstr "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead." | |
| 781 | ||
| 782 | msgctxt "#30910" | |
| 783 | msgid "Show all airing shows instead of just your watchlist/collection." | |
| 784 | msgstr "Show all airing shows instead of just your watchlist/collection." | |
| 785 | ||
| 786 | msgctxt "#30911" | |
| 787 | msgid "Your Torbox account username/email." | |
| 788 | msgstr "Your Torbox account username/email." | |
| 789 | ||
| 790 | msgctxt "#30912" | |
| 791 | msgid "Authorize add-on to access Torbox account." | |
| 792 | msgstr "Authorize add-on to access Torbox account." | |
| 793 | ||
| 794 | msgctxt "#30913" | |
| 795 | msgid "Revoke Torbox account access." | |
| 796 | msgstr "Revoke Torbox account access." | |
| 797 | ||
| 798 | msgctxt "#30914" | |
| 799 | msgid "Trakt Sync Interval (minutes)" | |
| 800 | msgstr "Trakt Sync Interval (minutes)" | |
| 801 | ||
| 802 | msgctxt "#30915" | |
| 803 | msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running." | |
| 804 | msgstr "How often Jacktook checks Trakt for remote activity changes while Kodi is running." | |
| 805 | ||
| 806 | msgctxt "#30916" | |
| 807 | msgid "Account Info" | |
| 808 | msgstr "Account Info" | |
| 809 | ||
| 810 | msgctxt "#30917" | |
| 811 | msgid "View your Trakt account profile and library stats." | |
| 812 | msgstr "View your Trakt account profile and library stats." | |
| 813 | ||
| 814 | msgctxt "#30924" | |
| 815 | msgid "Trakt List Name" | |
| 816 | msgstr "Trakt List Name" | |
| 817 | ||
| 818 | msgctxt "#30925" | |
| 819 | msgid "Trakt List Description" | |
| 820 | msgstr "Trakt List Description" | |
| 821 | ||
| 822 | msgctxt "#30926" | |
| 823 | msgid "Create Trakt List" | |
| 824 | msgstr "Create Trakt List" | |
| 825 | ||
| 826 | msgctxt "#30927" | |
| 827 | msgid "Choose Trakt List" | |
| 828 | msgstr "Choose Trakt List" | |
| 829 | ||
| 830 | msgctxt "#30936" | |
| 831 | msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position." | |
| 832 | msgstr "Enable or disable Trakt scrobbling and resume playback from the last tracked position." | |
| 833 | ||
| 834 | msgctxt "#30937" | |
| 835 | msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view." | |
| 836 | msgstr "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view." | |
| 837 | ||
| 838 | msgctxt "#40000" | |
| 839 | msgid "Arabic (United Arab Emirates)" | |
| 840 | msgstr "" | |
| 841 | ||
| 842 | msgctxt "#40001" | |
| 843 | msgid "Arabic (Saudi Arabia)" | |
| 844 | msgstr "" | |
| 845 | ||
| 846 | msgctxt "#40002" | |
| 847 | msgid "Belarusian (Belarus)" | |
| 848 | msgstr "" | |
| 849 | ||
| 850 | msgctxt "#40003" | |
| 851 | msgid "Bulgarian (Bulgaria)" | |
| 852 | msgstr "" | |
| 853 | ||
| 854 | msgctxt "#40004" | |
| 855 | msgid "Bengali (Bangladesh)" | |
| 856 | msgstr "" | |
| 857 | ||
| 858 | msgctxt "#40005" | |
| 859 | msgid "Catalan (Spain)" | |
| 860 | msgstr "" | |
| 861 | ||
| 862 | msgctxt "#40006" | |
| 863 | msgid "Chamorro (Guam)" | |
| 864 | msgstr "" | |
| 865 | ||
| 866 | msgctxt "#40007" | |
| 867 | msgid "Czech (Czech Republic)" | |
| 868 | msgstr "" | |
| 869 | ||
| 870 | msgctxt "#40008" | |
| 871 | msgid "Danish (Denmark)" | |
| 872 | msgstr "" | |
| 873 | ||
| 874 | msgctxt "#40009" | |
| 875 | msgid "German (Austria)" | |
| 876 | msgstr "" | |
| 877 | ||
| 878 | msgctxt "#40010" | |
| 879 | msgid "German (Switzerland)" | |
| 880 | msgstr "" | |
| 881 | ||
| 882 | msgctxt "#40011" | |
| 883 | msgid "German (Germany)" | |
| 884 | msgstr "" | |
| 885 | ||
| 886 | msgctxt "#40012" | |
| 887 | msgid "Greek (Greece)" | |
| 888 | msgstr "" | |
| 889 | ||
| 890 | msgctxt "#40013" | |
| 891 | msgid "English (Australia)" | |
| 892 | msgstr "" | |
| 893 | ||
| 894 | msgctxt "#40014" | |
| 895 | msgid "English (Canada)" | |
| 896 | msgstr "" | |
| 897 | ||
| 898 | msgctxt "#40015" | |
| 899 | msgid "English (United Kingdom)" | |
| 900 | msgstr "" | |
| 901 | ||
| 902 | msgctxt "#40016" | |
| 903 | msgid "English (Ireland)" | |
| 904 | msgstr "" | |
| 905 | ||
| 906 | msgctxt "#40017" | |
| 907 | msgid "English (New Zealand)" | |
| 908 | msgstr "" | |
| 909 | ||
| 910 | msgctxt "#40018" | |
| 911 | msgid "English (United States of America)" | |
| 912 | msgstr "" | |
| 913 | ||
| 914 | msgctxt "#40019" | |
| 915 | msgid "Esperanto (None)" | |
| 916 | msgstr "" | |
| 917 | ||
| 918 | msgctxt "#40020" | |
| 919 | msgid "Spanish (Spain)" | |
| 920 | msgstr "" | |
| 921 | ||
| 922 | msgctxt "#40021" | |
| 923 | msgid "Spanish (Mexico)" | |
| 924 | msgstr "" | |
| 925 | ||
| 926 | msgctxt "#40022" | |
| 927 | msgid "Estonian (Estonia)" | |
| 928 | msgstr "" | |
| 929 | ||
| 930 | msgctxt "#40023" | |
| 931 | msgid "Basque (Spain)" | |
| 932 | msgstr "" | |
| 933 | ||
| 934 | msgctxt "#40024" | |
| 935 | msgid "Persian (Iran)" | |
| 936 | msgstr "" | |
| 937 | ||
| 938 | msgctxt "#40025" | |
| 939 | msgid "Finnish (Finland)" | |
| 940 | msgstr "" | |
| 941 | ||
| 942 | msgctxt "#40026" | |
| 943 | msgid "French (Canada)" | |
| 944 | msgstr "" | |
| 945 | ||
| 946 | msgctxt "#40027" | |
| 947 | msgid "French (France)" | |
| 948 | msgstr "" | |
| 949 | ||
| 950 | msgctxt "#40028" | |
| 951 | msgid "Galician (Spain)" | |
| 952 | msgstr "" | |
| 953 | ||
| 954 | msgctxt "#40029" | |
| 955 | msgid "Hebrew (Israel)" | |
| 956 | msgstr "" | |
| 957 | ||
| 958 | msgctxt "#40030" | |
| 959 | msgid "Hindi (India)" | |
| 960 | msgstr "" | |
| 961 | ||
| 962 | msgctxt "#40031" | |
| 963 | msgid "Hungarian (Hungary)" | |
| 964 | msgstr "" | |
| 965 | ||
| 966 | msgctxt "#40032" | |
| 967 | msgid "Indonesian (Indonesia)" | |
| 968 | msgstr "" | |
| 969 | ||
| 970 | msgctxt "#40033" | |
| 971 | msgid "Italian (Italy)" | |
| 972 | msgstr "" | |
| 973 | ||
| 974 | msgctxt "#40034" | |
| 975 | msgid "Japanese (Japan)" | |
| 976 | msgstr "" | |
| 977 | ||
| 978 | msgctxt "#40035" | |
| 979 | msgid "Georgian (Georgia)" | |
| 980 | msgstr "" | |
| 981 | ||
| 982 | msgctxt "#40036" | |
| 983 | msgid "Kazakh (Kazakhstan)" | |
| 984 | msgstr "" | |
| 985 | ||
| 986 | msgctxt "#40037" | |
| 987 | msgid "Kannada (India)" | |
| 988 | msgstr "" | |
| 989 | ||
| 990 | msgctxt "#40038" | |
| 991 | msgid "Korean (South Korea)" | |
| 992 | msgstr "" | |
| 993 | ||
| 994 | msgctxt "#40039" | |
| 995 | msgid "Lithuanian (Lithuania)" | |
| 996 | msgstr "" | |
| 997 | ||
| 998 | msgctxt "#40040" | |
| 999 | msgid "Latvian (Latvia)" | |
| 1000 | msgstr "" | |
| 1001 | ||
| 1002 | msgctxt "#40041" | |
| 1003 | msgid "Malayalam (India)" | |
| 1004 | msgstr "" | |
| 1005 | ||
| 1006 | msgctxt "#40042" | |
| 1007 | msgid "Malay (Malaysia)" | |
| 1008 | msgstr "" | |
| 1009 | ||
| 1010 | msgctxt "#40043" | |
| 1011 | msgid "Malay (Singapore)" | |
| 1012 | msgstr "" | |
| 1013 | ||
| 1014 | msgctxt "#40044" | |
| 1015 | msgid "Norwegian Bokmål (Norway)" | |
| 1016 | msgstr "" | |
| 1017 | ||
| 1018 | msgctxt "#40045" | |
| 1019 | msgid "Dutch (Netherlands)" | |
| 1020 | msgstr "" | |
| 1021 | ||
| 1022 | msgctxt "#40046" | |
| 1023 | msgid "Norwegian (Norway)" | |
| 1024 | msgstr "" | |
| 1025 | ||
| 1026 | msgctxt "#40047" | |
| 1027 | msgid "Polish (Poland)" | |
| 1028 | msgstr "" | |
| 1029 | ||
| 1030 | msgctxt "#40048" | |
| 1031 | msgid "Portuguese (Brazil)" | |
| 1032 | msgstr "" | |
| 1033 | ||
| 1034 | msgctxt "#40049" | |
| 1035 | msgid "Portuguese (Portugal)" | |
| 1036 | msgstr "" | |
| 1037 | ||
| 1038 | msgctxt "#40050" | |
| 1039 | msgid "Romanian (Romania)" | |
| 1040 | msgstr "" | |
| 1041 | ||
| 1042 | msgctxt "#40051" | |
| 1043 | msgid "Russian (Russia)" | |
| 1044 | msgstr "" | |
| 1045 | ||
| 1046 | msgctxt "#40052" | |
| 1047 | msgid "Sinhalese (Sri Lanka)" | |
| 1048 | msgstr "" | |
| 1049 | ||
| 1050 | msgctxt "#40053" | |
| 1051 | msgid "Slovak (Slovakia)" | |
| 1052 | msgstr "" | |
| 1053 | ||
| 1054 | msgctxt "#40054" | |
| 1055 | msgid "Slovenian (Slovenia)" | |
| 1056 | msgstr "" | |
| 1057 | ||
| 1058 | msgctxt "#40055" | |
| 1059 | msgid "Serbian (Serbia)" | |
| 1060 | msgstr "" | |
| 1061 | ||
| 1062 | msgctxt "#40056" | |
| 1063 | msgid "Swedish (Sweden)" | |
| 1064 | msgstr "" | |
| 1065 | ||
| 1066 | msgctxt "#40057" | |
| 1067 | msgid "Tamil (India)" | |
| 1068 | msgstr "" | |
| 1069 | ||
| 1070 | msgctxt "#40058" | |
| 1071 | msgid "Telugu (India)" | |
| 1072 | msgstr "" | |
| 1073 | ||
| 1074 | msgctxt "#40059" | |
| 1075 | msgid "Thai (Thailand)" | |
| 1076 | msgstr "" | |
| 1077 | ||
| 1078 | msgctxt "#40060" | |
| 1079 | msgid "Tagalog (Philippines)" | |
| 1080 | msgstr "" | |
| 1081 | ||
| 1082 | msgctxt "#40061" | |
| 1083 | msgid "Turkish (Turkey)" | |
| 1084 | msgstr "" | |
| 1085 | ||
| 1086 | msgctxt "#40062" | |
| 1087 | msgid "Ukrainian (Ukraine)" | |
| 1088 | msgstr "" | |
| 1089 | ||
| 1090 | msgctxt "#40063" | |
| 1091 | msgid "Vietnamese (Vietnam)" | |
| 1092 | msgstr "" | |
| 1093 | ||
| 1094 | msgctxt "#40064" | |
| 1095 | msgid "Mandarin (China)" | |
| 1096 | msgstr "" | |
| 1097 | ||
| 1098 | msgctxt "#40065" | |
| 1099 | msgid "Mandarin (Hong Kong)" | |
| 1100 | msgstr "" | |
| 1101 | ||
| 1102 | msgctxt "#40066" | |
| 1103 | msgid "Mandarin (Taiwan)" | |
| 1104 | msgstr "" | |
| 1105 | ||
| 1106 | msgctxt "#40067" | |
| 1107 | msgid "Zulu (South Africa)" | |
| 1108 | msgstr "" | |
| 1109 | ||
| 1110 | msgctxt "#42418" | |
| 1111 | msgid "Language and country" | |
| 1112 | msgstr "" | |
| 1113 | ||
| 1114 | msgctxt "#42419" | |
| 1115 | msgid "The language and country settings used when looking up data from TMDb" | |
| 1116 | msgstr "" | |
| 1117 | ||
| 1118 | msgctxt "#42420" | |
| 1119 | msgid "EN" | |
| 1120 | msgstr "" | |
| 1121 | ||
| 1122 | msgctxt "#30801" | |
| 1123 | msgid "Zilean server address (include port if required)." | |
| 1124 | msgstr "" | |
| 1125 | ||
| 1126 | msgctxt "#30803" | |
| 1127 | msgid "Display torrents not yet cached on your debrid service." | |
| 1128 | msgstr "" | |
| 1129 | ||
| 1130 | msgctxt "#30808" | |
| 1131 | msgid "Fanart.tv Client Key (for artwork)." | |
| 1132 | msgstr "" | |
| 1133 | ||
| 1134 | msgctxt "#30809" | |
| 1135 | msgid "Improve performance by caching metadata." | |
| 1136 | msgstr "" | |
| 1137 | ||
| 1138 | msgctxt "#30810" | |
| 1139 | msgid "Seconds before Jackett requests time out (e.g., 30)." | |
| 1140 | msgstr "" | |
| 1141 | ||
| 1142 | msgctxt "#30811" | |
| 1143 | msgid "Trakt client secret from OAuth application settings." | |
| 1144 | msgstr "" | |
| 1145 | ||
| 1146 | msgctxt "#30812" | |
| 1147 | msgid "Limit TV show results to specific seasons/episodes." | |
| 1148 | msgstr "" | |
| 1149 | ||
| 1150 | msgctxt "#30813" | |
| 1151 | msgid "Hours before cached data is automatically refreshed (e.g., 24)." | |
| 1152 | msgstr "" | |
| 1153 | ||
| 1154 | msgctxt "#30814" | |
| 1155 | msgid "Premiumize Client Token (get from account page)." | |
| 1156 | msgstr "" | |
| 1157 | ||
| 1158 | msgctxt "#30815" | |
| 1159 | msgid "Jackett server URL (e.g., http://localhost)." | |
| 1160 | msgstr "" | |
| 1161 | ||
| 1162 | msgctxt "#30816" | |
| 1163 | msgid "Display Telegram integration options in UI." | |
| 1164 | msgstr "" | |
| 1165 | ||
| 1166 | msgctxt "#30817" | |
| 1167 | msgid "Your Real-Debrid account username/email." | |
| 1168 | msgstr "" | |
| 1169 | ||
| 1170 | msgctxt "#30818" | |
| 1171 | msgid "Trakt client ID from https://trakt.tv/oauth/apps." | |
| 1172 | msgstr "" | |
| 1173 | ||
| 1174 | msgctxt "#30819" | |
| 1175 | msgid "Connect Trakt.tv account to sync watch progress." | |
| 1176 | msgstr "" | |
| 1177 | ||
| 1178 | msgctxt "#30820" | |
| 1179 | msgid "Seconds before Zilean requests time out (e.g., 30)." | |
| 1180 | msgstr "" | |
| 1181 | ||
| 1182 | msgctxt "#30821" | |
| 1183 | msgid "Your Jackett Client Key (found in dashboard)." | |
| 1184 | msgstr "" | |
| 1185 | ||
| 1186 | msgctxt "#30822" | |
| 1187 | msgid "Sort results by criteria (e.g., 'date', 'title', 'size')." | |
| 1188 | msgstr "" | |
| 1189 | ||
| 1190 | msgctxt "#30823" | |
| 1191 | msgid "Configure Burst mode settings and providers." | |
| 1192 | msgstr "" | |
| 1193 | ||
| 1194 | msgctxt "#30824" | |
| 1195 | msgid "Enable Premiumize.me cloud service." | |
| 1196 | msgstr "" | |
| 1197 | ||
| 1198 | msgctxt "#30825" | |
| 1199 | msgid "Truncate descriptions after [X] characters (0 for full text)." | |
| 1200 | msgstr "" | |
| 1201 | ||
| 1202 | msgctxt "#30826" | |
| 1203 | msgid "Enable Prowlarr indexer management." | |
| 1204 | msgstr "" | |
| 1205 | ||
| 1206 | msgctxt "#30827" | |
| 1207 | msgid "Jackgram server URL (e.g., http://localhost:9117)." | |
| 1208 | msgstr "" | |
| 1209 | ||
| 1210 | msgctxt "#30828" | |
| 1211 | msgid "Debrider Client Key for authentication." | |
| 1212 | msgstr "" | |
| 1213 | ||
| 1214 | msgctxt "#30829" | |
| 1215 | msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)." | |
| 1216 | msgstr "" | |
| 1217 | ||
| 1218 | msgctxt "#30830" | |
| 1219 | msgid "Specify the torrent client." | |
| 1220 | msgstr "" | |
| 1221 | ||
| 1222 | msgctxt "#30831" | |
| 1223 | msgid "Enable Debrider cached torrent support." | |
| 1224 | msgstr "" | |
| 1225 | ||
| 1226 | msgctxt "#30832" | |
| 1227 | msgid "Torbox Client Token (found in account settings)." | |
| 1228 | msgstr "" | |
| 1229 | ||
| 1230 | msgctxt "#30833" | |
| 1231 | msgid "Trakt refresh token (auto-managed)." | |
| 1232 | msgstr "" | |
| 1233 | ||
| 1234 | msgctxt "#30834" | |
| 1235 | msgid "Show confirmation dialog before playing next episode." | |
| 1236 | msgstr "" | |
| 1237 | ||
| 1238 | msgctxt "#30835" | |
| 1239 | msgid "Authorize add-on to access Real-Debrid account." | |
| 1240 | msgstr "" | |
| 1241 | ||
| 1242 | msgctxt "#30836" | |
| 1243 | msgid "Enable torrent-based media content" | |
| 1244 | msgstr "" | |
| 1245 | ||
| 1246 | msgctxt "#30837" | |
| 1247 | msgid "Indicates if Real-Debrid account is linked." | |
| 1248 | msgstr "" | |
| 1249 | ||
| 1250 | msgctxt "#30838" | |
| 1251 | msgid "Automatically play next media item when current playback ends." | |
| 1252 | msgstr "" | |
| 1253 | ||
| 1254 | msgctxt "#30839" | |
| 1255 | msgid "Check for and install the latest add-on updates." | |
| 1256 | msgstr "" | |
| 1257 | ||
| 1258 | msgctxt "#30840" | |
| 1259 | msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)." | |
| 1260 | msgstr "" | |
| 1261 | ||
| 1262 | msgctxt "#30841" | |
| 1263 | msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)." | |
| 1264 | msgstr "" | |
| 1265 | ||
| 1266 | msgctxt "#30842" | |
| 1267 | msgid "Trakt authorization expiration date (read-only)." | |
| 1268 | msgstr "" | |
| 1269 | ||
| 1270 | msgctxt "#30843" | |
| 1271 | msgid "Enable Torbox.de cloud torrent integration." | |
| 1272 | msgstr "" | |
| 1273 | ||
| 1274 | msgctxt "#30844" | |
| 1275 | msgid "Trakt Access Token (auto-filled after auth)." | |
| 1276 | msgstr "" | |
| 1277 | ||
| 1278 | msgctxt "#30845" | |
| 1279 | msgid "Link Premiumize.me account to add-on." | |
| 1280 | msgstr "" | |
| 1281 | ||
| 1282 | msgctxt "#30846" | |
| 1283 | msgid "Clear cached data automatically after updating the add-on." | |
| 1284 | msgstr "" | |
| 1285 | ||
| 1286 | msgctxt "#30847" | |
| 1287 | msgid "Click to delete all cached data immediately." | |
| 1288 | msgstr "" | |
| 1289 | ||
| 1290 | msgctxt "#30848" | |
| 1291 | msgid "Real-Debrid Client Token (auto-filled after auth)." | |
| 1292 | msgstr "" | |
| 1293 | ||
| 1294 | msgctxt "#30849" | |
| 1295 | msgid "Enable Jackett torrent proxy support." | |
| 1296 | msgstr "" | |
| 1297 | ||
| 1298 | msgctxt "#30850" | |
| 1299 | msgid "Enable Jackgram integration for enhanced search." | |
| 1300 | msgstr "" | |
| 1301 | ||
| 1302 | msgctxt "#30851" | |
| 1303 | msgid "Prowlarr instance URL (e.g., http://localhost)." | |
| 1304 | msgstr "" | |
| 1305 | ||
| 1306 | msgctxt "#30852" | |
| 1307 | msgid "Maximum number of search results to display (e.g., 50)." | |
| 1308 | msgstr "" | |
| 1309 | ||
| 1310 | msgctxt "#30853" | |
| 1311 | msgid "Revoke Real-Debrid account access." | |
| 1312 | msgstr "" | |
| 1313 | ||
| 1314 | msgctxt "#30854" | |
| 1315 | msgid "Prowlarr Client Key (found in Settings > General)." | |
| 1316 | msgstr "" | |
| 1317 | ||
| 1318 | msgctxt "#30855" | |
| 1319 | msgid "Trakt.tv username (auto-populated after auth)." | |
| 1320 | msgstr "" | |
| 1321 | ||
| 1322 | msgctxt "#30856" | |
| 1323 | msgid "Disconnect Trakt.tv account from add-on." | |
| 1324 | msgstr "" | |
| 1325 | ||
| 1326 | msgctxt "#30857" | |
| 1327 | msgid "Enable Zilean integration for metadata fetching." | |
| 1328 | msgstr "" | |
| 1329 | ||
| 1330 | msgctxt "#30858" | |
| 1331 | msgid "Your Client Key (required for metadata)" | |
| 1332 | msgstr "" | |
| 1333 | ||
| 1334 | msgctxt "#30859" | |
| 1335 | msgid "Enable Burst mode for accelerated searches." | |
| 1336 | msgstr "" | |
| 1337 | ||
| 1338 | msgctxt "#30860" | |
| 1339 | msgid "Others" | |
| 1340 | msgstr "" | |
| 1341 | ||
| 1342 | msgctxt "#30861" | |
| 1343 | msgid "Filters" | |
| 1344 | msgstr "" | |
| 1345 | ||
| 1346 | msgctxt "#30862" | |
| 1347 | msgid "Torrent (P2P) Configuration" | |
| 1348 | msgstr "" | |
| 1349 | ||
| 1350 | msgctxt "#30863" | |
| 1351 | msgid "Specify the torrent client" | |
| 1352 | msgstr "" | |
| 1353 | ||
| 1354 | msgctxt "#30864" | |
| 1355 | msgid "Sources" | |
| 1356 | msgstr "" | |
| 1357 | ||
| 1358 | msgctxt "#30865" | |
| 1359 | msgid "General Filters" | |
| 1360 | msgstr "" | |
| 1361 | ||
| 1362 | msgctxt "#30866" | |
| 1363 | msgid "Sign in to your Stremio account to access add-ons and personalized features." | |
| 1364 | msgstr "Inicia sesión en tu cuenta de Stremio para acceder a complementos y características personalizadas." | |
| 1365 | ||
| 1366 | msgctxt "#30867" | |
| 1367 | msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data." | |
| 1368 | msgstr "Cierra sesión en tu cuenta de Stremio. Esto desconectará tu cuenta, pero no eliminará tus datos." | |
| 1369 | ||
| 1370 | msgctxt "#30868" | |
| 1371 | msgid "Log Out of Stremio" | |
| 1372 | msgstr "Cerrar sesión en Stremio" | |
| 1373 | ||
| 1374 | msgctxt "#30869" | |
| 1375 | msgid "Enable Stremio add-ons integration." | |
| 1376 | msgstr "Habilitar la integración de complementos de Stremio." | |
| 1377 | ||
| 1378 | msgctxt "#30870" | |
| 1379 | msgid "Select Stream Addons" | |
| 1380 | msgstr "" | |
| 1381 | ||
| 1382 | msgctxt "#30871" | |
| 1383 | msgid "Log In to Stremio" | |
| 1384 | msgstr "Iniciar sesión en Stremio" | |
| 1385 | ||
| 1386 | msgctxt "#30872" | |
| 1387 | msgid "Enable or disable Stremio add-ons." | |
| 1388 | msgstr "Habilitar o deshabilitar los complementos de Stremio." | |
| 1389 | ||
| 1390 | msgctxt "#30873" | |
| 1391 | msgid "Select language for player subtitle selection" | |
| 1392 | msgstr "" | |
| 1393 | ||
| 1394 | msgctxt "#30874" | |
| 1395 | msgid "Update Addons" | |
| 1396 | msgstr "" | |
| 1397 | ||
| 1398 | msgctxt "#30875" | |
| 1399 | msgid "Select Catalogs Addons" | |
| 1400 | msgstr "" | |
| 1401 | ||
| 1402 | msgctxt "#30876" | |
| 1403 | msgid "Download directory" | |
| 1404 | msgstr "" | |
| 1405 | ||
| 1406 | msgctxt "#30877" | |
| 1407 | msgid "Enable Trakt.tv Scrobbling" | |
| 1408 | msgstr "" | |
| 1409 | ||
| 1410 | msgctxt "#30878" | |
| 1411 | msgid "Auto select subtitle when playing" | |
| 1412 | msgstr "" | |
| 1413 | ||
| 1414 | msgctxt "#30879" | |
| 1415 | msgid "Auto-play quality" | |
| 1416 | msgstr "" | |
| 1417 | ||
| 1418 | msgctxt "#30880" | |
| 1419 | msgid "1080p" | |
| 1420 | msgstr "" | |
| 1421 | ||
| 1422 | msgctxt "#30881" | |
| 1423 | msgid "720p" | |
| 1424 | msgstr "" | |
| 1425 | ||
| 1426 | msgctxt "#30882" | |
| 1427 | msgid "4k" | |
| 1428 | msgstr "" | |
| 1429 | ||
| 1430 | msgctxt "#30883" | |
| 1431 | msgid "480p" | |
| 1432 | msgstr "" | |
| 1433 | ||
| 1434 | msgctxt "#30884" | |
| 1435 | msgid "Subtitles Configuration" | |
| 1436 | msgstr "" | |
| 1437 | ||
| 1438 | msgctxt "#30885" | |
| 1439 | msgid "Addon Host" | |
| 1440 | msgstr "" | |
| 1441 | ||
| 1442 | msgctxt "#30886" | |
| 1443 | msgid "Subtitles" | |
| 1444 | msgstr "" | |
| 1445 | ||
| 1446 | msgctxt "#30887" | |
| 1447 | msgid "Primary Language" | |
| 1448 | msgstr "" | |
| 1449 | ||
| 1450 | msgctxt "#30888" | |
| 1451 | msgid "Secondary Language" | |
| 1452 | msgstr "" | |
| 1453 | ||
| 1454 | msgctxt "#30889" | |
| 1455 | msgid "Deepl Enabled" | |
| 1456 | msgstr "" | |
| 1457 | ||
| 1458 | msgctxt "#30890" | |
| 1459 | msgid "Deepl Client Key" | |
| 1460 | msgstr "" | |
| 1461 | ||
| 1462 | msgctxt "#30891" | |
| 1463 | msgid "Translator Configuration" | |
| 1464 | msgstr "" | |
| 1465 | ||
| 1466 | msgctxt "#30892" | |
| 1467 | msgid "Deepl Target Language" | |
| 1468 | msgstr "" | |
| 1469 | ||
| 1470 | msgctxt "#30893" | |
| 1471 | msgid "Stremio Subtitle Configuration" | |
| 1472 | msgstr "" | |
| 1473 | ||
| 1474 | msgctxt "#90001" | |
| 1475 | msgid "Add Custom Addon" | |
| 1476 | msgstr "Add Custom Addon" | |
| 1477 | ||
| 1478 | msgctxt "#90002" | |
| 1479 | msgid "Add a custom Stremio addon by entering its URL" | |
| 1480 | msgstr "Agrega un complemento personalizado de Stremio ingresando su URL" | |
| 1481 | ||
| 1482 | msgctxt "#90003" | |
| 1483 | msgid "Auto-select audio when playing" | |
| 1484 | msgstr "" | |
| 1485 | ||
| 1486 | msgctxt "#90004" | |
| 1487 | msgid "Audio Language" | |
| 1488 | msgstr "" | |
| 1489 | ||
| 1490 | msgctxt "#90005" | |
| 1491 | msgid "Select audio language for playing the video" | |
| 1492 | msgstr "" | |
| 1493 | ||
| 1494 | msgctxt "#90006" | |
| 1495 | msgid "Search" | |
| 1496 | msgstr "" | |
| 1497 | ||
| 1498 | msgctxt "#90007" | |
| 1499 | msgid "TV Shows" | |
| 1500 | msgstr "" | |
| 1501 | ||
| 1502 | msgctxt "#90008" | |
| 1503 | msgid "Movies" | |
| 1504 | msgstr "" | |
| 1505 | ||
| 1506 | msgctxt "#90009" | |
| 1507 | msgid "Anime" | |
| 1508 | msgstr "" | |
| 1509 | ||
| 1510 | msgctxt "#90010" | |
| 1511 | msgid "Live TV" | |
| 1512 | msgstr "" | |
| 1513 | ||
| 1514 | msgctxt "#90011" | |
| 1515 | msgid "Direct Search" | |
| 1516 | msgstr "" | |
| 1517 | ||
| 1518 | msgctxt "#90012" | |
| 1519 | msgid "Torrents" | |
| 1520 | msgstr "" | |
| 1521 | ||
| 1522 | msgctxt "#90013" | |
| 1523 | msgid "Telegram" | |
| 1524 | msgstr "" | |
| 1525 | ||
| 1526 | msgctxt "#90014" | |
| 1527 | msgid "Cloud" | |
| 1528 | msgstr "" | |
| 1529 | ||
| 1530 | msgctxt "#90015" | |
| 1531 | msgid "Downloads" | |
| 1532 | msgstr "" | |
| 1533 | ||
| 1534 | msgctxt "#90016" | |
| 1535 | msgid "Settings" | |
| 1536 | msgstr "" | |
| 1537 | ||
| 1538 | msgctxt "#90017" | |
| 1539 | msgid "History" | |
| 1540 | msgstr "" | |
| 1541 | ||
| 1542 | msgctxt "#90018" | |
| 1543 | msgid "Donate" | |
| 1544 | msgstr "" | |
| 1545 | ||
| 1546 | msgctxt "#90019" | |
| 1547 | msgid "Recently Watched Files" | |
| 1548 | msgstr "" | |
| 1549 | ||
| 1550 | msgctxt "#90020" | |
| 1551 | msgid "Recently Watched Titles" | |
| 1552 | msgstr "" | |
| 1553 | ||
| 1554 | msgctxt "#90021" | |
| 1555 | msgid "TV Calendar" | |
| 1556 | msgstr "" | |
| 1557 | ||
| 1558 | ||
| 1559 | msgctxt "#90022" | |
| 1560 | msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:" | |
| 1561 | msgstr "" | |
| 1562 | ||
| 1563 | msgctxt "#90023" | |
| 1564 | msgid "Support Jacktook" | |
| 1565 | msgstr "" | |
| 1566 | ||
| 1567 | msgctxt "#90024" | |
| 1568 | msgid "Trending Recent" | |
| 1569 | msgstr "" | |
| 1570 | ||
| 1571 | msgctxt "#90025" | |
| 1572 | msgid "Genres" | |
| 1573 | msgstr "" | |
| 1574 | ||
| 1575 | msgctxt "#90026" | |
| 1576 | msgid "Calendar" | |
| 1577 | msgstr "" | |
| 1578 | ||
| 1579 | msgctxt "#90027" | |
| 1580 | msgid "Years" | |
| 1581 | msgstr "" | |
| 1582 | ||
| 1583 | msgctxt "#90028" | |
| 1584 | msgid "Trending" | |
| 1585 | msgstr "" | |
| 1586 | ||
| 1587 | msgctxt "#90029" | |
| 1588 | msgid "Most Watched" | |
| 1589 | msgstr "" | |
| 1590 | ||
| 1591 | msgctxt "#90030" | |
| 1592 | msgid "Most Favorited" | |
| 1593 | msgstr "" | |
| 1594 | ||
| 1595 | msgctxt "#90031" | |
| 1596 | msgid "Popular Lists" | |
| 1597 | msgstr "" | |
| 1598 | ||
| 1599 | msgctxt "#90032" | |
| 1600 | msgid "Trending Lists" | |
| 1601 | msgstr "" | |
| 1602 | ||
| 1603 | msgctxt "#90033" | |
| 1604 | msgid "Recomendations" | |
| 1605 | msgstr "" | |
| 1606 | ||
| 1607 | msgctxt "#90034" | |
| 1608 | msgid "Watched History" | |
| 1609 | msgstr "" | |
| 1610 | ||
| 1611 | msgctxt "#90035" | |
| 1612 | msgid "Watchlist" | |
| 1613 | msgstr "" | |
| 1614 | ||
| 1615 | msgctxt "#90036" | |
| 1616 | msgid "Top 10 BoxOffice" | |
| 1617 | msgstr "" | |
| 1618 | ||
| 1619 | msgctxt "#90037" | |
| 1620 | msgid "Popular" | |
| 1621 | msgstr "" | |
| 1622 | ||
| 1623 | msgctxt "#90038" | |
| 1624 | msgid "Popular Recent" | |
| 1625 | msgstr "" | |
| 1626 | ||
| 1627 | msgctxt "#90039" | |
| 1628 | msgid "Airing" | |
| 1629 | msgstr "" | |
| 1630 | ||
| 1631 | msgctxt "#90040" | |
| 1632 | msgid "Years" | |
| 1633 | msgstr "" | |
| 1634 | ||
| 1635 | msgctxt "#90041" | |
| 1636 | msgid "Genres" | |
| 1637 | msgstr "" | |
| 1638 | ||
| 1639 | msgctxt "#90042" | |
| 1640 | msgid "Top Rated" | |
| 1641 | msgstr "" | |
| 1642 | ||
| 1643 | msgctxt "#90043" | |
| 1644 | msgid "Most Watched" | |
| 1645 | msgstr "" | |
| 1646 | ||
| 1647 | msgctxt "#90044" | |
| 1648 | msgid "Torrentio Configuration" | |
| 1649 | msgstr "" | |
| 1650 | ||
| 1651 | msgctxt "#90045" | |
| 1652 | msgid "Enable Torrentio Settings" | |
| 1653 | msgstr "" | |
| 1654 | ||
| 1655 | msgctxt "#90046" | |
| 1656 | msgid "Enable or disable the Torrentio Configuration" | |
| 1657 | msgstr "" | |
| 1658 | ||
| 1659 | msgctxt "#90047" | |
| 1660 | msgid "Select Torrentio Providers" | |
| 1661 | msgstr "" | |
| 1662 | ||
| 1663 | msgctxt "#90048" | |
| 1664 | msgid "Choose which providers to include in the stream request." | |
| 1665 | msgstr "" | |
| 1666 | ||
| 1667 | # Context menus | |
| 1668 | ||
| 1669 | msgctxt "#90049" | |
| 1670 | msgid "Scrape again" | |
| 1671 | msgstr "" | |
| 1672 | ||
| 1673 | msgctxt "#90050" | |
| 1674 | msgid "Recommendations" | |
| 1675 | msgstr "" | |
| 1676 | ||
| 1677 | msgctxt "#90051" | |
| 1678 | msgid "Similar" | |
| 1679 | msgstr "" | |
| 1680 | ||
| 1681 | msgctxt "#90052" | |
| 1682 | msgid "Thread Number" | |
| 1683 | msgstr "" | |
| 1684 | ||
| 1685 | msgctxt "#90053" | |
| 1686 | msgid "Select the number of threads (1-30)" | |
| 1687 | msgstr "" | |
| 1688 | ||
| 1689 | msgctxt "#90054" | |
| 1690 | msgid "Kodi logs" | |
| 1691 | msgstr "" | |
| 1692 | ||
| 1693 | msgctxt "#90055" | |
| 1694 | msgid "Kodi old logs" | |
| 1695 | msgstr "" | |
| 1696 | ||
| 1697 | msgctxt "#90056" | |
| 1698 | msgid "Displays Kodi logs to help diagnose issues." | |
| 1699 | msgstr "" | |
| 1700 | ||
| 1701 | msgctxt "#90057" | |
| 1702 | msgid "Logs" | |
| 1703 | msgstr "" | |
| 1704 | ||
| 1705 | msgctxt "#90058" | |
| 1706 | msgid "Filter by Size" | |
| 1707 | msgstr "" | |
| 1708 | ||
| 1709 | msgctxt "#90059" | |
| 1710 | msgid "Enable Size Filter" | |
| 1711 | msgstr "" | |
| 1712 | ||
| 1713 | msgctxt "#90060" | |
| 1714 | msgid "Minimum Size (MB)" | |
| 1715 | msgstr "" | |
| 1716 | ||
| 1717 | msgctxt "#90061" | |
| 1718 | msgid "Maximum Size (MB)" | |
| 1719 | msgstr "" | |
| 1720 | ||
| 1721 | msgctxt "#90062" | |
| 1722 | msgid "Enable Cached Search" | |
| 1723 | msgstr "" | |
| 1724 | ||
| 1725 | msgctxt "#90063" | |
| 1726 | msgid "Only show cached streams from external services" | |
| 1727 | msgstr "" | |
| 1728 | ||
| 1729 | msgctxt "#90064" | |
| 1730 | msgid "Popular" | |
| 1731 | msgstr "" | |
| 1732 | ||
| 1733 | msgctxt "#90065" | |
| 1734 | msgid "Languages" | |
| 1735 | msgstr "" | |
| 1736 | ||
| 1737 | msgctxt "#90066" | |
| 1738 | msgid "Networks" | |
| 1739 | msgstr "" | |
| 1740 | ||
| 1741 | msgctxt "#90067" | |
| 1742 | msgid "Collections" | |
| 1743 | msgstr "" | |
| 1744 | ||
| 1745 | msgctxt "#90068" | |
| 1746 | msgid "Search for collections:" | |
| 1747 | msgstr "" | |
| 1748 | ||
| 1749 | msgctxt "#90069" | |
| 1750 | msgid "Main Menu" | |
| 1751 | msgstr "" | |
| 1752 | ||
| 1753 | msgctxt "#90070" | |
| 1754 | msgid "Last Titles" | |
| 1755 | msgstr "" | |
| 1756 | ||
| 1757 | msgctxt "#90071" | |
| 1758 | msgid "Last Files" | |
| 1759 | msgstr "" | |
| 1760 | ||
| 1761 | msgctxt "#90072" | |
| 1762 | msgid "Trakt Lists" | |
| 1763 | msgstr "" | |
| 1764 | ||
| 1765 | msgctxt "#90073" | |
| 1766 | msgid "Metadata" | |
| 1767 | msgstr "" | |
| 1768 | ||
| 1769 | msgctxt "#90074" | |
| 1770 | msgid "Top Lists" | |
| 1771 | msgstr "" | |
| 1772 | ||
| 1773 | msgctxt "#90075" | |
| 1774 | msgid "User Lists" | |
| 1775 | msgstr "" | |
| 1776 | ||
| 1777 | msgctxt "#90076" | |
| 1778 | msgid "Search Lists" | |
| 1779 | msgstr "" | |
| 1780 | ||
| 1781 | msgctxt "#90077" | |
| 1782 | msgid "MDbList Configuration" | |
| 1783 | msgstr "" | |
| 1784 | ||
| 1785 | msgctxt "#90078" | |
| 1786 | msgid "People" | |
| 1787 | msgstr "" | |
| 1788 | ||
| 1789 | msgctxt "#90079" | |
| 1790 | msgid "Popular People" | |
| 1791 | msgstr "" | |
| 1792 | ||
| 1793 | msgctxt "#90080" | |
| 1794 | msgid "Latest People" | |
| 1795 | msgstr "" | |
| 1796 | ||
| 1797 | msgctxt "#90081" | |
| 1798 | msgid "Search People" | |
| 1799 | msgstr "" | |
| 1800 | ||
| 1801 | msgctxt "#90082" | |
| 1802 | msgid "Download subtitles" | |
| 1803 | msgstr "" | |
| 1804 | ||
| 1805 | msgctxt "#90083" | |
| 1806 | msgid "Download video" | |
| 1807 | msgstr "" | |
| 1808 | ||
| 1809 | msgctxt "#90084" | |
| 1810 | msgid "Browse into" | |
| 1811 | msgstr "" | |
| 1812 | ||
| 1813 | msgctxt "#90085" | |
| 1814 | msgid "Include TV show Specials" | |
| 1815 | msgstr "" | |
| 1816 | ||
| 1817 | msgctxt "#90086" | |
| 1818 | msgid "Airing Today" | |
| 1819 | msgstr "" | |
| 1820 | ||
| 1821 | msgctxt "#90099" | |
| 1822 | msgid "Filter by Source Type" | |
| 1823 | msgstr "" | |
| 1824 | ||
| 1825 | # BluRay/UHD | |
| 1826 | msgctxt "#90100" | |
| 1827 | msgid "Include BluRay/UHD sources" | |
| 1828 | msgstr "" | |
| 1829 | ||
| 1830 | msgctxt "#90105" | |
| 1831 | msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1832 | msgstr "" | |
| 1833 | ||
| 1834 | # WEB/HD | |
| 1835 | msgctxt "#90101" | |
| 1836 | msgid "Include WEB/HD sources" | |
| 1837 | msgstr "" | |
| 1838 | ||
| 1839 | msgctxt "#90106" | |
| 1840 | msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1841 | msgstr "" | |
| 1842 | ||
| 1843 | # DVD/TV/SAT | |
| 1844 | msgctxt "#90102" | |
| 1845 | msgid "Include DVD/TV/SAT sources" | |
| 1846 | msgstr "" | |
| 1847 | ||
| 1848 | msgctxt "#90107" | |
| 1849 | msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1850 | msgstr "" | |
| 1851 | ||
| 1852 | # CAM/Screener | |
| 1853 | msgctxt "#90103" | |
| 1854 | msgid "Include CAM/Screener sources" | |
| 1855 | msgstr "" | |
| 1856 | ||
| 1857 | msgctxt "#90108" | |
| 1858 | msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR" | |
| 1859 | msgstr "" | |
| 1860 | ||
| 1861 | # Unknown | |
| 1862 | msgctxt "#90104" | |
| 1863 | msgid "Include Unknown sources" | |
| 1864 | msgstr "" | |
| 1865 | ||
| 1866 | msgctxt "#90109" | |
| 1867 | msgid "Includes uncategorized or unidentified sources" | |
| 1868 | msgstr "" | |
| 1869 | ||
| 1870 | msgctxt "#90110" | |
| 1871 | msgid "Do you want to clear your watched history?." | |
| 1872 | msgstr "" | |
| 1873 | ||
| 1874 | msgctxt "#90111" | |
| 1875 | msgid "Do you want to clear your history?." | |
| 1876 | msgstr "" | |
| 1877 | ||
| 1878 | msgctxt "#90112" | |
| 1879 | msgid "Clear History" | |
| 1880 | msgstr "" | |
| 1881 | ||
| 1882 | msgctxt "#90113" | |
| 1883 | msgid "Click to delete all watched history immediately." | |
| 1884 | msgstr "" | |
| 1885 | ||
| 1886 | msgctxt "#90114" | |
| 1887 | msgid "History cleared" | |
| 1888 | msgstr "" | |
| 1889 | ||
| 1890 | msgctxt "#90115" | |
| 1891 | msgid "Source Select" | |
| 1892 | msgstr "" | |
| 1893 | ||
| 1894 | msgctxt "#90116" | |
| 1895 | msgid "Open Settings" | |
| 1896 | msgstr "" | |
| 1897 | ||
| 1898 | msgctxt "#90117" | |
| 1899 | msgid "Select Torrentio Providers" | |
| 1900 | msgstr "" | |
| 1901 | ||
| 1902 | msgctxt "#90118" | |
| 1903 | msgid "Port" | |
| 1904 | msgstr "" | |
| 1905 | ||
| 1906 | msgctxt "#90119" | |
| 1907 | msgid "Jackett port (default:9117)" | |
| 1908 | msgstr "" | |
| 1909 | ||
| 1910 | msgctxt "#90120" | |
| 1911 | msgid "Prowlarr port (default:9696)" | |
| 1912 | msgstr "" | |
| 1913 | ||
| 1914 | msgctxt "#90121" | |
| 1915 | msgid "All Debrid Configuration" | |
| 1916 | msgstr "" | |
| 1917 | ||
| 1918 | msgctxt "#90122" | |
| 1919 | msgid "Auto download subtitles" | |
| 1920 | msgstr "" | |
| 1921 | ||
| 1922 | msgctxt "#90123" | |
| 1923 | msgid "Stremio Timeout" | |
| 1924 | msgstr "" | |
| 1925 | ||
| 1926 | msgctxt "#90124" | |
| 1927 | msgid "Webdav Client Configuration" | |
| 1928 | msgstr "" | |
| 1929 | ||
| 1930 | msgctxt "#90126" | |
| 1931 | msgid "Host" | |
| 1932 | msgstr "" | |
| 1933 | ||
| 1934 | msgctxt "#90127" | |
| 1935 | msgid "Username" | |
| 1936 | msgstr "" | |
| 1937 | ||
| 1938 | msgctxt "#90128" | |
| 1939 | msgid "Password" | |
| 1940 | msgstr "" | |
| 1941 | ||
| 1942 | msgctxt "#90129" | |
| 1943 | msgid "Test Connection" | |
| 1944 | msgstr "" | |
| 1945 | ||
| 1946 | msgctxt "#90131" | |
| 1947 | msgid "Webdav" | |
| 1948 | msgstr "" | |
| 1949 | ||
| 1950 | msgctxt "#90132" | |
| 1951 | msgid "Remote Path" | |
| 1952 | msgstr "" | |
| 1953 | ||
| 1954 | msgctxt "#90133" | |
| 1955 | msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)." | |
| 1956 | msgstr "" | |
| 1957 | ||
| 1958 | msgctxt "#90134" | |
| 1959 | msgid "The username required for authenticating with your WebDAV server" | |
| 1960 | msgstr "" | |
| 1961 | ||
| 1962 | msgctxt "#90135" | |
| 1963 | msgid "The password for the specified username to authenticate with your WebDAV server" | |
| 1964 | msgstr "" | |
| 1965 | ||
| 1966 | msgctxt "#90136" | |
| 1967 | msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)." | |
| 1968 | msgstr "" | |
| 1969 | ||
| 1970 | msgctxt "#90137" | |
| 1971 | msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials." | |
| 1972 | msgstr "" | |
| 1973 | ||
| 1974 | msgctxt "#90138" | |
| 1975 | msgid "Remove Custom Addon" | |
| 1976 | msgstr "Remove Custom Addon" | |
| 1977 | ||
| 1978 | msgctxt "#90139" | |
| 1979 | msgid "Playback" | |
| 1980 | msgstr "Playback" | |
| 1981 | ||
| 1982 | msgctxt "#90140" | |
| 1983 | msgid "Super quick play" | |
| 1984 | msgstr "" | |
| 1985 | ||
| 1986 | msgctxt "#90141" | |
| 1987 | msgid "Re-ask to play last played video" | |
| 1988 | msgstr "" | |
| 1989 | ||
| 1990 | msgctxt "#90142" | |
| 1991 | msgid "Replay Dialog" | |
| 1992 | msgstr "Replay Dialog" | |
| 1993 | ||
| 1994 | msgctxt "#90143" | |
| 1995 | msgid "Do you want to play last watched?" | |
| 1996 | msgstr "Do you want to play last watched?" | |
| 1997 | ||
| 1998 | msgctxt "#90144" | |
| 1999 | msgid "Could not resolve playback URL" | |
| 2000 | msgstr "Could not resolve playback URL" | |
| 2001 | ||
| 2002 | msgctxt "#90145" | |
| 2003 | msgid "Precaching Enabled" | |
| 2004 | msgstr "Precaching Enabled" | |
| 2005 | ||
| 2006 | msgctxt "#90146" | |
| 2007 | msgid "Enable or disable precaching of next episodes." | |
| 2008 | msgstr "Enable or disable precaching of next episodes." | |
| 2009 | ||
| 2010 | msgctxt "#90147" | |
| 2011 | msgid "Precaching Episode Count" | |
| 2012 | msgstr "Precaching Episode Count" | |
| 2013 | ||
| 2014 | msgctxt "#90148" | |
| 2015 | msgid "Number of next episodes to precache." | |
| 2016 | msgstr "Number of next episodes to precache." | |
| 2017 | ||
| 2018 | msgctxt "#30901" | |
| 2019 | msgid "Show Only Stremio Catalogs" | |
| 2020 | msgstr "" | |
| 2021 | ||
| 2022 | msgctxt "#30902" | |
| 2023 | msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus." | |
| 2024 | msgstr "" | |
| 2025 | ||
| 2026 | msgctxt "#30903" | |
| 2027 | msgid "Select Live TV Addons" | |
| 2028 | msgstr "" | |
| 2029 | ||
| 2030 | msgctxt "#30904" | |
| 2031 | msgid "Choose specific Stremio addons for Live TV content" | |
| 2032 | msgstr "" | |
| 2033 | ||
| 2034 | msgctxt "#30920" | |
| 2035 | msgid "Clear Trakt Cache" | |
| 2036 | msgstr "Clear Trakt Cache" | |
| 2037 | ||
| 2038 | msgctxt "#30921" | |
| 2039 | msgid "Clear TMDB Cache" | |
| 2040 | msgstr "Clear TMDB Cache" | |
| 2041 | ||
| 2042 | msgctxt "#30922" | |
| 2043 | msgid "Clear Providers Cache" | |
| 2044 | msgstr "Clear Providers Cache" | |
| 2045 | ||
| 2046 | msgctxt "#30923" | |
| 2047 | msgid "Clear Settings Cache" | |
| 2048 | msgstr "Clear Settings Cache" | |
| 2049 | ||
| 2050 | msgctxt "#90149" | |
| 2051 | msgid "Maintenance" | |
| 2052 | msgstr "Maintenance" | |
| 2053 | ||
| 2054 | msgctxt "#90150" | |
| 2055 | msgid "Stremio: General" | |
| 2056 | msgstr "Stremio: General" | |
| 2057 | ||
| 2058 | msgctxt "#90151" | |
| 2059 | msgid "Stremio: Account" | |
| 2060 | msgstr "Stremio: Account" | |
| 2061 | ||
| 2062 | msgctxt "#90152" | |
| 2063 | msgid "Stremio: Addons & Catalogs" | |
| 2064 | msgstr "Stremio: Addons & Catalogs" | |
| 2065 | ||
| 2066 | msgctxt "#90153" | |
| 2067 | msgid "Stremio: Torrentio" | |
| 2068 | msgstr "Stremio: Torrentio" | |
| 2069 | ||
| 2070 | msgctxt "#90154" | |
| 2071 | msgid "Trending Recent" | |
| 2072 | msgstr "" | |
| 2073 | ||
| 2074 | msgctxt "#90155" | |
| 2075 | msgid "Stremio: Filters" | |
| 2076 | msgstr "Stremio: Filters" | |
| 2077 | ||
| 2078 | msgctxt "#32300" | |
| 2079 | msgid "Filter by Quality" | |
| 2080 | msgstr "" | |
| 2081 | ||
| 2082 | msgctxt "#32301" | |
| 2083 | msgid "Filter by Provider" | |
| 2084 | msgstr "" | |
| 2085 | ||
| 2086 | msgctxt "#32302" | |
| 2087 | msgid "Filter by Source" | |
| 2088 | msgstr "" | |
| 2089 | ||
| 2090 | msgctxt "#32303" | |
| 2091 | msgid "Filter by Language" | |
| 2092 | msgstr "" | |
| 2093 | ||
| 2094 | msgctxt "#32304" | |
| 2095 | msgid "Filter by Torrents" | |
| 2096 | msgstr "" | |
| 2097 | ||
| 2098 | msgctxt "#32305" | |
| 2099 | msgid "Filter by Debrid" | |
| 2100 | msgstr "" | |
| 2101 | ||
| 2102 | msgctxt "#32306" | |
| 2103 | msgid "Reset Filter" | |
| 2104 | msgstr "" | |
| 2105 | ||
| 2106 | msgctxt "#30930" | |
| 2107 | msgid "Ping Addons" | |
| 2108 | msgstr "Ping Addons" | |
| 2109 | ||
| 2110 | msgctxt "#30931" | |
| 2111 | msgid "Check availability of addons and show only reachable ones" | |
| 2112 | msgstr "Check availability of addons and show only reachable ones" | |
| 2113 | ||
| 2114 | msgctxt "#90164" | |
| 2115 | msgid "Skip Intro / Recap" | |
| 2116 | msgstr "Skip Intro / Recap" | |
| 2117 | ||
| 2118 | msgctxt "#90165" | |
| 2119 | msgid "Enable Skip Intro" | |
| 2120 | msgstr "Enable Skip Intro" | |
| 2121 | ||
| 2122 | msgctxt "#90166" | |
| 2123 | msgid "Detect intros and recaps using IntroDB and offer to skip them during playback" | |
| 2124 | msgstr "Detect intros and recaps using IntroDB and offer to skip them during playback" | |
| 2125 | ||
| 2126 | msgctxt "#90167" | |
| 2127 | msgid "Auto-skip" | |
| 2128 | msgstr "Auto-skip" | |
| 2129 | ||
| 2130 | msgctxt "#90168" | |
| 2131 | msgid "Automatically skip intros and recaps without showing the button" | |
| 2132 | msgstr "Automatically skip intros and recaps without showing the button" | |
| 2133 | ||
| 2134 | msgctxt "#90160" | |
| 2135 | msgid "Skip Intro" | |
| 2136 | msgstr "Skip Intro" | |
| 2137 | ||
| 2138 | msgctxt "#90161" | |
| 2139 | msgid "Skip Recap" | |
| 2140 | msgstr "Skip Recap" | |
| 2141 | ||
| 2142 | msgctxt "#90162" | |
| 2143 | msgid "Manage from Phone" | |
| 2144 | msgstr "Manage from Phone" | |
| 2145 | ||
| 2146 | msgctxt "#90163" | |
| 2147 | msgid "Start a local web server and show a QR code to manage Stremio sources from your phone" | |
| 2148 | msgstr "Start a local web server and show a QR code to manage Stremio sources from your phone" | |
| 2149 | ||
| 2150 | msgctxt "#90200" | |
| 2151 | msgid "Continue Watching" | |
| 2152 | msgstr "Continue Watching" | |
| 2153 | ||
| 2154 | msgctxt "#90201" | |
| 2155 | msgid "Library" | |
| 2156 | msgstr "Library" | |
| 2157 | ||
| 2158 | msgctxt "#90202" | |
| 2159 | msgid "My Shows" | |
| 2160 | msgstr "My Shows" | |
| 2161 | ||
| 2162 | msgctxt "#90203" | |
| 2163 | msgid "My Movies" | |
| 2164 | msgstr "My Movies" | |
| 2165 | ||
| 2166 | msgctxt "#90204" | |
| 2167 | msgid "Remove from Library" | |
| 2168 | msgstr "Remove from Library" | |
| 2169 | ||
| 2170 | msgctxt "#90205" | |
| 2171 | msgid "Add to Library" | |
| 2172 | msgstr "Add to Library" | |
| 2173 | ||
| 2174 | msgctxt "#90206" | |
| 2175 | msgid "Remove" | |
| 2176 | msgstr "Remove" | |
| 2177 | ||
| 2178 | msgctxt "#90207" | |
| 2179 | msgid "Search Movies & TV" | |
| 2180 | msgstr "" | |
| 2181 | ||
| 2182 | msgctxt "#90208" | |
| 2183 | msgid "Recent Searches" | |
| 2184 | msgstr "" | |
| 2185 | ||
| 2186 | msgctxt "#90209" | |
| 2187 | msgid "Recent Direct Searches" | |
| 2188 | msgstr "" | |
| 2189 | ||
| 2190 | msgctxt "#90210" | |
| 2191 | msgid "Clear All Search History" | |
| 2192 | msgstr "" | |
| 2193 | ||
| 2194 | msgctxt "#90211" | |
| 2195 | msgid "Bypass Stremio Addons Native Sorting" | |
| 2196 | msgstr "" | |
| 2197 | ||
| 2198 | msgctxt "#90212" | |
| 2199 | msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds." | |
| 2200 | msgstr "" | |
| 2201 | ||
| 2202 | msgctxt "#90213" | |
| 2203 | msgid "Select Bypassed Addons" | |
| 2204 | msgstr "" | |
| 2205 | ||
| 2206 | msgctxt "#90214" | |
| 2207 | msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering." | |
| 2208 | msgstr "" | |
| 2209 | ||
| 2210 | msgctxt "#90215" | |
| 2211 | msgid "Downgrade" | |
| 2212 | msgstr "" | |
| 2213 | ||
| 2214 | msgctxt "#90671" | |
| 2215 | msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails." | |
| 2216 | msgstr "" | |
| 2217 | ||
| 2218 | msgctxt "#30932" | |
| 2219 | msgid "Update Action" | |
| 2220 | msgstr "" | |
| 2221 | ||
| 2222 | msgctxt "#30933" | |
| 2223 | msgid "Choose how to handle background update checks." | |
| 2224 | msgstr "" | |
| 2225 | ||
| 2226 | msgctxt "#30934" | |
| 2227 | msgid "Ask" | |
| 2228 | msgstr "" | |
| 2229 | ||
| 2230 | msgctxt "#30935" | |
| 2231 | msgid "Notify" | |
| 2232 | msgstr "" | |
| 2233 | ||
| 2234 | msgctxt "#90216" | |
| 2235 | msgid "Volume Check Enabled" | |
| 2236 | msgstr "Volume Check Enabled" | |
| 2237 | ||
| 2238 | msgctxt "#90217" | |
| 2239 | msgid "Automatically limits volume if it is above the threshold before playback." | |
| 2240 | msgstr "Automatically limits volume if it is above the threshold before playback." | |
| 2241 | ||
| 2242 | msgctxt "#90218" | |
| 2243 | msgid "Volume Threshold" | |
| 2244 | msgstr "Volume Threshold" | |
| 2245 | ||
| 2246 | msgctxt "#90219" | |
| 2247 | msgid "The volume percentage above which the warning triggers." | |
| 2248 | msgstr "The volume percentage above which the warning triggers." | |
| 2249 | ||
| 2250 | msgctxt "#90220" | |
| 2251 | msgid "High Volume Warning" | |
| 2252 | msgstr "High Volume Warning" | |
| 2253 | ||
| 2254 | msgctxt "#90221" | |
| 2255 | msgid "High volume detected. Adjusting to %s%% for safety." | |
| 2256 | msgstr "High volume detected. Adjusting to %s%% for safety." | |
| 2257 | ||
| 2258 | msgctxt "#90222" | |
| 2259 | msgid "Fanart.tv Configuration" | |
| 2260 | msgstr "" | |
| 2261 | ||
| 2262 | msgctxt "#90223" | |
| 2263 | msgid "Client Key" | |
| 2264 | msgstr "" | |
| 2265 | ||
| 2266 | msgctxt "#90224" | |
| 2267 | msgid "Your personal Fanart.tv client key for higher quality assets." | |
| 2268 | msgstr "" | |
| 2269 | ||
| 2270 | msgctxt "#90225" | |
| 2271 | msgid "Debrid Notifications" | |
| 2272 | msgstr "" | |
| 2273 | ||
| 2274 | msgctxt "#90226" | |
| 2275 | msgid "Debrid Expiration Notification" | |
| 2276 | msgstr "" | |
| 2277 | ||
| 2278 | msgctxt "#90227" | |
| 2279 | msgid "Enable or disable notifications when your Debrid subscription is about to expire." | |
| 2280 | msgstr "" | |
| 2281 | ||
| 2282 | msgctxt "#90228" | |
| 2283 | msgid "Expiration Threshold (Days)" | |
| 2284 | msgstr "" | |
| 2285 | ||
| 2286 | msgctxt "#90229" | |
| 2287 | msgid "How many days before expiration to start notifying." | |
| 2288 | msgstr "" | |
| 2289 | ||
| 2290 | msgctxt "#90230" | |
| 2291 | msgid "%s expires in %d days." | |
| 2292 | msgstr "" | |
| 2293 | ||
| 2294 | msgctxt "#90231" | |
| 2295 | msgid "%s expires today!" | |
| 2296 | msgstr "" | |
| 2297 | ||
| 2298 | msgctxt "#90232" | |
| 2299 | msgid "Easynews" | |
| 2300 | msgstr "" | |
| 2301 | ||
| 2302 | msgctxt "#90233" | |
| 2303 | msgid "Enable EasyNews as a direct stream provider." | |
| 2304 | msgstr "" | |
| 2305 | ||
| 2306 | msgctxt "#90234" | |
| 2307 | msgid "Your EasyNews username." | |
| 2308 | msgstr "" | |
| 2309 | ||
| 2310 | msgctxt "#90235" | |
| 2311 | msgid "Your EasyNews password." | |
| 2312 | msgstr "" | |
| 2313 | ||
| 2314 | msgctxt "#90236" | |
| 2315 | msgid "Request timeout in seconds." | |
| 2316 | msgstr "" | |
| 2317 | ||
| 2318 | msgctxt "#90237" | |
| 2319 | msgid "Account Information" | |
| 2320 | msgstr "" | |
| 2321 | ||
| 2322 | msgctxt "#90238" | |
| 2323 | msgid "Search Dialog Style" | |
| 2324 | msgstr "Search Dialog Style" | |
| 2325 | ||
| 2326 | msgctxt "#90239" | |
| 2327 | msgid "Background" | |
| 2328 | msgstr "Background" | |
| 2329 | ||
| 2330 | msgctxt "#90240" | |
| 2331 | msgid "Detailed Status" | |
| 2332 | msgstr "Detailed Status" | |
| 2333 | ||
| 2334 | msgctxt "#90241" | |
| 2335 | msgid "Search Status" | |
| 2336 | msgstr "Search Status" | |
| 2337 | ||
| 2338 | msgctxt "#90242" | |
| 2339 | msgid "Cancel" | |
| 2340 | msgstr "Cancel" | |
| 2341 | ||
| 2342 | msgctxt "#90243" | |
| 2343 | msgid "Results found: " | |
| 2344 | msgstr "Results found: " | |
| 2345 | ||
| 2346 | msgctxt "#90244" | |
| 2347 | msgid "Pending" | |
| 2348 | msgstr "Pending" | |
| 2349 | ||
| 2350 | msgctxt "#90245" | |
| 2351 | msgid "In Progress" | |
| 2352 | msgstr "In Progress" | |
| 2353 | ||
| 2354 | msgctxt "#90246" | |
| 2355 | msgid "Completed" | |
| 2356 | msgstr "Completed" | |
| 2357 | ||
| 2358 | msgctxt "#90247" | |
| 2359 | msgid "Failed" | |
| 2360 | msgstr "Failed" | |
| 2361 | ||
| 2362 | msgctxt "#90248" | |
| 2363 | msgid "Cancelled" | |
| 2364 | msgstr "Cancelled" | |
| 2365 | ||
| 2366 | msgctxt "#90249" | |
| 2367 | msgid "Your personal Jackgram API token for authentication." | |
| 2368 | msgstr "Your personal Jackgram API token for authentication." | |
| 2369 | ||
| 2370 | msgctxt "#90250" | |
| 2371 | msgid "Subtitles Found" | |
| 2372 | msgstr "Subtitles Found" | |
| 2373 | ||
| 2374 | msgctxt "#90251" | |
| 2375 | msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?" | |
| 2376 | msgstr "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?" | |
| 2377 | ||
| 2378 | msgctxt "#90252" | |
| 2379 | msgid "No subtitles found" | |
| 2380 | msgstr "No subtitles found" | |
| 2381 | ||
| 2382 | msgctxt "#90253" | |
| 2383 | msgid "No subtitles selected" | |
| 2384 | msgstr "No subtitles selected" | |
| 2385 | ||
| 2386 | msgctxt "#90254" | |
| 2387 | msgid "Translate Subtitles" | |
| 2388 | msgstr "Translate Subtitles" | |
| 2389 | ||
| 2390 | msgctxt "#90255" | |
| 2391 | msgid "Do you want to also translate the selected subtitles?" | |
| 2392 | msgstr "Do you want to also translate the selected subtitles?" | |
| 2393 | ||
| 2394 | msgctxt "#90256" | |
| 2395 | msgid "Select Subtitle to Download" | |
| 2396 | msgstr "Select Subtitle to Download" | |
| 2397 | ||
| 2398 | msgctxt "#90257" | |
| 2399 | msgid "Subtitles Loaded" | |
| 2400 | msgstr "Subtitles Loaded" | |
| 2401 | ||
| 2402 | msgctxt "#90258" | |
| 2403 | msgid "Backup" | |
| 2404 | msgstr "Backup" | |
| 2405 | ||
| 2406 | msgctxt "#90259" | |
| 2407 | msgid "Export Backup" | |
| 2408 | msgstr "Export Backup" | |
| 2409 | ||
| 2410 | msgctxt "#90260" | |
| 2411 | msgid "Export addon settings and custom Stremio addons to a JSON backup file." | |
| 2412 | msgstr "Export addon settings and custom Stremio addons to a JSON backup file." | |
| 2413 | ||
| 2414 | msgctxt "#90261" | |
| 2415 | msgid "Restore Backup" | |
| 2416 | msgstr "Restore Backup" | |
| 2417 | ||
| 2418 | msgctxt "#90262" | |
| 2419 | msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file." | |
| 2420 | msgstr "Restore addon settings and replace custom Stremio addons from a JSON backup file." | |
| 2421 | ||
| 2422 | msgctxt "#90263" | |
| 2423 | msgid "Remove Sensitive Data" | |
| 2424 | msgstr "Remove Sensitive Data" | |
| 2425 | ||
| 2426 | msgctxt "#90264" | |
| 2427 | msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups." | |
| 2428 | msgstr "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups." | |
| 2429 | ||
| 2430 | msgctxt "#90265" | |
| 2431 | msgid "Settings backup exported" | |
| 2432 | msgstr "Settings backup exported" | |
| 2433 | ||
| 2434 | msgctxt "#90266" | |
| 2435 | msgid "Settings backup restored" | |
| 2436 | msgstr "Settings backup restored" | |
| 2437 | ||
| 2438 | msgctxt "#90267" | |
| 2439 | msgid "Settings Backup Failed" | |
| 2440 | msgstr "Settings Backup Failed" | |
| 2441 | ||
| 2442 | msgctxt "#90268" | |
| 2443 | msgid "Select export folder" | |
| 2444 | msgstr "Select export folder" | |
| 2445 | ||
| 2446 | msgctxt "#90269" | |
| 2447 | msgid "Select backup file" | |
| 2448 | msgstr "Select backup file" | |
| 2449 | ||
| 2450 | msgctxt "#90270" | |
| 2451 | msgid "Backup file is invalid." | |
| 2452 | msgstr "Backup file is invalid." | |
| 2453 | ||
| 2454 | msgctxt "#90271" | |
| 2455 | msgid "No export folder selected." | |
| 2456 | msgstr "No export folder selected." | |
| 2457 | ||
| 2458 | msgctxt "#90272" | |
| 2459 | msgid "No backup file selected." | |
| 2460 | msgstr "No backup file selected." | |
| 2461 | ||
| 2462 | msgctxt "#90273" | |
| 2463 | msgid "Unable to export settings backup." | |
| 2464 | msgstr "Unable to export settings backup." | |
| 2465 | ||
| 2466 | msgctxt "#90274" | |
| 2467 | msgid "Unable to restore settings backup." | |
| 2468 | msgstr "Unable to restore settings backup." | |
| 2469 | ||
| 2470 | msgctxt "#90275" | |
| 2471 | msgid "Restore Backup" | |
| 2472 | msgstr "Restore Backup" | |
| 2473 | ||
| 2474 | msgctxt "#90276" | |
| 2475 | msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?" | |
| 2476 | msgstr "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?" | |
| 2477 | ||
| 2478 | msgctxt "#90277" | |
| 2479 | msgid "Restore cancelled" | |
| 2480 | msgstr "Restore cancelled" | |
| 2481 | ||
| 2482 | msgctxt "#90278" | |
| 2483 | msgid "Reset All Settings" | |
| 2484 | msgstr "Reset All Settings" | |
| 2485 | ||
| 2486 | msgctxt "#90279" | |
| 2487 | msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?" | |
| 2488 | msgstr "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?" | |
| 2489 | ||
| 2490 | msgctxt "#90280" | |
| 2491 | msgid "Reset cancelled" | |
| 2492 | msgstr "Reset cancelled" | |
| 2493 | ||
| 2494 | msgctxt "#90281" | |
| 2495 | msgid "All addon settings reset" | |
| 2496 | msgstr "All addon settings reset" | |
| 2497 | ||
| 2498 | msgctxt "#90282" | |
| 2499 | msgid "Reset Failed" | |
| 2500 | msgstr "Reset Failed" | |
| 2501 | ||
| 2502 | msgctxt "#90283" | |
| 2503 | msgid "Unable to reset addon settings." | |
| 2504 | msgstr "Unable to reset addon settings." | |
| 2505 | ||
| 2506 | msgctxt "#90284" | |
| 2507 | msgid "Reset every addon setting back to its default value and clear custom Stremio addons." | |
| 2508 | msgstr "Reset every addon setting back to its default value and clear custom Stremio addons." | |
| 2509 | ||
| 2510 | msgctxt "#90285" | |
| 2511 | msgid "Factory Reset" | |
| 2512 | msgstr "Factory Reset" | |
| 2513 | ||
| 2514 | msgctxt "#90286" | |
| 2515 | msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?" | |
| 2516 | msgstr "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?" | |
| 2517 | ||
| 2518 | msgctxt "#90287" | |
| 2519 | msgid "Factory reset cancelled" | |
| 2520 | msgstr "Factory reset cancelled" | |
| 2521 | ||
| 2522 | msgctxt "#90288" | |
| 2523 | msgid "Factory reset completed" | |
| 2524 | msgstr "Factory reset completed" | |
| 2525 | ||
| 2526 | msgctxt "#90289" | |
| 2527 | msgid "Factory Reset Failed" | |
| 2528 | msgstr "Factory Reset Failed" | |
| 2529 | ||
| 2530 | msgctxt "#90290" | |
| 2531 | msgid "Unable to complete factory reset." | |
| 2532 | msgstr "Unable to complete factory reset." | |
| 2533 | ||
| 2534 | msgctxt "#90291" | |
| 2535 | msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data." | |
| 2536 | msgstr "Reset settings to defaults, clear caches, and wipe local addon history/database data." | |
| 2537 | ||
| 2538 | msgctxt "#90292" | |
| 2539 | msgid "Trakt Library" | |
| 2540 | msgstr "Trakt Library" | |
| 2541 | ||
| 2542 | msgctxt "#90293" | |
| 2543 | msgid "Trakt Discovery" | |
| 2544 | msgstr "Trakt Discovery" | |
| 2545 | ||
| 2546 | msgctxt "#90294" | |
| 2547 | msgid "Collection" | |
| 2548 | msgstr "Collection" | |
| 2549 | ||
| 2550 | msgctxt "#90295" | |
| 2551 | msgid "Favorites" | |
| 2552 | msgstr "Favorites" | |
| 2553 | ||
| 2554 | msgctxt "#90296" | |
| 2555 | msgid "Up Next" | |
| 2556 | msgstr "Up Next" | |
| 2557 | ||
| 2558 | msgctxt "#90297" | |
| 2559 | msgid "My Lists" | |
| 2560 | msgstr "My Lists" | |
| 2561 | ||
| 2562 | msgctxt "#90298" | |
| 2563 | msgid "Liked Lists" | |
| 2564 | msgstr "Liked Lists" | |
| 2565 | ||
| 2566 | msgctxt "#90331" | |
| 2567 | msgid "Stremio Addons" | |
| 2568 | msgstr "Stremio Addons" | |
| 2569 | ||
| 2570 | msgctxt "#90332" | |
| 2571 | msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first." | |
| 2572 | msgstr "No addons available yet. Use Log In to Stremio or Add Custom Addon first." | |
| 2573 | ||
| 2574 | msgctxt "#90333" | |
| 2575 | msgid "Stremio Stream Addons" | |
| 2576 | msgstr "Stremio Stream Addons" | |
| 2577 | ||
| 2578 | msgctxt "#90334" | |
| 2579 | msgid "Stremio Catalog Addons" | |
| 2580 | msgstr "Stremio Catalog Addons" | |
| 2581 | ||
| 2582 | msgctxt "#90335" | |
| 2583 | msgid "Stremio Live TV Addons" | |
| 2584 | msgstr "Stremio Live TV Addons" | |
| 2585 | ||
| 2586 | msgctxt "#90336" | |
| 2587 | msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged." | |
| 2588 | msgstr "Refresh addons imported from your Stremio account. Custom addons remain unchanged." | |
| 2589 | ||
| 2590 | msgctxt "#90337" | |
| 2591 | msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first." | |
| 2592 | msgstr "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first." | |
| 2593 | ||
| 2594 | msgctxt "#90338" | |
| 2595 | msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first." | |
| 2596 | msgstr "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first." | |
| 2597 | ||
| 2598 | msgctxt "#90339" | |
| 2599 | msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first." | |
| 2600 | msgstr "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 | ||
| 2602 | msgctxt "#90340" | |
| 2603 | msgid "Remove custom Stremio addons you previously added manually." | |
| 2604 | msgstr "Remove custom Stremio addons you previously added manually." | |
| 2605 | ||
| 2606 | msgctxt "#90341" | |
| 2607 | msgid "Choose Torrent Client" | |
| 2608 | msgstr "Choose Torrent Client" | |
| 2609 | ||
| 2610 | msgctxt "#90342" | |
| 2611 | msgid "Clear Database Cache" | |
| 2612 | msgstr "Clear Database Cache" | |
| 2613 | ||
| 2614 | msgctxt "#90343" | |
| 2615 | msgid "Clear Stremio Cache" | |
| 2616 | msgstr "Clear Stremio Cache" | |
| 2617 | ||
| 2618 | msgctxt "#90344" | |
| 2619 | msgid "Clear Debrid Cache" | |
| 2620 | msgstr "Clear Debrid Cache" | |
| 2621 | ||
| 2622 | msgctxt "#90345" | |
| 2623 | msgid "Clear MDBList Cache" | |
| 2624 | msgstr "Clear MDBList Cache" | |
| 2625 | ||
| 2626 | msgctxt "#90346" | |
| 2627 | msgid "Restore backup from" | |
| 2628 | msgstr "Restore backup from" | |
| 2629 | ||
| 2630 | msgctxt "#90347" | |
| 2631 | msgid "File" | |
| 2632 | msgstr "File" | |
| 2633 | ||
| 2634 | msgctxt "#90348" | |
| 2635 | msgid "URL" | |
| 2636 | msgstr "URL" | |
| 2637 | ||
| 2638 | msgctxt "#90349" | |
| 2639 | msgid "Enter backup URL" | |
| 2640 | msgstr "Enter backup URL" | |
| 2641 | ||
| 2642 | msgctxt "#90350" | |
| 2643 | msgid "No backup URL provided." | |
| 2644 | msgstr "No backup URL provided." | |
| 2645 | ||
| 2646 | msgctxt "#90351" | |
| 2647 | msgid "Unable to download settings backup." | |
| 2648 | msgstr "Unable to download settings backup." | |
| 2649 | ||
| 2650 | msgctxt "#90352" | |
| 2651 | msgid "Source Select Style" | |
| 2652 | msgstr "Source Select Style" | |
| 2653 | ||
| 2654 | msgctxt "#90353" | |
| 2655 | msgid "Modern" | |
| 2656 | msgstr "Modern" | |
| 2657 | ||
| 2658 | msgctxt "#90354" | |
| 2659 | msgid "OLED" | |
| 2660 | msgstr "OLED" | |
| 2661 | ||
| 2662 | msgctxt "#90355" | |
| 2663 | msgid "Cyberpunk" | |
| 2664 | msgstr "Cyberpunk" | |
| 2665 | ||
| 2666 | msgctxt "#90356" | |
| 2667 | msgid "Forest" | |
| 2668 | msgstr "Forest" | |
| 2669 | ||
| 2670 | msgctxt "#90357" | |
| 2671 | msgid "Gold" | |
| 2672 | msgstr "Gold" | |
| 2673 | ||
| 2674 | msgctxt "#90358" | |
| 2675 | msgid "No playable results found" | |
| 2676 | msgstr "No playable results found" | |
| 2677 | ||
| 2678 | msgctxt "#90359" | |
| 2679 | msgid "Add to TorrServer" | |
| 2680 | msgstr "Add to TorrServer" | |
| 2681 | ||
| 2682 | msgctxt "#90360" | |
| 2683 | msgid "Added to TorrServer" | |
| 2684 | msgstr "Added to TorrServer" | |
| 2685 | ||
| 2686 | msgctxt "#90361" | |
| 2687 | msgid "Could not resolve torrent details" | |
| 2688 | msgstr "Could not resolve torrent details" | |
| 2689 | ||
| 2690 | msgctxt "#90362" | |
| 2691 | msgid "Added to %s cloud" | |
| 2692 | msgstr "Added to %s cloud" | |
| 2693 | ||
| 2694 | msgctxt "#90363" | |
| 2695 | msgid "Choose Debrid Service" | |
| 2696 | msgstr "Choose Debrid Service" | |
| 2697 | ||
| 2698 | msgctxt "#90364" | |
| 2699 | msgid "No supported debrid service is enabled" | |
| 2700 | msgstr "No supported debrid service is enabled" | |
| 2701 | ||
| 2702 | msgctxt "#90365" | |
| 2703 | msgid "Download to Debrid" | |
| 2704 | msgstr "Download to Debrid" | |
| 2705 | ||
| 2706 | msgctxt "#90366" | |
| 2707 | msgid "Include Unknown quality" | |
| 2708 | msgstr "Include Unknown quality" | |
| 2709 | ||
| 2710 | msgctxt "#90367" | |
| 2711 | msgid "Includes releases where video quality could not be identified" | |
| 2712 | msgstr "Includes releases where video quality could not be identified" | |
| 2713 | ||
| 2714 | msgctxt "#90368" | |
| 2715 | msgid "Search by Keyword" | |
| 2716 | msgstr "Search by Keyword" | |
| 2717 | ||
| 2718 | msgctxt "#90369" | |
| 2719 | msgid "Latest Movies" | |
| 2720 | msgstr "Latest Movies" | |
| 2721 | ||
| 2722 | msgctxt "#90370" | |
| 2723 | msgid "Latest Series" | |
| 2724 | msgstr "Latest Series" | |
| 2725 | ||
| 2726 | msgctxt "#90371" | |
| 2727 | msgid "Raw Files" | |
| 2728 | msgstr "Raw Files" | |
| 2729 | ||
| 2730 | msgctxt "#90372" | |
| 2731 | msgid "MDblist" | |
| 2732 | msgstr "MDblist" | |
| 2733 | ||
| 2734 | msgctxt "#90373" | |
| 2735 | msgid "Active Downloads" | |
| 2736 | msgstr "Active Downloads" | |
| 2737 | ||
| 2738 | msgctxt "#90374" | |
| 2739 | msgid "Cancel Download" | |
| 2740 | msgstr "Cancel Download" | |
| 2741 | ||
| 2742 | msgctxt "#90375" | |
| 2743 | msgid "Delete File" | |
| 2744 | msgstr "Delete File" | |
| 2745 | ||
| 2746 | msgctxt "#90376" | |
| 2747 | msgid "Search Collections" | |
| 2748 | msgstr "Search Collections" | |
| 2749 | ||
| 2750 | msgctxt "#90377" | |
| 2751 | msgid "Popular Collections" | |
| 2752 | msgstr "Popular Collections" | |
| 2753 | ||
| 2754 | msgctxt "#90378" | |
| 2755 | msgid "Top Rated Collections" | |
| 2756 | msgstr "Top Rated Collections" | |
| 2757 | ||
| 2758 | msgctxt "#90379" | |
| 2759 | msgid "Recommendations" | |
| 2760 | msgstr "Recommendations" | |
| 2761 | ||
| 2762 | msgctxt "#90380" | |
| 2763 | msgid "Similar" | |
| 2764 | msgstr "Similar" | |
| 2765 | ||
| 2766 | msgctxt "#90381" | |
| 2767 | msgid "Keyword: %s" | |
| 2768 | msgstr "Keyword: %s" | |
| 2769 | ||
| 2770 | msgctxt "#90382" | |
| 2771 | msgid "Please set a valid Trakt Client ID Key" | |
| 2772 | msgstr "Please set a valid Trakt Client ID Key" | |
| 2773 | ||
| 2774 | msgctxt "#90383" | |
| 2775 | msgid "Please set a valid Trakt Client Secret Key" | |
| 2776 | msgstr "Please set a valid Trakt Client Secret Key" | |
| 2777 | ||
| 2778 | msgctxt "#90384" | |
| 2779 | msgid "Trakt Error Authorizing" | |
| 2780 | msgstr "Trakt Error Authorizing" | |
| 2781 | ||
| 2782 | msgctxt "#90385" | |
| 2783 | msgid "Trakt Account Authorized" | |
| 2784 | msgstr "Trakt Account Authorized" | |
| 2785 | ||
| 2786 | msgctxt "#90386" | |
| 2787 | msgid "You are now logged out from Trakt.tv" | |
| 2788 | msgstr "You are now logged out from Trakt.tv" | |
| 2789 | ||
| 2790 | msgctxt "#90387" | |
| 2791 | msgid "Failed to mark as watched" | |
| 2792 | msgstr "Failed to mark as watched" | |
| 2793 | ||
| 2794 | msgctxt "#90388" | |
| 2795 | msgid "Marked as watched" | |
| 2796 | msgstr "Marked as watched" | |
| 2797 | ||
| 2798 | msgctxt "#90389" | |
| 2799 | msgid "No results found" | |
| 2800 | msgstr "No results found" | |
| 2801 | ||
| 2802 | msgctxt "#90390" | |
| 2803 | msgid "Invalid mode" | |
| 2804 | msgstr "Invalid mode" | |
| 2805 | ||
| 2806 | msgctxt "#90391" | |
| 2807 | msgid "No recommendations found" | |
| 2808 | msgstr "No recommendations found" | |
| 2809 | ||
| 2810 | msgctxt "#90392" | |
| 2811 | msgid "No similar items found" | |
| 2812 | msgstr "No similar items found" | |
| 2813 | ||
| 2814 | msgctxt "#90393" | |
| 2815 | msgid "TMDB metadata not found" | |
| 2816 | msgstr "TMDB metadata not found" | |
| 2817 | ||
| 2818 | msgctxt "#90394" | |
| 2819 | msgid "No TV shows found" | |
| 2820 | msgstr "No TV shows found" | |
| 2821 | ||
| 2822 | msgctxt "#90395" | |
| 2823 | msgid "No shows airing today" | |
| 2824 | msgstr "No shows airing today" | |
| 2825 | ||
| 2826 | msgctxt "#90396" | |
| 2827 | msgid "Invalid collection query" | |
| 2828 | msgstr "Invalid collection query" | |
| 2829 | ||
| 2830 | msgctxt "#90397" | |
| 2831 | msgid "No keywords found" | |
| 2832 | msgstr "No keywords found" | |
| 2833 | ||
| 2834 | msgctxt "#90398" | |
| 2835 | msgid "No keyword ID provided" | |
| 2836 | msgstr "No keyword ID provided" | |
| 2837 | ||
| 2838 | msgctxt "#90399" | |
| 2839 | msgid "No results found for this keyword" | |
| 2840 | msgstr "No results found for this keyword" | |
| 2841 | ||
| 2842 | msgctxt "#90400" | |
| 2843 | msgid "No TMDB ID found" | |
| 2844 | msgstr "No TMDB ID found" | |
| 2845 | ||
| 2846 | msgctxt "#90401" | |
| 2847 | msgid "Unsupported media type or missing external_ids" | |
| 2848 | msgstr "Unsupported media type or missing external_ids" | |
| 2849 | ||
| 2850 | msgctxt "#90402" | |
| 2851 | msgid "Refreshed Metadata" | |
| 2852 | msgstr "Refreshed Metadata" | |
| 2853 | ||
| 2854 | msgctxt "#90403" | |
| 2855 | msgid "No Stremio addons selected" | |
| 2856 | msgstr "No Stremio addons selected" | |
| 2857 | ||
| 2858 | msgctxt "#90404" | |
| 2859 | msgid "No suitable source found for auto play." | |
| 2860 | msgstr "No suitable source found for auto play." | |
| 2861 | ||
| 2862 | msgctxt "#90405" | |
| 2863 | msgid "No sources found with the preferred quality." | |
| 2864 | msgstr "No sources found with the preferred quality." | |
| 2865 | ||
| 2866 | msgctxt "#90406" | |
| 2867 | msgid "No languages found" | |
| 2868 | msgstr "No languages found" | |
| 2869 | ||
| 2870 | msgctxt "#90407" | |
| 2871 | msgid "Failed to resolve playback source" | |
| 2872 | msgstr "Failed to resolve playback source" | |
| 2873 | ||
| 2874 | msgctxt "#90408" | |
| 2875 | msgid "Trakt not authorized. Please authenticate." | |
| 2876 | msgstr "Trakt not authorized. Please authenticate." | |
| 2877 | ||
| 2878 | msgctxt "#90409" | |
| 2879 | msgid "Added to Trakt Watchlist" | |
| 2880 | msgstr "Added to Trakt Watchlist" | |
| 2881 | ||
| 2882 | msgctxt "#90410" | |
| 2883 | msgid "Error modifying Trakt Watchlist" | |
| 2884 | msgstr "Error modifying Trakt Watchlist" | |
| 2885 | ||
| 2886 | msgctxt "#90411" | |
| 2887 | msgid "Marked as Watched on Trakt" | |
| 2888 | msgstr "Marked as Watched on Trakt" | |
| 2889 | ||
| 2890 | msgctxt "#90412" | |
| 2891 | msgid "Error modifying Trakt Watched status" | |
| 2892 | msgstr "Error modifying Trakt Watched status" | |
| 2893 | ||
| 2894 | msgctxt "#90413" | |
| 2895 | msgid "Play" | |
| 2896 | msgstr "Play" | |
| 2897 | ||
| 2898 | msgctxt "#90414" | |
| 2899 | msgid "Trailer" | |
| 2900 | msgstr "Trailer" | |
| 2901 | ||
| 2902 | msgctxt "#90415" | |
| 2903 | msgid "Watched" | |
| 2904 | msgstr "Watched" | |
| 2905 | ||
| 2906 | msgctxt "#90416" | |
| 2907 | msgid "Refresh" | |
| 2908 | msgstr "Refresh" | |
| 2909 | ||
| 2910 | msgctxt "#90418" | |
| 2911 | msgid "No credits found for this ID" | |
| 2912 | msgstr "No credits found for this ID" | |
| 2913 | ||
| 2914 | msgctxt "#90419" | |
| 2915 | msgid "Credited People" | |
| 2916 | msgstr "Credited People" | |
| 2917 | ||
| 2918 | msgctxt "#90420" | |
| 2919 | msgid "Person Details" | |
| 2920 | msgstr "Person Details" | |
| 2921 | ||
| 2922 | msgctxt "#90421" | |
| 2923 | msgid "Person not found" | |
| 2924 | msgstr "Person not found" | |
| 2925 | ||
| 2926 | msgctxt "#90422" | |
| 2927 | msgid "Jackgram not activated" | |
| 2928 | msgstr "Jackgram not activated" | |
| 2929 | ||
| 2930 | msgctxt "#90423" | |
| 2931 | msgid "Not yet implemented" | |
| 2932 | msgstr "Not yet implemented" | |
| 2933 | ||
| 2934 | msgctxt "#90424" | |
| 2935 | msgid "Unsupported debrid type" | |
| 2936 | msgstr "Unsupported debrid type" | |
| 2937 | ||
| 2938 | msgctxt "#90425" | |
| 2939 | msgid "Easynews credentials required" | |
| 2940 | msgstr "Easynews credentials required" | |
| 2941 | ||
| 2942 | msgctxt "#90426" | |
| 2943 | msgid "No Corrupt or Missing Databases" | |
| 2944 | msgstr "No Corrupt or Missing Databases" | |
| 2945 | ||
| 2946 | msgctxt "#90427" | |
| 2947 | msgid "Otaku (script.otaku.mappings) not found" | |
| 2948 | msgstr "Otaku (script.otaku.mappings) not found" | |
| 2949 | ||
| 2950 | msgctxt "#90428" | |
| 2951 | msgid "Database read error. File may be corrupted." | |
| 2952 | msgstr "Database read error. File may be corrupted." | |
| 2953 | ||
| 2954 | msgctxt "#90429" | |
| 2955 | msgid "Failed to delete data. Database error." | |
| 2956 | msgstr "Failed to delete data. Database error." | |
| 2957 | ||
| 2958 | msgctxt "#90430" | |
| 2959 | msgid "Failed to clear cache. Database in use." | |
| 2960 | msgstr "Failed to clear cache. Database in use." | |
| 2961 | ||
| 2962 | msgctxt "#90431" | |
| 2963 | msgid "No items found in this list" | |
| 2964 | msgstr "No items found in this list" | |
| 2965 | ||
| 2966 | msgctxt "#90432" | |
| 2967 | msgid "Databases Rebuilt" | |
| 2968 | msgstr "Databases Rebuilt" | |
| 2969 | ||
| 2970 | msgctxt "#90433" | |
| 2971 | msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s" | |
| 2972 | msgstr "[B]Following Databases Rebuilt:[/B][CR][CR]%s" | |
| 2973 | ||
| 2974 | msgctxt "#90434" | |
| 2975 | msgid "Added to Trakt watchlist" | |
| 2976 | msgstr "Added to Trakt watchlist" | |
| 2977 | ||
| 2978 | msgctxt "#90435" | |
| 2979 | msgid "Failed to add to Trakt watchlist" | |
| 2980 | msgstr "Failed to add to Trakt watchlist" | |
| 2981 | ||
| 2982 | msgctxt "#90436" | |
| 2983 | msgid "Removed from Trakt watchlist" | |
| 2984 | msgstr "Removed from Trakt watchlist" | |
| 2985 | ||
| 2986 | msgctxt "#90437" | |
| 2987 | msgid "Failed to remove from Trakt watchlist" | |
| 2988 | msgstr "Failed to remove from Trakt watchlist" | |
| 2989 | ||
| 2990 | msgctxt "#90438" | |
| 2991 | msgid "Failed to mark as watched on Trakt" | |
| 2992 | msgstr "Failed to mark as watched on Trakt" | |
| 2993 | ||
| 2994 | msgctxt "#90439" | |
| 2995 | msgid "Marked as unwatched on Trakt" | |
| 2996 | msgstr "Marked as unwatched on Trakt" | |
| 2997 | ||
| 2998 | msgctxt "#90440" | |
| 2999 | msgid "Failed to mark as unwatched on Trakt" | |
| 3000 | msgstr "Failed to mark as unwatched on Trakt" | |
| 3001 | ||
| 3002 | msgctxt "#90441" | |
| 3003 | msgid "Added to Trakt collection" | |
| 3004 | msgstr "Added to Trakt collection" | |
| 3005 | ||
| 3006 | msgctxt "#90442" | |
| 3007 | msgid "Removed from Trakt collection" | |
| 3008 | msgstr "Removed from Trakt collection" | |
| 3009 | ||
| 3010 | msgctxt "#90443" | |
| 3011 | msgid "Failed to add to Trakt collection" | |
| 3012 | msgstr "Failed to add to Trakt collection" | |
| 3013 | ||
| 3014 | msgctxt "#90444" | |
| 3015 | msgid "Failed to remove from Trakt collection" | |
| 3016 | msgstr "Failed to remove from Trakt collection" | |
| 3017 | ||
| 3018 | msgctxt "#90445" | |
| 3019 | msgid "Added to Trakt favorites" | |
| 3020 | msgstr "Added to Trakt favorites" | |
| 3021 | ||
| 3022 | msgctxt "#90446" | |
| 3023 | msgid "Removed from Trakt favorites" | |
| 3024 | msgstr "Removed from Trakt favorites" | |
| 3025 | ||
| 3026 | msgctxt "#90447" | |
| 3027 | msgid "Failed to add to Trakt favorites" | |
| 3028 | msgstr "Failed to add to Trakt favorites" | |
| 3029 | ||
| 3030 | msgctxt "#90448" | |
| 3031 | msgid "Failed to remove from Trakt favorites" | |
| 3032 | msgstr "Failed to remove from Trakt favorites" | |
| 3033 | ||
| 3034 | msgctxt "#90449" | |
| 3035 | msgid "Created Trakt list" | |
| 3036 | msgstr "Created Trakt list" | |
| 3037 | ||
| 3038 | msgctxt "#90450" | |
| 3039 | msgid "Failed to create Trakt list" | |
| 3040 | msgstr "Failed to create Trakt list" | |
| 3041 | ||
| 3042 | msgctxt "#90451" | |
| 3043 | msgid "Deleted Trakt list" | |
| 3044 | msgstr "Deleted Trakt list" | |
| 3045 | ||
| 3046 | msgctxt "#90452" | |
| 3047 | msgid "Failed to delete Trakt list" | |
| 3048 | msgstr "Failed to delete Trakt list" | |
| 3049 | ||
| 3050 | msgctxt "#90453" | |
| 3051 | msgid "Liked Trakt list" | |
| 3052 | msgstr "Liked Trakt list" | |
| 3053 | ||
| 3054 | msgctxt "#90454" | |
| 3055 | msgid "Failed to like Trakt list" | |
| 3056 | msgstr "Failed to like Trakt list" | |
| 3057 | ||
| 3058 | msgctxt "#90455" | |
| 3059 | msgid "Unliked Trakt list" | |
| 3060 | msgstr "Unliked Trakt list" | |
| 3061 | ||
| 3062 | msgctxt "#90456" | |
| 3063 | msgid "Failed to unlike Trakt list" | |
| 3064 | msgstr "Failed to unlike Trakt list" | |
| 3065 | ||
| 3066 | msgctxt "#90457" | |
| 3067 | msgid "Added to Trakt list" | |
| 3068 | msgstr "Added to Trakt list" | |
| 3069 | ||
| 3070 | msgctxt "#90458" | |
| 3071 | msgid "OpenSubtitles Stremio: No results found." | |
| 3072 | msgstr "" | |
| 3073 | ||
| 3074 | msgctxt "#90459" | |
| 3075 | msgid "Failed to add to Trakt list" | |
| 3076 | msgstr "Failed to add to Trakt list" | |
| 3077 | ||
| 3078 | msgctxt "#90460" | |
| 3079 | msgid "Subtitle Manager: No subtitles available for this video." | |
| 3080 | msgstr "" | |
| 3081 | ||
| 3082 | msgctxt "#90461" | |
| 3083 | msgid "No Trakt lists found" | |
| 3084 | msgstr "No Trakt lists found" | |
| 3085 | ||
| 3086 | msgctxt "#90462" | |
| 3087 | msgid "Item not found in Trakt collection" | |
| 3088 | msgstr "Item not found in Trakt collection" | |
| 3089 | ||
| 3090 | msgctxt "#90463" | |
| 3091 | msgid "Trakt could not match this item for collection" | |
| 3092 | msgstr "Trakt could not match this item for collection" | |
| 3093 | ||
| 3094 | msgctxt "#90464" | |
| 3095 | msgid "No Trakt collection changes were applied" | |
| 3096 | msgstr "No Trakt collection changes were applied" | |
| 3097 | ||
| 3098 | msgctxt "#90465" | |
| 3099 | msgid "Could not build Trakt collection payload for show" | |
| 3100 | msgstr "Could not build Trakt collection payload for show" | |
| 3101 | ||
| 3102 | msgctxt "#90466" | |
| 3103 | msgid "Delete this Trakt list?" | |
| 3104 | msgstr "Delete this Trakt list?" | |
| 3105 | ||
| 3106 | msgctxt "#90468" | |
| 3107 | msgid "Search menu" | |
| 3108 | msgstr "" | |
| 3109 | ||
| 3110 | msgctxt "#90469" | |
| 3111 | msgid "Continue watching menu" | |
| 3112 | msgstr "" | |
| 3113 | ||
| 3114 | msgctxt "#90470" | |
| 3115 | msgid "TV Shows Items" | |
| 3116 | msgstr "" | |
| 3117 | ||
| 3118 | msgctxt "#90471" | |
| 3119 | msgid "Movies Items" | |
| 3120 | msgstr "" | |
| 3121 | ||
| 3122 | msgctxt "#90472" | |
| 3123 | msgid "Anime menu" | |
| 3124 | msgstr "" | |
| 3125 | ||
| 3126 | msgctxt "#90473" | |
| 3127 | msgid "TV Menu" | |
| 3128 | msgstr "" | |
| 3129 | ||
| 3130 | msgctxt "#90474" | |
| 3131 | msgid "Library menu" | |
| 3132 | msgstr "" | |
| 3133 | ||
| 3134 | msgctxt "#90475" | |
| 3135 | msgid "Torrents Management" | |
| 3136 | msgstr "" | |
| 3137 | ||
| 3138 | msgctxt "#90476" | |
| 3139 | msgid "Telegram menu" | |
| 3140 | msgstr "" | |
| 3141 | ||
| 3142 | msgctxt "#90477" | |
| 3143 | msgid "Cloud Storage" | |
| 3144 | msgstr "" | |
| 3145 | ||
| 3146 | msgctxt "#90478" | |
| 3147 | msgid "Downloads Management" | |
| 3148 | msgstr "" | |
| 3149 | ||
| 3150 | msgctxt "#90479" | |
| 3151 | msgid "Settings Management" | |
| 3152 | msgstr "" | |
| 3153 | ||
| 3154 | msgctxt "#90480" | |
| 3155 | msgid "History Menu" | |
| 3156 | msgstr "" | |
| 3157 | ||
| 3158 | msgctxt "#90481" | |
| 3159 | msgid "Files History" | |
| 3160 | msgstr "" | |
| 3161 | ||
| 3162 | msgctxt "#90482" | |
| 3163 | msgid "Titles History" | |
| 3164 | msgstr "" | |
| 3165 | ||
| 3166 | msgctxt "#90483" | |
| 3167 | msgid "Titles Calendar" | |
| 3168 | msgstr "" | |
| 3169 | ||
| 3170 | msgctxt "#90484" | |
| 3171 | msgid "My Shows" | |
| 3172 | msgstr "" | |
| 3173 | ||
| 3174 | msgctxt "#90485" | |
| 3175 | msgid "My Movies" | |
| 3176 | msgstr "" | |
| 3177 | ||
| 3178 | msgctxt "#90486" | |
| 3179 | msgid "Upcoming Episodes" | |
| 3180 | msgstr "" | |
| 3181 | ||
| 3182 | msgctxt "#90487" | |
| 3183 | msgid "Discovery List" | |
| 3184 | msgstr "" | |
| 3185 | ||
| 3186 | msgctxt "#90488" | |
| 3187 | msgid "Library List" | |
| 3188 | msgstr "" | |
| 3189 | ||
| 3190 | msgctxt "#90489" | |
| 3191 | msgid "Trakt Recommendations" | |
| 3192 | msgstr "" | |
| 3193 | ||
| 3194 | msgctxt "#90490" | |
| 3195 | msgid "Trending Lists" | |
| 3196 | msgstr "" | |
| 3197 | ||
| 3198 | msgctxt "#90491" | |
| 3199 | msgid "Popular Lists" | |
| 3200 | msgstr "" | |
| 3201 | ||
| 3202 | msgctxt "#90492" | |
| 3203 | msgid "Search Lists" | |
| 3204 | msgstr "" | |
| 3205 | ||
| 3206 | msgctxt "#90493" | |
| 3207 | msgid "Recent Activity" | |
| 3208 | msgstr "" | |
| 3209 | ||
| 3210 | msgctxt "#90494" | |
| 3211 | msgid "Downloads List" | |
| 3212 | msgstr "" | |
| 3213 | ||
| 3214 | msgctxt "#90495" | |
| 3215 | msgid "Download New" | |
| 3216 | msgstr "" | |
| 3217 | ||
| 3218 | msgctxt "#90496" | |
| 3219 | msgid "Cartoons Popular" | |
| 3220 | msgstr "Cartoons Popular" | |
| 3221 | ||
| 3222 | msgctxt "#90497" | |
| 3223 | msgid "Animation Popular" | |
| 3224 | msgstr "Animation Popular" | |
| 3225 | ||
| 3226 | msgctxt "#90500" | |
| 3227 | msgid "Translating subtitle..." | |
| 3228 | msgstr "Translating subtitle..." | |
| 3229 | ||
| 3230 | msgctxt "#90501" | |
| 3231 | msgid "Translation done." | |
| 3232 | msgstr "Translation done." | |
| 3233 | ||
| 3234 | msgctxt "#90502" | |
| 3235 | msgid "Searching subtitles..." | |
| 3236 | msgstr "" | |
| 3237 | ||
| 3238 | msgctxt "#90503" | |
| 3239 | msgid "No subtitles found" | |
| 3240 | msgstr "" | |
| 3241 | ||
| 3242 | msgctxt "#90504" | |
| 3243 | msgid "Failed to authenticate with DeepL. Please check your API key." | |
| 3244 | msgstr "" | |
| 3245 | ||
| 3246 | msgctxt "#90505" | |
| 3247 | msgid "DeepL API key is missing or invalid." | |
| 3248 | msgstr "" | |
| 3249 | ||
| 3250 | msgctxt "#90510" | |
| 3251 | msgid "No meta available" | |
| 3252 | msgstr "No meta available" | |
| 3253 | ||
| 3254 | msgctxt "#90511" | |
| 3255 | msgid "No content available for" | |
| 3256 | msgstr "No content available for" | |
| 3257 | ||
| 3258 | msgctxt "#90512" | |
| 3259 | msgid "Season" | |
| 3260 | msgstr "Season" | |
| 3261 | ||
| 3262 | msgctxt "#90513" | |
| 3263 | msgid "No episodes available" | |
| 3264 | msgstr "No episodes available" | |
| 3265 | ||
| 3266 | msgctxt "#90514" | |
| 3267 | msgid "No videos available" | |
| 3268 | msgstr "No videos available" | |
| 3269 | ||
| 3270 | msgctxt "#90515" | |
| 3271 | msgid "Next Page" | |
| 3272 | msgstr "Next Page" | |
| 3273 | ||
| 3274 | msgctxt "#90516" | |
| 3275 | msgid "No metas available" | |
| 3276 | msgstr "No metas available" | |
| 3277 | ||
| 3278 | msgctxt "#90517" | |
| 3279 | msgid "No seasons available" | |
| 3280 | msgstr "No seasons available" | |
| 3281 | ||
| 3282 | msgctxt "#90518" | |
| 3283 | msgid "Invalid query" | |
| 3284 | msgstr "Invalid query" | |
| 3285 | ||
| 3286 | msgctxt "#90519" | |
| 3287 | msgid "No lists found" | |
| 3288 | msgstr "No lists found" | |
| 3289 | ||
| 3290 | msgctxt "#90520" | |
| 3291 | msgid "No user lists found" | |
| 3292 | msgstr "No user lists found" | |
| 3293 | ||
| 3294 | msgctxt "#90521" | |
| 3295 | msgid "No top lists found" | |
| 3296 | msgstr "No top lists found" | |
| 3297 | ||
| 3298 | msgctxt "#90522" | |
| 3299 | msgid "Custom Addon" | |
| 3300 | msgstr "Custom Addon" | |
| 3301 | ||
| 3302 | msgctxt "#90523" | |
| 3303 | msgid "Enter the custom Stremio addon URL" | |
| 3304 | msgstr "Enter the custom Stremio addon URL" | |
| 3305 | ||
| 3306 | msgctxt "#90524" | |
| 3307 | msgid "No URL provided." | |
| 3308 | msgstr "No URL provided." | |
| 3309 | ||
| 3310 | msgctxt "#90525" | |
| 3311 | msgid "Failed to fetch manifest: %s" | |
| 3312 | msgstr "Failed to fetch manifest: %s" | |
| 3313 | ||
| 3314 | msgctxt "#90526" | |
| 3315 | msgid "Manifest missing 'id' or 'name'." | |
| 3316 | msgstr "Manifest missing 'id' or 'name'." | |
| 3317 | ||
| 3318 | msgctxt "#90527" | |
| 3319 | msgid "Custom Stremio addon added successfully!" | |
| 3320 | msgstr "Custom Stremio addon added successfully!" | |
| 3321 | ||
| 3322 | msgctxt "#90528" | |
| 3323 | msgid "Addon does not provide 'stream' or 'catalog' resources." | |
| 3324 | msgstr "Addon does not provide 'stream' or 'catalog' resources." | |
| 3325 | ||
| 3326 | msgctxt "#90529" | |
| 3327 | msgid "This addon is already added to your list." | |
| 3328 | msgstr "This addon is already added to your list." | |
| 3329 | ||
| 3330 | msgctxt "#90530" | |
| 3331 | msgid "Failed to add custom addon: %s" | |
| 3332 | msgstr "Failed to add custom addon: %s" | |
| 3333 | ||
| 3334 | msgctxt "#90531" | |
| 3335 | msgid "Remove Addon" | |
| 3336 | msgstr "Remove Addon" | |
| 3337 | ||
| 3338 | msgctxt "#90532" | |
| 3339 | msgid "No custom Stremio addons to remove." | |
| 3340 | msgstr "No custom Stremio addons to remove." | |
| 3341 | ||
| 3342 | msgctxt "#90533" | |
| 3343 | msgid "Remove Stremio Addons" | |
| 3344 | msgstr "Remove Stremio Addons" | |
| 3345 | ||
| 3346 | msgctxt "#90534" | |
| 3347 | msgid "Selected addon(s) removed." | |
| 3348 | msgstr "Selected addon(s) removed." | |
| 3349 | ||
| 3350 | msgctxt "#90535" | |
| 3351 | msgid "Untitled List" | |
| 3352 | msgstr "Untitled List" | |
| 3353 | ||
| 3354 | msgctxt "#90536" | |
| 3355 | msgid "Removed from Trakt list" | |
| 3356 | msgstr "Removed from Trakt list" | |
| 3357 | ||
| 3358 | msgctxt "#90537" | |
| 3359 | msgid "Failed to remove from Trakt list" | |
| 3360 | msgstr "Failed to remove from Trakt list" | |
| 3361 | ||
| 3362 | msgctxt "#90538" | |
| 3363 | msgid "Resume from %.1f%%" | |
| 3364 | msgstr "Resume from %.1f%%" | |
| 3365 | ||
| 3366 | msgctxt "#90539" | |
| 3367 | msgid "Start from Beginning" | |
| 3368 | msgstr "Start from Beginning" | |
| 3369 | ||
| 3370 | msgctxt "#90540" | |
| 3371 | msgid "Authorize Debrid Services" | |
| 3372 | msgstr "Authorize Debrid Services" | |
| 3373 | ||
| 3374 | msgctxt "#90541" | |
| 3375 | msgid "Navigate to: [B]%s[/B]" | |
| 3376 | msgstr "Navigate to: [B]%s[/B]" | |
| 3377 | ||
| 3378 | msgctxt "#90542" | |
| 3379 | msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3380 | msgstr "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3381 | ||
| 3382 | msgctxt "#90543" | |
| 3383 | msgid "Premiumize Auth" | |
| 3384 | msgstr "Premiumize Auth" | |
| 3385 | ||
| 3386 | msgctxt "#90544" | |
| 3387 | msgid "Success" | |
| 3388 | msgstr "Success" | |
| 3389 | ||
| 3390 | msgctxt "#90545" | |
| 3391 | msgid "Authentication completed." | |
| 3392 | msgstr "Authentication completed." | |
| 3393 | ||
| 3394 | msgctxt "#90546" | |
| 3395 | msgid "Error:" | |
| 3396 | msgstr "Error:" | |
| 3397 | ||
| 3398 | msgctxt "#90547" | |
| 3399 | msgid "Error: %s" | |
| 3400 | msgstr "Error: %s" | |
| 3401 | ||
| 3402 | msgctxt "#90548" | |
| 3403 | msgid "Auth Error" | |
| 3404 | msgstr "Auth Error" | |
| 3405 | ||
| 3406 | msgctxt "#90549" | |
| 3407 | msgid "Torbox authentication successful." | |
| 3408 | msgstr "Torbox authentication successful." | |
| 3409 | ||
| 3410 | msgctxt "#90554" | |
| 3411 | msgid "Please wait..." | |
| 3412 | msgstr "Please wait..." | |
| 3413 | ||
| 3414 | msgctxt "#90555" | |
| 3415 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3416 | msgstr "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3417 | ||
| 3418 | msgctxt "#90556" | |
| 3419 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3420 | msgstr "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3421 | ||
| 3422 | msgctxt "#90557" | |
| 3423 | msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]" | |
| 3424 | msgstr "Pastebin Link:\n[COLOR cyan]%s[/COLOR]" | |
| 3425 | ||
| 3426 | msgctxt "#90670" | |
| 3427 | msgid "Local URL:\n[COLOR cyan]%s[/COLOR]" | |
| 3428 | msgstr "Local URL:\n[COLOR cyan]%s[/COLOR]" | |
| 3429 | ||
| 3430 | msgctxt "#90558" | |
| 3431 | msgid "QR Code Authentication" | |
| 3432 | msgstr "QR Code Authentication" | |
| 3433 | ||
| 3434 | msgctxt "#90559" | |
| 3435 | msgid "Kodi 20 or above required. Please update Kodi to use this addon." | |
| 3436 | msgstr "Kodi 20 or above required. Please update Kodi to use this addon." | |
| 3437 | ||
| 3438 | msgctxt "#90560" | |
| 3439 | msgid "Failed to start web server. Port may be in use." | |
| 3440 | msgstr "Failed to start web server. Port may be in use." | |
| 3441 | ||
| 3442 | msgctxt "#90561" | |
| 3443 | msgid "Authentification Removed." | |
| 3444 | msgstr "Authentification Removed." | |
| 3445 | ||
| 3446 | msgctxt "#90562" | |
| 3447 | msgid "WebDAV Error" | |
| 3448 | msgstr "WebDAV Error" | |
| 3449 | ||
| 3450 | msgctxt "#90563" | |
| 3451 | msgid "Trakt Authorization" | |
| 3452 | msgstr "Trakt Authorization" | |
| 3453 | ||
| 3454 | msgctxt "#90564" | |
| 3455 | msgid "Do you want to continue transfer in background?" | |
| 3456 | msgstr "Do you want to continue transfer in background?" | |
| 3457 | ||
| 3458 | msgctxt "#90565" | |
| 3459 | msgid "Cloud Transfer" | |
| 3460 | msgstr "Cloud Transfer" | |
| 3461 | ||
| 3462 | msgctxt "#90566" | |
| 3463 | msgid "Kodi Logs" | |
| 3464 | msgstr "Kodi Logs" | |
| 3465 | ||
| 3466 | msgctxt "#90567" | |
| 3467 | msgid "Show Logs" | |
| 3468 | msgstr "Show Logs" | |
| 3469 | ||
| 3470 | msgctxt "#90568" | |
| 3471 | msgid "Export to paste.kodi.tv" | |
| 3472 | msgstr "Export to paste.kodi.tv" | |
| 3473 | ||
| 3474 | msgctxt "#90569" | |
| 3475 | msgid "Kodi Logs Exported" | |
| 3476 | msgstr "Kodi Logs Exported" | |
| 3477 | ||
| 3478 | msgctxt "#90570" | |
| 3479 | msgid "Providers Selection Dialog" | |
| 3480 | msgstr "Providers Selection Dialog" | |
| 3481 | ||
| 3482 | msgctxt "#90571" | |
| 3483 | msgid "Your current Providers are: \n%s\n\nDo you want to change?" | |
| 3484 | msgstr "Your current Providers are: \n%s\n\nDo you want to change?" | |
| 3485 | ||
| 3486 | msgctxt "#90572" | |
| 3487 | msgid "Select Providers" | |
| 3488 | msgstr "Select Providers" | |
| 3489 | ||
| 3490 | msgctxt "#90573" | |
| 3491 | msgid "Selection Dialog" | |
| 3492 | msgstr "Selection Dialog" | |
| 3493 | ||
| 3494 | msgctxt "#90574" | |
| 3495 | msgid "Successfully selected: %s" | |
| 3496 | msgstr "Successfully selected: %s" | |
| 3497 | ||
| 3498 | msgctxt "#90575" | |
| 3499 | msgid "Selection" | |
| 3500 | msgstr "Selection" | |
| 3501 | ||
| 3502 | msgctxt "#90576" | |
| 3503 | msgid "No providers selected" | |
| 3504 | msgstr "No providers selected" | |
| 3505 | ||
| 3506 | msgctxt "#90577" | |
| 3507 | msgid "Changelog" | |
| 3508 | msgstr "Changelog" | |
| 3509 | ||
| 3510 | msgctxt "#90578" | |
| 3511 | msgid "[B]Unable to check for updates[/B]" | |
| 3512 | msgstr "[B]Unable to check for updates[/B]" | |
| 3513 | ||
| 3514 | msgctxt "#90579" | |
| 3515 | msgid "[B]No update available[/B]" | |
| 3516 | msgstr "[B]No update available[/B]" | |
| 3517 | ||
| 3518 | msgctxt "#90580" | |
| 3519 | msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3520 | msgstr "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3521 | ||
| 3522 | msgctxt "#90581" | |
| 3523 | msgid "[B]Update available. Do you want to update?[/B]" | |
| 3524 | msgstr "[B]Update available. Do you want to update?[/B]" | |
| 3525 | ||
| 3526 | msgctxt "#90582" | |
| 3527 | msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]" | |
| 3528 | msgstr "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]" | |
| 3529 | ||
| 3530 | msgctxt "#90583" | |
| 3531 | msgid "Error fetching versions: %s" | |
| 3532 | msgstr "Error fetching versions: %s" | |
| 3533 | ||
| 3534 | msgctxt "#90584" | |
| 3535 | msgid "No older/different versions found." | |
| 3536 | msgstr "No older/different versions found." | |
| 3537 | ||
| 3538 | msgctxt "#90585" | |
| 3539 | msgid "Select Version to Downgrade" | |
| 3540 | msgstr "Select Version to Downgrade" | |
| 3541 | ||
| 3542 | msgctxt "#90586" | |
| 3543 | msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?" | |
| 3544 | msgstr "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?" | |
| 3545 | ||
| 3546 | msgctxt "#90587" | |
| 3547 | msgid "[B]Downloading update...[/B]" | |
| 3548 | msgstr "[B]Downloading update...[/B]" | |
| 3549 | ||
| 3550 | msgctxt "#90588" | |
| 3551 | msgid "Error: Unable to download update." | |
| 3552 | msgstr "Error: Unable to download update." | |
| 3553 | ||
| 3554 | msgctxt "#90589" | |
| 3555 | msgid "Error saving update file: %s" | |
| 3556 | msgstr "Error saving update file: %s" | |
| 3557 | ||
| 3558 | msgctxt "#90590" | |
| 3559 | msgid "Error extracting update. Please install manually." | |
| 3560 | msgstr "Error extracting update. Please install manually." | |
| 3561 | ||
| 3562 | msgctxt "#90591" | |
| 3563 | msgid "Do you want to view the changelog for the new version?" | |
| 3564 | msgstr "Do you want to view the changelog for the new version?" | |
| 3565 | ||
| 3566 | msgctxt "#90592" | |
| 3567 | msgid "New Release v%s - Changelog" | |
| 3568 | msgstr "New Release v%s - Changelog" | |
| 3569 | ||
| 3570 | msgctxt "#90593" | |
| 3571 | msgid "[B]Update complete.[/B]" | |
| 3572 | msgstr "[B]Update complete.[/B]" | |
| 3573 | ||
| 3574 | msgctxt "#90594" | |
| 3575 | msgid "Real Debrid Auth" | |
| 3576 | msgstr "Real Debrid Auth" | |
| 3577 | ||
| 3578 | msgctxt "#90595" | |
| 3579 | msgid "Torbox Auth" | |
| 3580 | msgstr "Torbox Auth" | |
| 3581 | ||
| 3582 | msgctxt "#90596" | |
| 3583 | msgid "Debrider Auth" | |
| 3584 | msgstr "Debrider Auth" | |
| 3585 | ||
| 3586 | msgctxt "#90597" | |
| 3587 | msgid "Download Error" | |
| 3588 | msgstr "Download Error" | |
| 3589 | ||
| 3590 | msgctxt "#90598" | |
| 3591 | msgid "Failed to download text: %s" | |
| 3592 | msgstr "Failed to download text: %s" | |
| 3593 | ||
| 3594 | msgctxt "#90599" | |
| 3595 | msgid "General Error" | |
| 3596 | msgstr "General Error" | |
| 3597 | ||
| 3598 | msgctxt "#90600" | |
| 3599 | msgid "Could not display text file." | |
| 3600 | msgstr "Could not display text file." | |
| 3601 | ||
| 3602 | msgctxt "#90601" | |
| 3603 | msgid "Failed to export logs." | |
| 3604 | msgstr "Failed to export logs." | |
| 3605 | ||
| 3606 | msgctxt "#90602" | |
| 3607 | msgid "Error reading log: %s" | |
| 3608 | msgstr "Error reading log: %s" | |
| 3609 | ||
| 3610 | msgctxt "#90603" | |
| 3611 | msgid "Kodi log file not found." | |
| 3612 | msgstr "Kodi log file not found." | |
| 3613 | ||
| 3614 | msgctxt "#90604" | |
| 3615 | msgid "%s Auth" | |
| 3616 | msgstr "%s Auth" | |
| 3617 | ||
| 3618 | msgctxt "#90605" | |
| 3619 | msgid "Ok" | |
| 3620 | msgstr "Ok" | |
| 3621 | ||
| 3622 | msgctxt "#90606" | |
| 3623 | msgid "No" | |
| 3624 | msgstr "No" | |
| 3625 | ||
| 3626 | msgctxt "#90607" | |
| 3627 | msgid "Pinging addons for availability..." | |
| 3628 | msgstr "Pinging addons for availability..." | |
| 3629 | ||
| 3630 | msgctxt "#90608" | |
| 3631 | msgid "Pinging addons... %d/%d" | |
| 3632 | msgstr "Pinging addons... %d/%d" | |
| 3633 | ||
| 3634 | msgctxt "#90609" | |
| 3635 | msgid "Ping Results" | |
| 3636 | msgstr "Ping Results" | |
| 3637 | ||
| 3638 | msgctxt "#90610" | |
| 3639 | msgid "Reachable: %d\nUnreachable: %d" | |
| 3640 | msgstr "Reachable: %d\nUnreachable: %d" | |
| 3641 | ||
| 3642 | msgctxt "#90611" | |
| 3643 | msgid "Select Category to Filter" | |
| 3644 | msgstr "Select Category to Filter" | |
| 3645 | ||
| 3646 | msgctxt "#90612" | |
| 3647 | msgid "Stremio Add-ons Import" | |
| 3648 | msgstr "Stremio Add-ons Import" | |
| 3649 | ||
| 3650 | msgctxt "#90613" | |
| 3651 | msgid "To import your add-ons, please log in with your Stremio email and password." | |
| 3652 | msgstr "To import your add-ons, please log in with your Stremio email and password." | |
| 3653 | ||
| 3654 | msgctxt "#90614" | |
| 3655 | msgid "Enter your Email" | |
| 3656 | msgstr "Enter your Email" | |
| 3657 | ||
| 3658 | msgctxt "#90615" | |
| 3659 | msgid "Enter your Password" | |
| 3660 | msgstr "Enter your Password" | |
| 3661 | ||
| 3662 | msgctxt "#90616" | |
| 3663 | msgid "Login Failed" | |
| 3664 | msgstr "Login Failed" | |
| 3665 | ||
| 3666 | msgctxt "#90617" | |
| 3667 | msgid "Failed to login: %s" | |
| 3668 | msgstr "Failed to login: %s" | |
| 3669 | ||
| 3670 | msgctxt "#90618" | |
| 3671 | msgid "Add-ons Import Failed" | |
| 3672 | msgstr "Add-ons Import Failed" | |
| 3673 | ||
| 3674 | msgctxt "#90619" | |
| 3675 | msgid "Please try again later and report the issue if the problem persists. For more details, check the log file." | |
| 3676 | msgstr "Please try again later and report the issue if the problem persists. For more details, check the log file." | |
| 3677 | ||
| 3678 | msgctxt "#90620" | |
| 3679 | msgid "Addons Imported" | |
| 3680 | msgstr "Addons Imported" | |
| 3681 | ||
| 3682 | msgctxt "#90621" | |
| 3683 | msgid "Successfully imported addons from your account." | |
| 3684 | msgstr "Successfully imported addons from your account." | |
| 3685 | ||
| 3686 | msgctxt "#90622" | |
| 3687 | msgid "Update Stremio Addons" | |
| 3688 | msgstr "Update Stremio Addons" | |
| 3689 | ||
| 3690 | msgctxt "#90623" | |
| 3691 | msgid "Do you want to update the Addons from your account?" | |
| 3692 | msgstr "Do you want to update the Addons from your account?" | |
| 3693 | ||
| 3694 | msgctxt "#90624" | |
| 3695 | msgid "Log Out from Stremio" | |
| 3696 | msgstr "Log Out from Stremio" | |
| 3697 | ||
| 3698 | msgctxt "#90625" | |
| 3699 | msgid "Are you sure you want to log out? You can continue using Stremio without logging in, but your settings will be reset to the default configuration." | |
| 3700 | msgstr "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 | ||
| 3702 | msgctxt "#90626" | |
| 3703 | msgid "Log Out" | |
| 3704 | msgstr "Log Out" | |
| 3705 | ||
| 3706 | msgctxt "#90627" | |
| 3707 | msgid "Use Existing" | |
| 3708 | msgstr "Use Existing" | |
| 3709 | ||
| 3710 | msgctxt "#90628" | |
| 3711 | msgid "Download New" | |
| 3712 | msgstr "Download New" | |
| 3713 | ||
| 3714 | msgctxt "#90629" | |
| 3715 | msgid "Playback Failed" | |
| 3716 | msgstr "Playback Failed" | |
| 3717 | ||
| 3718 | msgctxt "#90630" | |
| 3719 | msgid "The video could not be played.\nDo you want to retry?" | |
| 3720 | msgstr "The video could not be played.\nDo you want to retry?" | |
| 3721 | ||
| 3722 | msgctxt "#90631" | |
| 3723 | msgid "Retry" | |
| 3724 | msgstr "Retry" | |
| 3725 | ||
| 3726 | msgctxt "#90632" | |
| 3727 | msgid "Information" | |
| 3728 | msgstr "Information" | |
| 3729 | ||
| 3730 | msgctxt "#90633" | |
| 3731 | msgid "Review" | |
| 3732 | msgstr "Review" | |
| 3733 | ||
| 3734 | msgctxt "#90634" | |
| 3735 | msgid "Comment" | |
| 3736 | msgstr "Comment" | |
| 3737 | ||
| 3738 | msgctxt "#90635" | |
| 3739 | msgid "Trivia" | |
| 3740 | msgstr "Trivia" | |
| 3741 | ||
| 3742 | msgctxt "#90636" | |
| 3743 | msgid "Goof" | |
| 3744 | msgstr "Goof" | |
| 3745 | ||
| 3746 | msgctxt "#90637" | |
| 3747 | msgid "Parental Guide" | |
| 3748 | msgstr "Parental Guide" | |
| 3749 | ||
| 3750 | msgctxt "#90638" | |
| 3751 | msgid "Converting Magnet...\n\n%s" | |
| 3752 | msgstr "Converting Magnet...\n\n%s" | |
| 3753 | ||
| 3754 | msgctxt "#90639" | |
| 3755 | msgid "Real Debrid Error." | |
| 3756 | msgstr "Real Debrid Error." | |
| 3757 | ||
| 3758 | msgctxt "#90640" | |
| 3759 | msgid "File already cached" | |
| 3760 | msgstr "File already cached" | |
| 3761 | ||
| 3762 | msgctxt "#90641" | |
| 3763 | msgid "Saving file to the Real Debrid Cloud" | |
| 3764 | msgstr "Saving file to the Real Debrid Cloud" | |
| 3765 | ||
| 3766 | msgctxt "#90642" | |
| 3767 | msgid "File cached" | |
| 3768 | msgstr "File cached" | |
| 3769 | ||
| 3770 | msgctxt "#90643" | |
| 3771 | msgid "Saving File to the Real Debrid Cloud...\n%s\n\n" | |
| 3772 | msgstr "Saving File to the Real Debrid Cloud...\n%s\n\n" | |
| 3773 | ||
| 3774 | msgctxt "#90644" | |
| 3775 | msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed" | |
| 3776 | msgstr "Downloading %s GB @ %s mbps from %s peers, %s %% completed" | |
| 3777 | ||
| 3778 | msgctxt "#90645" | |
| 3779 | msgid "Trakt" | |
| 3780 | msgstr "Trakt" | |
| 3781 | ||
| 3782 | msgctxt "#90651" | |
| 3783 | msgid "Easynews Account Info" | |
| 3784 | msgstr "Easynews Account Info" | |
| 3785 | ||
| 3786 | msgctxt "#90652" | |
| 3787 | msgid "DeepL API Error" | |
| 3788 | msgstr "DeepL API Error" | |
| 3789 | ||
| 3790 | msgctxt "#90653" | |
| 3791 | msgid "Download" | |
| 3792 | msgstr "Download" | |
| 3793 | ||
| 3794 | msgctxt "#90654" | |
| 3795 | msgid "Failed to start download: %s" | |
| 3796 | msgstr "Failed to start download: %s" | |
| 3797 | ||
| 3798 | msgctxt "#90655" | |
| 3799 | msgid "Modify Search" | |
| 3800 | msgstr "Modify Search" | |
| 3801 | ||
| 3802 | msgctxt "#90656" | |
| 3803 | msgid "Torbox" | |
| 3804 | msgstr "Torbox" | |
| 3805 | ||
| 3806 | msgctxt "#90657" | |
| 3807 | msgid "Easy-Debrid" | |
| 3808 | msgstr "Easy-Debrid" | |
| 3809 | ||
| 3810 | msgctxt "#90658" | |
| 3811 | msgid "All-Debrid" | |
| 3812 | msgstr "All-Debrid" | |
| 3813 | ||
| 3814 | msgctxt "#90659" | |
| 3815 | msgid "Real-Debrid" | |
| 3816 | msgstr "Real-Debrid" | |
| 3817 | ||
| 3818 | msgctxt "#90660" | |
| 3819 | msgid "Getting results from providers..." | |
| 3820 | msgstr "Getting results from providers..." | |
| 3821 | ||
| 3822 | msgctxt "#90661" | |
| 3823 | msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3824 | msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3825 | ||
| 3826 | msgctxt "#90662" | |
| 3827 | msgid "Downloads Viewer" | |
| 3828 | msgstr "Downloads Viewer" | |
| 3829 | ||
| 3830 | msgctxt "#90663" | |
| 3831 | msgid "WebDAV Text Viewer" | |
| 3832 | msgstr "WebDAV Text Viewer" | |
| 3833 | ||
| 3834 | msgctxt "#90664" | |
| 3835 | msgid "Selected Item" | |
| 3836 | msgstr "Selected Item" | |
| 3837 | ||
| 3838 | msgctxt "#90665" | |
| 3839 | msgid "Subtitle No. %d" | |
| 3840 | msgstr "Subtitle No. %d" | |
| 3841 | ||
| 3842 | msgctxt "#90666" | |
| 3843 | msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?" | |
| 3844 | msgstr "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?" | |
| 3845 | ||
| 3846 | msgctxt "#90667" | |
| 3847 | msgid "Free DeepL Usage" | |
| 3848 | msgstr "Free DeepL Usage" | |
| 3849 | ||
| 3850 | msgctxt "#90668" | |
| 3851 | msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?" | |
| 3852 | msgstr "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?" | |
| 3853 | ||
| 3854 | msgctxt "#90669" | |
| 3855 | msgid "Translation Cost" | |
| 3856 | msgstr "Translation Cost" | |
| 3857 | ||
| 3858 | msgctxt "#90672" | |
| 3859 | msgid "Play Trailer" | |
| 3860 | msgstr "Play Trailer" | |
| 3861 | ||
| 3862 | msgctxt "#90673" | |
| 3863 | msgid "Trailer unavailable" | |
| 3864 | msgstr "Trailer unavailable" | |
| 3865 | ||
| 3866 | msgctxt "#90690" | |
| 3867 | msgid "Clear All Items" | |
| 3868 | msgstr "Clear All Items" | |
| 3869 | ||
| 3870 | msgctxt "#90691" | |
| 3871 | msgid "Remove all items from this library?" | |
| 3872 | msgstr "Remove all items from this library?" | |
| 3873 | ||
| 3874 | msgctxt "#90692" | |
| 3875 | msgid "Library cleared" | |
| 3876 | msgstr "Library cleared" | |
| 3877 | ||
| 3878 | msgctxt "#90693" | |
| 3879 | msgid "No items to remove" | |
| 3880 | msgstr "No items to remove" | |
| 3881 | ||
| 3882 | msgctxt "#90694" | |
| 3883 | msgid "Added to %s cloud but still downloading. Please wait before playing." | |
| 3884 | msgstr "Added to %s cloud but still downloading. Please wait before playing." | |
| 3885 | ||
| 3886 | msgctxt "#90695" | |
| 3887 | msgid "Torrent Not Ready" | |
| 3888 | msgstr "Torrent Not Ready" | |
| 3889 | ||
| 3890 | msgctxt "#90696" | |
| 3891 | msgid "This torrent is still downloading in %s and is not ready yet." | |
| 3892 | msgstr "This torrent is still downloading in %s and is not ready yet." | |
| 3893 | ||
| 3894 | msgctxt "#90697" | |
| 3895 | msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?" | |
| 3896 | msgstr "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?" | |
| 3897 | ||
| 3898 | msgctxt "#90698" | |
| 3899 | msgid "Play with Torrent" | |
| 3900 | msgstr "Play with Torrent" | |
| 3901 | ||
| 3902 | msgctxt "#90699" | |
| 3903 | msgid "Cancel & Wait" | |
| 3904 | msgstr "Cancel & Wait" | |
| 3905 | ||
| 3906 | msgctxt "#90712" | |
| 3907 | msgid "Views" | |
| 3908 | msgstr "Views" | |
| 3909 | ||
| 3910 | msgctxt "#90713" | |
| 3911 | msgid "Set Main View" | |
| 3912 | msgstr "Set Main View" | |
| 3913 | ||
| 3914 | msgctxt "#90714" | |
| 3915 | msgid "Set Movies View" | |
| 3916 | msgstr "Set Movies View" | |
| 3917 | ||
| 3918 | msgctxt "#90715" | |
| 3919 | msgid "Set TV Shows View" | |
| 3920 | msgstr "Set TV Shows View" | |
| 3921 | ||
| 3922 | msgctxt "#90716" | |
| 3923 | msgid "Set Seasons View" | |
| 3924 | msgstr "Set Seasons View" | |
| 3925 | ||
| 3926 | msgctxt "#90717" | |
| 3927 | msgid "Set Episodes View" | |
| 3928 | msgstr "Set Episodes View" | |
| 3929 | ||
| 3930 | msgctxt "#90718" | |
| 3931 | msgid "Set Library View" | |
| 3932 | msgstr "Set Library View" | |
| 3933 | ||
| 3934 | msgctxt "#90719" | |
| 3935 | msgid "Set History View" | |
| 3936 | msgstr "Set History View" | |
| 3937 | ||
| 3938 | msgctxt "#90720" | |
| 3939 | msgid "Set Downloads View" | |
| 3940 | msgstr "Set Downloads View" | |
| 3941 | ||
| 3942 | msgctxt "#90721" | |
| 3943 | msgid "Save Current View" | |
| 3944 | msgstr "Save Current View" | |
| 3945 | ||
| 3946 | msgctxt "#90722" | |
| 3947 | msgid "View saved" | |
| 3948 | msgstr "View saved" | |
| 3949 | ||
| 3950 | msgctxt "#90723" | |
| 3951 | msgid "Could not detect the current view" | |
| 3952 | msgstr "Could not detect the current view" | |
| 3953 | ||
| 3954 | msgctxt "#90724" | |
| 3955 | msgid "Saved views reset" | |
| 3956 | msgstr "Saved views reset" | |
| 3957 | ||
| 3958 | msgctxt "#90725" | |
| 3959 | msgid "Reset Saved Views" | |
| 3960 | msgstr "Reset Saved Views" | |
| 3961 | ||
| 3962 | msgctxt "#90726" | |
| 3963 | msgid "Open this screen, change the Kodi view manually, then select Save Current View." | |
| 3964 | msgstr "Open this screen, change the Kodi view manually, then select Save Current View." | |
| 3965 | ||
| 3966 | msgctxt "#90742" | |
| 3967 | msgid "Addon Settings" | |
| 3968 | msgstr "Addon Settings" | |
| 3969 | ||
| 3970 | msgctxt "#90743" | |
| 3971 | msgid "Set Views" | |
| 3972 | msgstr "Set Views" | |
| 3973 | ||
| 3974 | msgctxt "#90730" | |
| 3975 | msgid "Search with title + year" | |
| 3976 | msgstr "Search with title + year" | |
| 3977 | ||
| 3978 | msgctxt "#90731" | |
| 3979 | msgid "Search with original title" | |
| 3980 | msgstr "Search with original title" | |
| 3981 | ||
| 3982 | msgctxt "#90732" | |
| 3983 | msgid "Search with original title + year" | |
| 3984 | msgstr "Search with original title + year" | |
| 3985 | ||
| 3986 | msgctxt "#90733" | |
| 3987 | msgid "Search modes" | |
| 3988 | msgstr "Search modes" | |
| 3989 | ||
| 3990 | msgctxt "#90734" | |
| 3991 | msgid "Enter year" | |
| 3992 | msgstr "Enter year" | |
| 3993 | ||
| 3994 | msgctxt "#90735" | |
| 3995 | msgid "Invalid year" | |
| 3996 | msgstr "Invalid year" | |
| 3997 | ||
| 3998 | msgctxt "#90736" | |
| 3999 | msgid "Edit title" | |
| 4000 | msgstr "Edit title" | |
| 4001 | ||
| 4002 | msgctxt "#90512" | |
| 4003 | msgid "Season" | |
| 4004 | msgstr "Season" | |
| 4005 | ||
| 4006 | msgctxt "#90737" | |
| 4007 | msgid "Invalid season" | |
| 4008 | msgstr "Invalid season" | |
| 4009 | ||
| 4010 | msgctxt "#90738" | |
| 4011 | msgid "Episode" | |
| 4012 | msgstr "Episode" | |
| 4013 | ||
| 4014 | msgctxt "#90739" | |
| 4015 | msgid "Invalid episode" | |
| 4016 | msgstr "Invalid episode" | |
| 4017 | ||
| 4018 | msgctxt "#90740" | |
| 4019 | msgid "Edit current title" | |
| 4020 | msgstr "Edit current title" | |
| 4021 | ||
| 4022 | msgctxt "#90741" | |
| 4023 | msgid "Use original title" | |
| 4024 | msgstr "Use original title" | |
| 4025 | ||
| 4026 | msgctxt "#90744" | |
| 4027 | msgid "Upload Subtitle" | |
| 4028 | msgstr "Upload Subtitle" | |
| 4029 | ||
| 4030 | msgctxt "#90745" | |
| 4031 | msgid "Select Subtitle File" | |
| 4032 | msgstr "Select Subtitle File" | |
| 4033 | ||
| 4034 | msgctxt "#90746" | |
| 4035 | msgid "Local File" | |
| 4036 | msgstr "Local File" | |
| 4037 | ||
| 4038 | msgctxt "#90747" | |
| 4039 | msgid "Upload from Browser" | |
| 4040 | msgstr "Upload from Browser" | |
| 4041 | ||
| 4042 | msgctxt "#90748" | |
| 4043 | msgid "Upload subtitle from your browser" | |
| 4044 | msgstr "Upload subtitle from your browser" | |
| 4045 | ||
| 4046 | msgctxt "#90749" | |
| 4047 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" | |
| 4048 | msgstr "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" | |
| 4049 | ||
| 4050 | msgctxt "#90750" | |
| 4051 | msgid "Search Title Language" | |
| 4052 | msgstr "Search Title Language" | |
| 4053 | ||
| 4054 | msgctxt "#90751" | |
| 4055 | msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches." | |
| 4056 | msgstr "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches." | |
| 4057 | ||
| 4058 | msgctxt "#90752" | |
| 4059 | msgid "English first" | |
| 4060 | msgstr "English first" | |
| 4061 | ||
| 4062 | msgctxt "#90753" | |
| 4063 | msgid "English only" | |
| 4064 | msgstr "English only" | |
| 4065 | ||
| 4066 | msgctxt "#90754" | |
| 4067 | msgid "Localized first" | |
| 4068 | msgstr "Localized first" |
| … | … | @@ -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 | # | |
| 6 | msgid "" | |
| 7 | msgstr "" | |
| 8 | "Report-Msgid-Bugs-To: \n" | |
| 9 | "POT-Creation-Date: 2023-09-19 19:53+0200\n" | |
| 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | |
| 11 | "Language: 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 | ||
| 18 | msgctxt "#30000" | |
| 19 | msgid "Players" | |
| 20 | msgstr "Players" | |
| 21 | ||
| 22 | msgctxt "#30001" | |
| 23 | msgid "Clients" | |
| 24 | msgstr "Clientes:" | |
| 25 | ||
| 26 | msgctxt "#30002" | |
| 27 | msgid "Torrent Client" | |
| 28 | msgstr "Cliente" | |
| 29 | ||
| 30 | msgctxt "#30020" | |
| 31 | msgid "Indexers" | |
| 32 | msgstr "Indexadores" | |
| 33 | ||
| 34 | msgctxt "#30021" | |
| 35 | msgid "Indexer" | |
| 36 | msgstr "Indexador" | |
| 37 | ||
| 38 | msgctxt "#30022" | |
| 39 | msgid "Jackett Configuration:" | |
| 40 | msgstr "Configuração do Jackett:" | |
| 41 | ||
| 42 | msgctxt "#30023" | |
| 43 | msgid "Client Key" | |
| 44 | msgstr "Chave da API do Jackett" | |
| 45 | ||
| 46 | msgctxt "#30025" | |
| 47 | msgid "Host" | |
| 48 | msgstr "Host do Jackett" | |
| 49 | ||
| 50 | msgctxt "#30026" | |
| 51 | msgid "Description length" | |
| 52 | msgstr "Tamanho da descrição" | |
| 53 | ||
| 54 | msgctxt "#30027" | |
| 55 | msgid "Insecured connection" | |
| 56 | msgstr "Conexão insegura" | |
| 57 | ||
| 58 | msgctxt "#30028" | |
| 59 | msgid "Sort by" | |
| 60 | msgstr "Ordenar por" | |
| 61 | ||
| 62 | msgctxt "#30029" | |
| 63 | msgid "Total Results" | |
| 64 | msgstr "Total de resultados" | |
| 65 | ||
| 66 | msgctxt "#30030" | |
| 67 | msgid "Timeout" | |
| 68 | msgstr "Tempo limite" | |
| 69 | ||
| 70 | msgctxt "#30031" | |
| 71 | msgid "Timeout" | |
| 72 | msgstr "Tempo limite" | |
| 73 | ||
| 74 | msgctxt "#30050" | |
| 75 | msgid "Prowlarr Configuration:" | |
| 76 | msgstr "Configuração do Prowlarr:" | |
| 77 | ||
| 78 | msgctxt "#30051" | |
| 79 | msgid "Client Key" | |
| 80 | msgstr "Chave da API do Prowlarr" | |
| 81 | ||
| 82 | msgctxt "#30052" | |
| 83 | msgid "Host" | |
| 84 | msgstr "Host do Prowlarr" | |
| 85 | ||
| 86 | msgctxt "#30053" | |
| 87 | msgid "Indexers-Ids" | |
| 88 | msgstr "IDs dos Indexadores " | |
| 89 | ||
| 90 | msgctxt "#30054" | |
| 91 | msgid "Anime IndexersIds" | |
| 92 | msgstr "IDs dos Indexadores para Anime" | |
| 93 | ||
| 94 | msgctxt "#30100" | |
| 95 | msgid "TMDB" | |
| 96 | msgstr "TMDB" | |
| 97 | ||
| 98 | msgctxt "#30102" | |
| 99 | msgid "TMDB Configuration" | |
| 100 | msgstr "Configuração do TMDB:" | |
| 101 | ||
| 102 | msgctxt "#30103" | |
| 103 | msgid "Client Key" | |
| 104 | msgstr "Chave da API do TMDB" | |
| 105 | ||
| 106 | msgctxt "#30104" | |
| 107 | msgid "Clear tmdb cache" | |
| 108 | msgstr "Limpar cache do TMDB" | |
| 109 | ||
| 110 | msgctxt "#30105" | |
| 111 | msgid "Cache expiration hours" | |
| 112 | msgstr "Expiração do cache (Horas)" | |
| 113 | ||
| 114 | msgctxt "#30106" | |
| 115 | msgid "Clear Cache" | |
| 116 | msgstr "Limpar o cache" | |
| 117 | ||
| 118 | msgctxt "#30107" | |
| 119 | msgid "Enable Cache" | |
| 120 | msgstr "Habilitar cache" | |
| 121 | ||
| 122 | msgctxt "#30140" | |
| 123 | msgid "AniList" | |
| 124 | msgstr "AniList" | |
| 125 | ||
| 126 | msgctxt "#30142" | |
| 127 | msgid "AniList Configuration:" | |
| 128 | msgstr "Configuração do AniList:" | |
| 129 | ||
| 130 | msgctxt "#30143" | |
| 131 | msgid "Client ID" | |
| 132 | msgstr "ID do cliente" | |
| 133 | ||
| 134 | msgctxt "#30144" | |
| 135 | msgid "Client Secret" | |
| 136 | msgstr "Segredo do cliente" | |
| 137 | ||
| 138 | msgctxt "#30150" | |
| 139 | msgid "Services" | |
| 140 | msgstr "Services" | |
| 141 | ||
| 142 | msgctxt "#30152" | |
| 143 | msgid "Real Debrid Configuration" | |
| 144 | msgstr "Configuração do Real Debrid" | |
| 145 | ||
| 146 | msgctxt "#30154" | |
| 147 | msgid "Token" | |
| 148 | msgstr "Token:" | |
| 149 | ||
| 150 | msgctxt "#30200" | |
| 151 | msgid "Fanart.TV" | |
| 152 | msgstr "Fanart.TV" | |
| 153 | ||
| 154 | msgctxt "#30202" | |
| 155 | msgid "Fanart.TV Configuration:" | |
| 156 | msgstr "Configuração do Fanart.TV:" | |
| 157 | ||
| 158 | msgctxt "#30203" | |
| 159 | msgid "Client ID" | |
| 160 | msgstr "ID do cliente" | |
| 161 | ||
| 162 | msgctxt "#30221" | |
| 163 | msgid "No Client Key provided for" | |
| 164 | msgstr "Nenhuma chave de API fornecida para" | |
| 165 | ||
| 166 | msgctxt "#30223" | |
| 167 | msgid "You need to configure" | |
| 168 | msgstr "Você precisa configurar" | |
| 169 | ||
| 170 | msgctxt "#30228" | |
| 171 | msgid "The request to Torrentio failed." | |
| 172 | msgstr "A solicitação para o Torrentio falhou." | |
| 173 | ||
| 174 | msgctxt "#30229" | |
| 175 | msgid "The request to Jackett failed." | |
| 176 | msgstr "A solicitação para o Jackett falhou." | |
| 177 | ||
| 178 | msgctxt "#30230" | |
| 179 | msgid "The request to Prowlarr failed." | |
| 180 | msgstr "A solicitação para o Prowlarr falhou." | |
| 181 | ||
| 182 | msgctxt "#30231" | |
| 183 | msgid "The request to Elhosted failed." | |
| 184 | msgstr "A solicitação para o Elhosted falhou." | |
| 185 | ||
| 186 | msgctxt "#30232" | |
| 187 | msgid "The request to Jackgram failed." | |
| 188 | msgstr "A solicitação para o Jackgram falhou." | |
| 189 | ||
| 190 | msgctxt "#30233" | |
| 191 | msgid "The request to Mediafusion failed." | |
| 192 | msgstr "A solicitação para o Mediafusion falhou." | |
| 193 | ||
| 194 | msgctxt "#30234" | |
| 195 | msgid "The request to Peerflix failed." | |
| 196 | msgstr "A solicitação para o Peerflix falhou." | |
| 197 | ||
| 198 | msgctxt "#30235" | |
| 199 | msgid "Stremio" | |
| 200 | msgstr "Stremio" | |
| 201 | ||
| 202 | msgctxt "#30236" | |
| 203 | msgid "Stremio add-ons" | |
| 204 | msgstr "Add-ons do Stremio" | |
| 205 | ||
| 206 | msgctxt "#30237" | |
| 207 | msgid "Import your config" | |
| 208 | msgstr "Importar suas configurações" | |
| 209 | ||
| 210 | msgctxt "#30238" | |
| 211 | msgid "Manage add-ons" | |
| 212 | msgstr "Gerenciar add-ons" | |
| 213 | ||
| 214 | msgctxt "#30240" | |
| 215 | msgid "TMDB cache cleared" | |
| 216 | msgstr "Cache do TMDB limpo" | |
| 217 | ||
| 218 | msgctxt "#30241" | |
| 219 | msgid "Search on TMDB:" | |
| 220 | msgstr "Pesquisar no TMDB:" | |
| 221 | ||
| 222 | msgctxt "#30242" | |
| 223 | msgid "Search on AniList:" | |
| 224 | msgstr "Pesquisar no AniList:" | |
| 225 | ||
| 226 | msgctxt "#30243" | |
| 227 | msgid "Search for a keyword:" | |
| 228 | msgstr "Pesquisar por uma palavra-chave:" | |
| 229 | ||
| 230 | msgctxt "#30244" | |
| 231 | msgid "Cache cleared" | |
| 232 | msgstr "Cache limpo" | |
| 233 | ||
| 234 | msgctxt "#30249" | |
| 235 | msgid "Addon Jacktook Burst not found - (script.jacktook.burst)" | |
| 236 | msgstr "Addon Jacktook Burst not found - (script.jacktook.burst)" | |
| 237 | ||
| 238 | msgctxt "#30250" | |
| 239 | msgid "Addon Torrest not found - (plugin.video.torrest)" | |
| 240 | msgstr "Addon Torrest não encontrado - (plugin.video.torrest)" | |
| 241 | ||
| 242 | msgctxt "#30251" | |
| 243 | msgid "No sources found to play" | |
| 244 | msgstr "Nenhuma fonte encontrada para reproduzir" | |
| 245 | ||
| 246 | msgctxt "#30252" | |
| 247 | msgid "Addon Elementum not found - (plugin.video.elementum)" | |
| 248 | msgstr "Addon Elementum não encontrado - (plugin.video.elementum)" | |
| 249 | ||
| 250 | msgctxt "#30253" | |
| 251 | msgid "Addon Jacktorr not found - (plugin.video.jacktorr)" | |
| 252 | msgstr "Addon Jacktorr não encontrado - (plugin.video.jacktorr)" | |
| 253 | ||
| 254 | msgctxt "#30254" | |
| 255 | msgid "Do you want to install Elementum?" | |
| 256 | msgstr "Deseja instalar Elementum?" | |
| 257 | ||
| 258 | msgctxt "#30255" | |
| 259 | msgid "Do you want to install Jacktorr?" | |
| 260 | msgstr "Deseja instalar Jacktorr?" | |
| 261 | ||
| 262 | msgctxt "#30256" | |
| 263 | msgid "Do you want to install Torrest?" | |
| 264 | msgstr "Deseja instalar Torrest?" | |
| 265 | ||
| 266 | msgctxt "#30257" | |
| 267 | msgid "Do you want to install Jacktook Burst?" | |
| 268 | msgstr "Deseja instalar Jacktook Burst?" | |
| 269 | ||
| 270 | msgctxt "#30650" | |
| 271 | msgid "Queued" | |
| 272 | msgstr "Adicionado à fila" | |
| 273 | ||
| 274 | msgctxt "#30651" | |
| 275 | msgid "Checking" | |
| 276 | msgstr "Verificando" | |
| 277 | ||
| 278 | msgctxt "#30652" | |
| 279 | msgid "Finding" | |
| 280 | msgstr "Encontrando" | |
| 281 | ||
| 282 | msgctxt "#30653" | |
| 283 | msgid "Downloading" | |
| 284 | msgstr "Baixando" | |
| 285 | ||
| 286 | msgctxt "#30654" | |
| 287 | msgid "Finished" | |
| 288 | msgstr "Finalizado" | |
| 289 | ||
| 290 | msgctxt "#30655" | |
| 291 | msgid "Seeding" | |
| 292 | msgstr "Semeando" | |
| 293 | ||
| 294 | msgctxt "#30656" | |
| 295 | msgid "Allocating" | |
| 296 | msgstr "Alocando" | |
| 297 | ||
| 298 | msgctxt "#30657" | |
| 299 | msgid "Checking resume data" | |
| 300 | msgstr "Verificando dados para reproduzir" | |
| 301 | ||
| 302 | msgctxt "#30658" | |
| 303 | msgid "Paused" | |
| 304 | msgstr "Pausado" | |
| 305 | ||
| 306 | msgctxt "#30659" | |
| 307 | msgid "Buffering" | |
| 308 | msgstr "Carregando" | |
| 309 | ||
| 310 | msgctxt "#30660" | |
| 311 | msgid "Unknown" | |
| 312 | msgstr "Desconhecido" | |
| 313 | ||
| 314 | msgctxt "#30700" | |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "Carregar e reproduzir" | |
| 317 | ||
| 318 | msgctxt "#30701" | |
| 319 | msgid "Stop downloading" | |
| 320 | msgstr "Parar o download" | |
| 321 | ||
| 322 | msgctxt "#30702" | |
| 323 | msgid "Downloads" | |
| 324 | msgstr "Downloads" | |
| 325 | ||
| 326 | msgctxt "#30703" | |
| 327 | msgid "Resume" | |
| 328 | msgstr "Retomar" | |
| 329 | ||
| 330 | msgctxt "#30704" | |
| 331 | msgid "Pause" | |
| 332 | msgstr "Pausar" | |
| 333 | ||
| 334 | msgctxt "#30705" | |
| 335 | msgid "Remove torrent" | |
| 336 | msgstr "Remover torrent" | |
| 337 | ||
| 338 | msgctxt "#30706" | |
| 339 | msgid "Remove torrent and files" | |
| 340 | msgstr "Remover torrent e arquivos" | |
| 341 | ||
| 342 | msgctxt "#30707" | |
| 343 | msgid "Torrent status" | |
| 344 | msgstr "Status do Torrent" | |
| 345 | ||
| 346 | msgctxt "#30708" | |
| 347 | msgid "Stop" | |
| 348 | msgstr "Parar" | |
| 349 | ||
| 350 | msgctxt "#30709" | |
| 351 | msgid "Drop torrent" | |
| 352 | msgstr "Drop torrent" | |
| 353 | ||
| 354 | msgctxt "#30710" | |
| 355 | msgid "Include Season Packs" | |
| 356 | msgstr "Incluir pacotes de temporadas" | |
| 357 | ||
| 358 | msgctxt "#30711" | |
| 359 | msgid "Torrent Settings" | |
| 360 | msgstr "Configurações de Torrent" | |
| 361 | ||
| 362 | msgctxt "#30712" | |
| 363 | msgid "General" | |
| 364 | msgstr "Geral" | |
| 365 | ||
| 366 | msgctxt "#30713" | |
| 367 | msgid "Enable" | |
| 368 | msgstr "Habilitar" | |
| 369 | ||
| 370 | msgctxt "#30714" | |
| 371 | msgid "Jacktorr" | |
| 372 | msgstr "Jacktorr" | |
| 373 | ||
| 374 | msgctxt "#30715" | |
| 375 | msgid "Torrest" | |
| 376 | msgstr "Torrest" | |
| 377 | ||
| 378 | msgctxt "#30716" | |
| 379 | msgid "Elementum" | |
| 380 | msgstr "Elementum" | |
| 381 | ||
| 382 | msgctxt "#30717" | |
| 383 | msgid "General" | |
| 384 | msgstr "Geral" | |
| 385 | ||
| 386 | msgctxt "#30718" | |
| 387 | msgid "Auto-Play" | |
| 388 | msgstr "Reprodução automática" | |
| 389 | ||
| 390 | msgctxt "#30719" | |
| 391 | msgid "Enable Next Episode Dialog" | |
| 392 | msgstr "Ativar diálogo de próximo episódio" | |
| 393 | ||
| 394 | msgctxt "#30720" | |
| 395 | msgid "Seconds left before show Next Episode Dialog" | |
| 396 | msgstr "Segundos restantes antes de mostrar diálogo de próximo episódio" | |
| 397 | ||
| 398 | msgctxt "#30721" | |
| 399 | msgid "Cache Enabled" | |
| 400 | msgstr "Cache ativado" | |
| 401 | ||
| 402 | msgctxt "#30722" | |
| 403 | msgid "Cache Expiration (hours)" | |
| 404 | msgstr "Expiração do cache (horas)" | |
| 405 | ||
| 406 | msgctxt "#30723" | |
| 407 | msgid "Cache" | |
| 408 | msgstr "Cache" | |
| 409 | ||
| 410 | msgctxt "#30724" | |
| 411 | msgid "Update" | |
| 412 | msgstr "Atualizar" | |
| 413 | ||
| 414 | msgctxt "#30725" | |
| 415 | msgid "Indexers Configuration" | |
| 416 | msgstr "Indexers Configuração" | |
| 417 | ||
| 418 | msgctxt "#30726" | |
| 419 | msgid "Sort" | |
| 420 | msgstr "Ordenar" | |
| 421 | ||
| 422 | msgctxt "#30727" | |
| 423 | msgid "Quality" | |
| 424 | msgstr "Qualidade" | |
| 425 | ||
| 426 | msgctxt "#30938" | |
| 427 | msgid "Silent Resume" | |
| 428 | msgstr "Retomar Silenciosamente" | |
| 429 | ||
| 430 | msgctxt "#30939" | |
| 431 | msgid "Auto-resume from last position without asking" | |
| 432 | msgstr "Retomar automaticamente da última posição sem perguntar" | |
| 433 | ||
| 434 | msgctxt "#30728" | |
| 435 | msgid "Seeds" | |
| 436 | msgstr "Sementes" | |
| 437 | ||
| 438 | msgctxt "#30729" | |
| 439 | msgid "Size" | |
| 440 | msgstr "Tamanho" | |
| 441 | ||
| 442 | msgctxt "#30730" | |
| 443 | msgid "Cached" | |
| 444 | msgstr "Em cache" | |
| 445 | ||
| 446 | msgctxt "#30731" | |
| 447 | msgid "Date" | |
| 448 | msgstr "Data" | |
| 449 | ||
| 450 | msgctxt "#30732" | |
| 451 | msgid "None" | |
| 452 | msgstr "Nenhum" | |
| 453 | ||
| 454 | msgctxt "#30733" | |
| 455 | msgid "Torrentio Configuration" | |
| 456 | msgstr "Torrentio Configuração" | |
| 457 | ||
| 458 | msgctxt "#30734" | |
| 459 | msgid "Enable" | |
| 460 | msgstr "Habilitar" | |
| 461 | ||
| 462 | msgctxt "#30736" | |
| 463 | msgid "Subtitle Language" | |
| 464 | msgstr "Idioma das legendas" | |
| 465 | ||
| 466 | msgctxt "#30737" | |
| 467 | msgid "GB" | |
| 468 | msgstr "GB" | |
| 469 | ||
| 470 | msgctxt "#30738" | |
| 471 | msgid "JP" | |
| 472 | msgstr "JP" | |
| 473 | ||
| 474 | msgctxt "#30739" | |
| 475 | msgid "RU" | |
| 476 | msgstr "RU" | |
| 477 | ||
| 478 | msgctxt "#30740" | |
| 479 | msgid "IT" | |
| 480 | msgstr "IT" | |
| 481 | ||
| 482 | msgctxt "#30741" | |
| 483 | msgid "PT" | |
| 484 | msgstr "PT" | |
| 485 | ||
| 486 | msgctxt "#30742" | |
| 487 | msgid "ES" | |
| 488 | msgstr "ES" | |
| 489 | ||
| 490 | msgctxt "#30743" | |
| 491 | msgid "MX" | |
| 492 | msgstr "MX" | |
| 493 | ||
| 494 | msgctxt "#30744" | |
| 495 | msgid "KR" | |
| 496 | msgstr "KR" | |
| 497 | ||
| 498 | msgctxt "#30745" | |
| 499 | msgid "CN" | |
| 500 | msgstr "CN" | |
| 501 | ||
| 502 | msgctxt "#30746" | |
| 503 | msgid "TW" | |
| 504 | msgstr "TW" | |
| 505 | ||
| 506 | msgctxt "#30747" | |
| 507 | msgid "FR" | |
| 508 | msgstr "FR" | |
| 509 | ||
| 510 | msgctxt "#30748" | |
| 511 | msgid "DE" | |
| 512 | msgstr "DE" | |
| 513 | ||
| 514 | msgctxt "#30749" | |
| 515 | msgid "NL" | |
| 516 | msgstr "NL" | |
| 517 | ||
| 518 | msgctxt "#30750" | |
| 519 | msgid "IN" | |
| 520 | msgstr "IN" | |
| 521 | ||
| 522 | msgctxt "#30751" | |
| 523 | msgid "PL" | |
| 524 | msgstr "PL" | |
| 525 | ||
| 526 | msgctxt "#30752" | |
| 527 | msgid "LT" | |
| 528 | msgstr "LT" | |
| 529 | ||
| 530 | msgctxt "#30753" | |
| 531 | msgid "CZ" | |
| 532 | msgstr "CZ" | |
| 533 | ||
| 534 | msgctxt "#30754" | |
| 535 | msgid "SK" | |
| 536 | msgstr "SK" | |
| 537 | ||
| 538 | msgctxt "#30755" | |
| 539 | msgid "SI" | |
| 540 | msgstr "SI" | |
| 541 | ||
| 542 | msgctxt "#30756" | |
| 543 | msgid "HU" | |
| 544 | msgstr "HU" | |
| 545 | ||
| 546 | msgctxt "#30757" | |
| 547 | msgid "RO" | |
| 548 | msgstr "RO" | |
| 549 | ||
| 550 | msgctxt "#30758" | |
| 551 | msgid "HR" | |
| 552 | msgstr "HR" | |
| 553 | ||
| 554 | msgctxt "#30759" | |
| 555 | msgid "UA" | |
| 556 | msgstr "UA" | |
| 557 | ||
| 558 | msgctxt "#30760" | |
| 559 | msgid "GR" | |
| 560 | msgstr "GR" | |
| 561 | ||
| 562 | msgctxt "#30761" | |
| 563 | msgid "DK" | |
| 564 | msgstr "DK" | |
| 565 | ||
| 566 | msgctxt "#30762" | |
| 567 | msgid "FI" | |
| 568 | msgstr "FI" | |
| 569 | ||
| 570 | msgctxt "#30763" | |
| 571 | msgid "SE" | |
| 572 | msgstr "SE" | |
| 573 | ||
| 574 | msgctxt "#30764" | |
| 575 | msgid "NO" | |
| 576 | msgstr "NO" | |
| 577 | ||
| 578 | msgctxt "#30765" | |
| 579 | msgid "TR" | |
| 580 | msgstr "TR" | |
| 581 | ||
| 582 | msgctxt "#30766" | |
| 583 | msgid "Torrentio Providers" | |
| 584 | msgstr "Provedores Torrentio" | |
| 585 | ||
| 586 | msgctxt "#30767" | |
| 587 | msgid "Jackgram Configuration" | |
| 588 | msgstr "Jackgram Configuração" | |
| 589 | ||
| 590 | msgctxt "#30768" | |
| 591 | msgid "Host" | |
| 592 | msgstr "Host" | |
| 593 | ||
| 594 | msgctxt "#30769" | |
| 595 | msgid "Elfhosted/KnightCrawler Configuration" | |
| 596 | msgstr "Elfhosted/KnightCrawler Configuração" | |
| 597 | ||
| 598 | msgctxt "#30770" | |
| 599 | msgid "Language" | |
| 600 | msgstr "Idioma" | |
| 601 | ||
| 602 | msgctxt "#30771" | |
| 603 | msgid "Zilean Configuration" | |
| 604 | msgstr "Zilean Configuração" | |
| 605 | ||
| 606 | msgctxt "#30772" | |
| 607 | msgid "Zilean Host" | |
| 608 | msgstr "Host Zilean" | |
| 609 | ||
| 610 | msgctxt "#30774" | |
| 611 | msgid "Zilean Timeout" | |
| 612 | msgstr "Zilean Tempo limite" | |
| 613 | ||
| 614 | msgctxt "#30775" | |
| 615 | msgid "Jackett Configuration" | |
| 616 | msgstr "Jackett Configuração" | |
| 617 | ||
| 618 | msgctxt "#30776" | |
| 619 | msgid "Prowlarr Configuration" | |
| 620 | msgstr "Prowlarr Configuração" | |
| 621 | ||
| 622 | msgctxt "#30777" | |
| 623 | msgid "MediaFusion configuration" | |
| 624 | msgstr "Configuração MediaFusion" | |
| 625 | ||
| 626 | msgctxt "#30779" | |
| 627 | msgid "Manifest Url" | |
| 628 | msgstr "URL do Manifesto" | |
| 629 | ||
| 630 | msgctxt "#30780" | |
| 631 | msgid "Jacktook Burst Configuration" | |
| 632 | msgstr "Jacktook Burst Configuração" | |
| 633 | ||
| 634 | msgctxt "#30781" | |
| 635 | msgid "Burst Configuration" | |
| 636 | msgstr "Burst Configuração" | |
| 637 | ||
| 638 | msgctxt "#30782" | |
| 639 | msgid "User" | |
| 640 | msgstr "Usuário" | |
| 641 | ||
| 642 | msgctxt "#30783" | |
| 643 | msgid "Authorize" | |
| 644 | msgstr "Autorizar" | |
| 645 | ||
| 646 | msgctxt "#30784" | |
| 647 | msgid "Remove Authorization" | |
| 648 | msgstr "Remover Autorização" | |
| 649 | ||
| 650 | msgctxt "#30785" | |
| 651 | msgid "Premiumize Configuration" | |
| 652 | msgstr "Premiumize Configuração" | |
| 653 | ||
| 654 | msgctxt "#30786" | |
| 655 | msgid "Enabled" | |
| 656 | msgstr "Habilitado" | |
| 657 | ||
| 658 | msgctxt "#30787" | |
| 659 | msgid "Token" | |
| 660 | msgstr "Token" | |
| 661 | ||
| 662 | msgctxt "#30788" | |
| 663 | msgid "TorBox Configuration" | |
| 664 | msgstr "TorBox Configuração" | |
| 665 | ||
| 666 | msgctxt "#30789" | |
| 667 | msgid "Debrider Configuration" | |
| 668 | msgstr "Debrider Configuração" | |
| 669 | ||
| 670 | msgctxt "#30790" | |
| 671 | msgid "Uncached" | |
| 672 | msgstr "Não em cache" | |
| 673 | ||
| 674 | msgctxt "#30791" | |
| 675 | msgid "Show uncached torrents" | |
| 676 | msgstr "Mostrar torrents não em cache" | |
| 677 | ||
| 678 | msgctxt "#30792" | |
| 679 | msgid "Use Kodi Language" | |
| 680 | msgstr "Usar idioma do Kodi" | |
| 681 | ||
| 682 | msgctxt "#30793" | |
| 683 | msgid "Trakt" | |
| 684 | msgstr "Trakt" | |
| 685 | ||
| 686 | msgctxt "#30794" | |
| 687 | msgid "Trakt Configuration" | |
| 688 | msgstr "Trakt Configuração" | |
| 689 | ||
| 690 | msgctxt "#30795" | |
| 691 | msgid "Expires" | |
| 692 | msgstr "Expira" | |
| 693 | ||
| 694 | msgctxt "#30796" | |
| 695 | msgid "Refresh" | |
| 696 | msgstr "Atualizar" | |
| 697 | ||
| 698 | msgctxt "#30797" | |
| 699 | msgid "Token" | |
| 700 | msgstr "Token" | |
| 701 | ||
| 702 | msgctxt "#30798" | |
| 703 | msgid "Trakt Client" | |
| 704 | msgstr "Cliente Trakt" | |
| 705 | ||
| 706 | msgctxt "#30800" | |
| 707 | msgid "Trakt Secret" | |
| 708 | msgstr "Segredo Trakt" | |
| 709 | ||
| 710 | msgctxt "#30802" | |
| 711 | msgid "Log out" | |
| 712 | msgstr "Sair" | |
| 713 | ||
| 714 | msgctxt "#30804" | |
| 715 | msgid "Menus" | |
| 716 | msgstr "Menus" | |
| 717 | ||
| 718 | msgctxt "#30805" | |
| 719 | msgid "Show Telegram Menu" | |
| 720 | msgstr "Mostrar menu Telegram" | |
| 721 | ||
| 722 | msgctxt "#30806" | |
| 723 | msgid "Clear cache when update" | |
| 724 | msgstr "Limpar cache na atualização" | |
| 725 | ||
| 726 | msgctxt "#30807" | |
| 727 | msgid "Peerfix Configuration" | |
| 728 | msgstr "Peerfix Configuração" | |
| 729 | ||
| 730 | msgctxt "#30894" | |
| 731 | msgid "Show Changelog" | |
| 732 | msgstr "Mostrar log de alterações" | |
| 733 | ||
| 734 | msgctxt "#30895" | |
| 735 | msgid "View the latest changes and updates to the addon." | |
| 736 | msgstr "Ver as últimas alterações e atualizações do addon." | |
| 737 | ||
| 738 | msgctxt "#30896" | |
| 739 | msgid "Calendar" | |
| 740 | msgstr "Calendário" | |
| 741 | ||
| 742 | msgctxt "#30897" | |
| 743 | msgid "Use broadcast day adjustment" | |
| 744 | msgstr "Usar ajuste de dia de transmissão" | |
| 745 | ||
| 746 | msgctxt "#30898" | |
| 747 | msgid "Adjust broadcast day based on manual offset" | |
| 748 | msgstr "Ajustar o dia da transmissão com base no desvio manual" | |
| 749 | ||
| 750 | msgctxt "#30899" | |
| 751 | msgid "Broadcast Offset (Hours)" | |
| 752 | msgstr "Broadcast Offset (Hours)" | |
| 753 | ||
| 754 | msgctxt "#30900" | |
| 755 | msgid "Manual hour adjustment for broadcast day simulation" | |
| 756 | msgstr "Ajuste manual da hora para simulação do dia de transmissão" | |
| 757 | ||
| 758 | msgctxt "#30905" | |
| 759 | msgid "Trakt Calendar Previous Days" | |
| 760 | msgstr "Dias passados do calendário Trakt" | |
| 761 | ||
| 762 | msgctxt "#30906" | |
| 763 | msgid "Number of days in the past to show in the Trakt calendar." | |
| 764 | msgstr "Número de dias passados a mostrar no calendário Trakt." | |
| 765 | ||
| 766 | msgctxt "#30907" | |
| 767 | msgid "Trakt Calendar Future Days" | |
| 768 | msgstr "Dias futuros do calendário Trakt" | |
| 769 | ||
| 770 | msgctxt "#30908" | |
| 771 | msgid "Number of days in the future to show in the Trakt calendar." | |
| 772 | msgstr "Número de dias futuros a mostrar no calendário Trakt." | |
| 773 | ||
| 774 | msgctxt "#30909" | |
| 775 | msgid "Show All Shows in Calendar" | |
| 776 | msgstr "Exibir todas as séries no calendário" | |
| 777 | ||
| 778 | msgctxt "#30918" | |
| 779 | msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead." | |
| 780 | msgstr "{provider} não pode reproduzir diretamente releases compactados (.rar/.zip). Escolha uma fonte com arquivos de vídeo." | |
| 781 | ||
| 782 | msgctxt "#30910" | |
| 783 | msgid "Show all airing shows instead of just your watchlist/collection." | |
| 784 | msgstr "Exibe todas as séries em exibição em vez de apenas as da sua lista/coleção." | |
| 785 | ||
| 786 | msgctxt "#30911" | |
| 787 | msgid "Your Torbox account username/email." | |
| 788 | msgstr "Nome de usuário/e-mail da sua conta Torbox." | |
| 789 | ||
| 790 | msgctxt "#30912" | |
| 791 | msgid "Authorize add-on to access Torbox account." | |
| 792 | msgstr "Autorizar o add-on a acessar a conta Torbox." | |
| 793 | ||
| 794 | msgctxt "#30913" | |
| 795 | msgid "Revoke Torbox account access." | |
| 796 | msgstr "Revogar o acesso à conta Torbox." | |
| 797 | ||
| 798 | msgctxt "#30914" | |
| 799 | msgid "Trakt Sync Interval (minutes)" | |
| 800 | msgstr "Intervalo de sincronizacao do Trakt (minutos)" | |
| 801 | ||
| 802 | msgctxt "#30915" | |
| 803 | msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running." | |
| 804 | msgstr "Com que frequencia o Jacktook verifica mudancas remotas de atividade no Trakt enquanto o Kodi estiver em execucao." | |
| 805 | ||
| 806 | msgctxt "#30916" | |
| 807 | msgid "Account Info" | |
| 808 | msgstr "Informacoes da conta Trakt" | |
| 809 | ||
| 810 | msgctxt "#30917" | |
| 811 | msgid "View your Trakt account profile and library stats." | |
| 812 | msgstr "Veja o perfil da sua conta Trakt e as estatisticas da biblioteca." | |
| 813 | ||
| 814 | msgctxt "#30924" | |
| 815 | msgid "Trakt List Name" | |
| 816 | msgstr "Nome da lista Trakt" | |
| 817 | ||
| 818 | msgctxt "#30925" | |
| 819 | msgid "Trakt List Description" | |
| 820 | msgstr "Descricao da lista Trakt" | |
| 821 | ||
| 822 | msgctxt "#30926" | |
| 823 | msgid "Create Trakt List" | |
| 824 | msgstr "Criar lista Trakt" | |
| 825 | ||
| 826 | msgctxt "#30927" | |
| 827 | msgid "Choose Trakt List" | |
| 828 | msgstr "Escolher lista Trakt" | |
| 829 | ||
| 830 | msgctxt "#30936" | |
| 831 | msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position." | |
| 832 | msgstr "Ativa ou desativa o scrobbling do Trakt e retoma a reproducao da ultima posicao registrada." | |
| 833 | ||
| 834 | msgctxt "#30937" | |
| 835 | msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view." | |
| 836 | msgstr "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 | ||
| 838 | msgctxt "#40000" | |
| 839 | msgid "Arabic (United Arab Emirates)" | |
| 840 | msgstr "Árabe (Emirados Árabes Unidos)" | |
| 841 | ||
| 842 | msgctxt "#40001" | |
| 843 | msgid "Arabic (Saudi Arabia)" | |
| 844 | msgstr "Árabe (Arábia Saudita)" | |
| 845 | ||
| 846 | msgctxt "#40002" | |
| 847 | msgid "Belarusian (Belarus)" | |
| 848 | msgstr "Belarusian (Belarus)" | |
| 849 | ||
| 850 | msgctxt "#40003" | |
| 851 | msgid "Bulgarian (Bulgaria)" | |
| 852 | msgstr "Búlgaro (Bulgaria)" | |
| 853 | ||
| 854 | msgctxt "#40004" | |
| 855 | msgid "Bengali (Bangladesh)" | |
| 856 | msgstr "Bengali (Bangladesh)" | |
| 857 | ||
| 858 | msgctxt "#40005" | |
| 859 | msgid "Catalan (Spain)" | |
| 860 | msgstr "Catalão (Espanha)" | |
| 861 | ||
| 862 | msgctxt "#40006" | |
| 863 | msgid "Chamorro (Guam)" | |
| 864 | msgstr "Chamorro (Guam)" | |
| 865 | ||
| 866 | msgctxt "#40007" | |
| 867 | msgid "Czech (Czech Republic)" | |
| 868 | msgstr "Tcheco (República Tcheca)" | |
| 869 | ||
| 870 | msgctxt "#40008" | |
| 871 | msgid "Danish (Denmark)" | |
| 872 | msgstr "Dinamarquês (Dinamarca)" | |
| 873 | ||
| 874 | msgctxt "#40009" | |
| 875 | msgid "German (Austria)" | |
| 876 | msgstr "Alemão (Austria)" | |
| 877 | ||
| 878 | msgctxt "#40010" | |
| 879 | msgid "German (Switzerland)" | |
| 880 | msgstr "Alemão (Suíça)" | |
| 881 | ||
| 882 | msgctxt "#40011" | |
| 883 | msgid "German (Germany)" | |
| 884 | msgstr "Alemão (Alemanha)" | |
| 885 | ||
| 886 | msgctxt "#40012" | |
| 887 | msgid "Greek (Greece)" | |
| 888 | msgstr "Grego (Greece)" | |
| 889 | ||
| 890 | msgctxt "#40013" | |
| 891 | msgid "English (Australia)" | |
| 892 | msgstr "Inglês (Austrália)" | |
| 893 | ||
| 894 | msgctxt "#40014" | |
| 895 | msgid "English (Canada)" | |
| 896 | msgstr "Inglês (Canadá)" | |
| 897 | ||
| 898 | msgctxt "#40015" | |
| 899 | msgid "English (United Kingdom)" | |
| 900 | msgstr "Inglês (Reino Unido)" | |
| 901 | ||
| 902 | msgctxt "#40016" | |
| 903 | msgid "English (Ireland)" | |
| 904 | msgstr "Inglês (Ireland)" | |
| 905 | ||
| 906 | msgctxt "#40017" | |
| 907 | msgid "English (New Zealand)" | |
| 908 | msgstr "Inglês (New Zealand)" | |
| 909 | ||
| 910 | msgctxt "#40018" | |
| 911 | msgid "English (United States of America)" | |
| 912 | msgstr "Inglês (United States of America)" | |
| 913 | ||
| 914 | msgctxt "#40019" | |
| 915 | msgid "Esperanto (None)" | |
| 916 | msgstr "Esperanto (None)" | |
| 917 | ||
| 918 | msgctxt "#40020" | |
| 919 | msgid "Spanish (Spain)" | |
| 920 | msgstr "Espanhol (Espanha)" | |
| 921 | ||
| 922 | msgctxt "#40021" | |
| 923 | msgid "Spanish (Mexico)" | |
| 924 | msgstr "Espanhol (México)" | |
| 925 | ||
| 926 | msgctxt "#40022" | |
| 927 | msgid "Estonian (Estonia)" | |
| 928 | msgstr "Estonian (Estonia)" | |
| 929 | ||
| 930 | msgctxt "#40023" | |
| 931 | msgid "Basque (Spain)" | |
| 932 | msgstr "Basque (Espanha)" | |
| 933 | ||
| 934 | msgctxt "#40024" | |
| 935 | msgid "Persian (Iran)" | |
| 936 | msgstr "Persian (Iran)" | |
| 937 | ||
| 938 | msgctxt "#40025" | |
| 939 | msgid "Finnish (Finland)" | |
| 940 | msgstr "Finlandês (Finland)" | |
| 941 | ||
| 942 | msgctxt "#40026" | |
| 943 | msgid "French (Canada)" | |
| 944 | msgstr "Francês (Canadá)" | |
| 945 | ||
| 946 | msgctxt "#40027" | |
| 947 | msgid "French (France)" | |
| 948 | msgstr "Francês (França)" | |
| 949 | ||
| 950 | msgctxt "#40028" | |
| 951 | msgid "Galician (Spain)" | |
| 952 | msgstr "Galician (Espanha)" | |
| 953 | ||
| 954 | msgctxt "#40029" | |
| 955 | msgid "Hebrew (Israel)" | |
| 956 | msgstr "Hebrew (Israel)" | |
| 957 | ||
| 958 | msgctxt "#40030" | |
| 959 | msgid "Hindi (India)" | |
| 960 | msgstr "Hindi (Índia)" | |
| 961 | ||
| 962 | msgctxt "#40031" | |
| 963 | msgid "Hungarian (Hungary)" | |
| 964 | msgstr "Húngaro (Hungary)" | |
| 965 | ||
| 966 | msgctxt "#40032" | |
| 967 | msgid "Indonesian (Indonesia)" | |
| 968 | msgstr "Indonésio (Indonesia)" | |
| 969 | ||
| 970 | msgctxt "#40033" | |
| 971 | msgid "Italian (Italy)" | |
| 972 | msgstr "Italiano (Itália)" | |
| 973 | ||
| 974 | msgctxt "#40034" | |
| 975 | msgid "Japanese (Japan)" | |
| 976 | msgstr "Japonês (Japão)" | |
| 977 | ||
| 978 | msgctxt "#40035" | |
| 979 | msgid "Georgian (Georgia)" | |
| 980 | msgstr "Georgian (Georgia)" | |
| 981 | ||
| 982 | msgctxt "#40036" | |
| 983 | msgid "Kazakh (Kazakhstan)" | |
| 984 | msgstr "Kazakh (Kazakhstan)" | |
| 985 | ||
| 986 | msgctxt "#40037" | |
| 987 | msgid "Kannada (India)" | |
| 988 | msgstr "Kannada (Índia)" | |
| 989 | ||
| 990 | msgctxt "#40038" | |
| 991 | msgid "Korean (South Korea)" | |
| 992 | msgstr "Coreano (Coreia do Sul)" | |
| 993 | ||
| 994 | msgctxt "#40039" | |
| 995 | msgid "Lithuanian (Lithuania)" | |
| 996 | msgstr "Lithuanian (Lithuania)" | |
| 997 | ||
| 998 | msgctxt "#40040" | |
| 999 | msgid "Latvian (Latvia)" | |
| 1000 | msgstr "Latvian (Latvia)" | |
| 1001 | ||
| 1002 | msgctxt "#40041" | |
| 1003 | msgid "Malayalam (India)" | |
| 1004 | msgstr "Malayalam (Índia)" | |
| 1005 | ||
| 1006 | msgctxt "#40042" | |
| 1007 | msgid "Malay (Malaysia)" | |
| 1008 | msgstr "Malay (Malaysia)" | |
| 1009 | ||
| 1010 | msgctxt "#40043" | |
| 1011 | msgid "Malay (Singapore)" | |
| 1012 | msgstr "Malay (Singapore)" | |
| 1013 | ||
| 1014 | msgctxt "#40044" | |
| 1015 | msgid "Norwegian Bokmål (Norway)" | |
| 1016 | msgstr "Norwegian Bokmål (Noruega)" | |
| 1017 | ||
| 1018 | msgctxt "#40045" | |
| 1019 | msgid "Dutch (Netherlands)" | |
| 1020 | msgstr "Holandês (Países Baixos)" | |
| 1021 | ||
| 1022 | msgctxt "#40046" | |
| 1023 | msgid "Norwegian (Norway)" | |
| 1024 | msgstr "Norueguês (Noruega)" | |
| 1025 | ||
| 1026 | msgctxt "#40047" | |
| 1027 | msgid "Polish (Poland)" | |
| 1028 | msgstr "Polonês (Polônia)" | |
| 1029 | ||
| 1030 | msgctxt "#40048" | |
| 1031 | msgid "Portuguese (Brazil)" | |
| 1032 | msgstr "Português (Brasil)" | |
| 1033 | ||
| 1034 | msgctxt "#40049" | |
| 1035 | msgid "Portuguese (Portugal)" | |
| 1036 | msgstr "Português (Portugal)" | |
| 1037 | ||
| 1038 | msgctxt "#40050" | |
| 1039 | msgid "Romanian (Romania)" | |
| 1040 | msgstr "Romeno (Romania)" | |
| 1041 | ||
| 1042 | msgctxt "#40051" | |
| 1043 | msgid "Russian (Russia)" | |
| 1044 | msgstr "Russo (Rússia)" | |
| 1045 | ||
| 1046 | msgctxt "#40052" | |
| 1047 | msgid "Sinhalese (Sri Lanka)" | |
| 1048 | msgstr "Sinhalese (Sri Lanka)" | |
| 1049 | ||
| 1050 | msgctxt "#40053" | |
| 1051 | msgid "Slovak (Slovakia)" | |
| 1052 | msgstr "Slovak (Slovakia)" | |
| 1053 | ||
| 1054 | msgctxt "#40054" | |
| 1055 | msgid "Slovenian (Slovenia)" | |
| 1056 | msgstr "Slovenian (Slovenia)" | |
| 1057 | ||
| 1058 | msgctxt "#40055" | |
| 1059 | msgid "Serbian (Serbia)" | |
| 1060 | msgstr "Serbian (Serbia)" | |
| 1061 | ||
| 1062 | msgctxt "#40056" | |
| 1063 | msgid "Swedish (Sweden)" | |
| 1064 | msgstr "Sueco (Suécia)" | |
| 1065 | ||
| 1066 | msgctxt "#40057" | |
| 1067 | msgid "Tamil (India)" | |
| 1068 | msgstr "Tamil (Índia)" | |
| 1069 | ||
| 1070 | msgctxt "#40058" | |
| 1071 | msgid "Telugu (India)" | |
| 1072 | msgstr "Telugu (Índia)" | |
| 1073 | ||
| 1074 | msgctxt "#40059" | |
| 1075 | msgid "Thai (Thailand)" | |
| 1076 | msgstr "Tailandês (Tailândia)" | |
| 1077 | ||
| 1078 | msgctxt "#40060" | |
| 1079 | msgid "Tagalog (Philippines)" | |
| 1080 | msgstr "Tagalog (Philippines)" | |
| 1081 | ||
| 1082 | msgctxt "#40061" | |
| 1083 | msgid "Turkish (Turkey)" | |
| 1084 | msgstr "Turco (Turkey)" | |
| 1085 | ||
| 1086 | msgctxt "#40062" | |
| 1087 | msgid "Ukrainian (Ukraine)" | |
| 1088 | msgstr "Ucraniano (Ucrânia)" | |
| 1089 | ||
| 1090 | msgctxt "#40063" | |
| 1091 | msgid "Vietnamese (Vietnam)" | |
| 1092 | msgstr "Vietnamita (Vietnã)" | |
| 1093 | ||
| 1094 | msgctxt "#40064" | |
| 1095 | msgid "Mandarin (China)" | |
| 1096 | msgstr "Mandarin (China)" | |
| 1097 | ||
| 1098 | msgctxt "#40065" | |
| 1099 | msgid "Mandarin (Hong Kong)" | |
| 1100 | msgstr "Mandarin (Hong Kong)" | |
| 1101 | ||
| 1102 | msgctxt "#40066" | |
| 1103 | msgid "Mandarin (Taiwan)" | |
| 1104 | msgstr "Mandarin (Taiwan)" | |
| 1105 | ||
| 1106 | msgctxt "#40067" | |
| 1107 | msgid "Zulu (South Africa)" | |
| 1108 | msgstr "Zulu (South Africa)" | |
| 1109 | ||
| 1110 | msgctxt "#42418" | |
| 1111 | msgid "Language and country" | |
| 1112 | msgstr "Idioma e país" | |
| 1113 | ||
| 1114 | msgctxt "#42419" | |
| 1115 | msgid "The language and country settings used when looking up data from TMDb" | |
| 1116 | msgstr "As configurações de idioma e país utilizadas ao procurar dados no TMDb" | |
| 1117 | ||
| 1118 | msgctxt "#42420" | |
| 1119 | msgid "EN" | |
| 1120 | msgstr "EN" | |
| 1121 | ||
| 1122 | msgctxt "#30801" | |
| 1123 | msgid "Zilean server address (include port if required)." | |
| 1124 | msgstr "Endereço do servidor Zilean (inclua a porta se necessário)." | |
| 1125 | ||
| 1126 | msgctxt "#30803" | |
| 1127 | msgid "Display torrents not yet cached on your debrid service." | |
| 1128 | msgstr "Exibir torrents ainda não armazenados em cache no seu serviço debrid." | |
| 1129 | ||
| 1130 | msgctxt "#30808" | |
| 1131 | msgid "Fanart.tv Client Key (for artwork)." | |
| 1132 | msgstr "ID do cliente da API Fanart.tv (para imagens)." | |
| 1133 | ||
| 1134 | msgctxt "#30809" | |
| 1135 | msgid "Improve performance by caching metadata." | |
| 1136 | msgstr "Melhore o desempenho armazenando metadados em cache." | |
| 1137 | ||
| 1138 | msgctxt "#30810" | |
| 1139 | msgid "Seconds before Jackett requests time out (e.g., 30)." | |
| 1140 | msgstr "Segundos antes do tempo limite das requisições do Jackett (ex.: 30)." | |
| 1141 | ||
| 1142 | msgctxt "#30811" | |
| 1143 | msgid "Trakt client secret from OAuth application settings." | |
| 1144 | msgstr "Segredo do cliente Trakt das configurações do aplicativo OAuth." | |
| 1145 | ||
| 1146 | msgctxt "#30812" | |
| 1147 | msgid "Limit TV show results to specific seasons/episodes." | |
| 1148 | msgstr "Limitar resultados de séries a temporadas/episódios específicos." | |
| 1149 | ||
| 1150 | msgctxt "#30813" | |
| 1151 | msgid "Hours before cached data is automatically refreshed (e.g., 24)." | |
| 1152 | msgstr "Horas antes dos dados em cache serem atualizados automaticamente (ex.: 24)." | |
| 1153 | ||
| 1154 | msgctxt "#30814" | |
| 1155 | msgid "Premiumize Client Token (get from account page)." | |
| 1156 | msgstr "Token da API Premiumize (obtenha na página da conta)." | |
| 1157 | ||
| 1158 | msgctxt "#30815" | |
| 1159 | msgid "Jackett server URL (e.g., http://localhost)." | |
| 1160 | msgstr "URL do servidor Jackett (ex.: http://localhost:9117)." | |
| 1161 | ||
| 1162 | msgctxt "#30816" | |
| 1163 | msgid "Display Telegram integration options in UI." | |
| 1164 | msgstr "Exibir opções de integração do Telegram na interface." | |
| 1165 | ||
| 1166 | msgctxt "#30817" | |
| 1167 | msgid "Your Real-Debrid account username/email." | |
| 1168 | msgstr "Nome de usuário/e-mail da sua conta Real-Debrid." | |
| 1169 | ||
| 1170 | msgctxt "#30818" | |
| 1171 | msgid "Trakt client ID from https://trakt.tv/oauth/apps." | |
| 1172 | msgstr "ID do cliente Trakt de https://trakt.tv/oauth/apps." | |
| 1173 | ||
| 1174 | msgctxt "#30819" | |
| 1175 | msgid "Connect Trakt.tv account to sync watch progress." | |
| 1176 | msgstr "Conecte sua conta Trakt.tv para sincronizar o progresso de visualização." | |
| 1177 | ||
| 1178 | msgctxt "#30820" | |
| 1179 | msgid "Seconds before Zilean requests time out (e.g., 30)." | |
| 1180 | msgstr "Segundos antes do tempo limite das requisições do Zilean (ex.: 30)." | |
| 1181 | ||
| 1182 | msgctxt "#30821" | |
| 1183 | msgid "Your Jackett Client Key (found in dashboard)." | |
| 1184 | msgstr "Sua chave da API Jackett (encontrada no painel)." | |
| 1185 | ||
| 1186 | msgctxt "#30822" | |
| 1187 | msgid "Sort results by criteria (e.g., 'date', 'title', 'size')." | |
| 1188 | msgstr "Classificar resultados por critérios (ex.: 'data', 'título', 'tamanho')." | |
| 1189 | ||
| 1190 | msgctxt "#30823" | |
| 1191 | msgid "Configure Burst mode settings and providers." | |
| 1192 | msgstr "Configurar ajustes e provedores do modo Burst." | |
| 1193 | ||
| 1194 | msgctxt "#30824" | |
| 1195 | msgid "Enable Premiumize.me cloud service." | |
| 1196 | msgstr "Habilitar o serviço de nuvem Premiumize.me." | |
| 1197 | ||
| 1198 | msgctxt "#30825" | |
| 1199 | msgid "Truncate descriptions after [X] characters (0 for full text)." | |
| 1200 | msgstr "Truncar descrições após [X] caracteres (0 para texto completo)." | |
| 1201 | ||
| 1202 | msgctxt "#30826" | |
| 1203 | msgid "Enable Prowlarr indexer management." | |
| 1204 | msgstr "Habilitar gerenciamento de indexadores do Prowlarr." | |
| 1205 | ||
| 1206 | msgctxt "#30827" | |
| 1207 | msgid "Jackgram server URL (e.g., http://localhost:9117)." | |
| 1208 | msgstr "URL do servidor Jackgram (ex.: http://localhost:9117)." | |
| 1209 | ||
| 1210 | msgctxt "#30828" | |
| 1211 | msgid "Debrider Client Key for authentication." | |
| 1212 | msgstr "Chave da API Debrider para autenticação." | |
| 1213 | ||
| 1214 | msgctxt "#30829" | |
| 1215 | msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)." | |
| 1216 | msgstr "Atraso (em segundos) antes do diálogo do próximo episódio aparecer (ex.: 10)." | |
| 1217 | ||
| 1218 | msgctxt "#30830" | |
| 1219 | msgid "Specify the torrent client." | |
| 1220 | msgstr "Especifique o executável do cliente de torrent." | |
| 1221 | ||
| 1222 | msgctxt "#30831" | |
| 1223 | msgid "Enable Debrider cached torrent support." | |
| 1224 | msgstr "Habilitar suporte a torrents em cache do EasyDebrid." | |
| 1225 | ||
| 1226 | msgctxt "#30832" | |
| 1227 | msgid "Torbox Client Token (found in account settings)." | |
| 1228 | msgstr "Token da API Torbox (encontrado nas configurações da conta)." | |
| 1229 | ||
| 1230 | msgctxt "#30833" | |
| 1231 | msgid "Trakt refresh token (auto-managed)." | |
| 1232 | msgstr "Token de atualização do Trakt (gerenciado automaticamente)." | |
| 1233 | ||
| 1234 | msgctxt "#30834" | |
| 1235 | msgid "Show confirmation dialog before playing next episode." | |
| 1236 | msgstr "Mostrar diálogo de confirmação antes de reproduzir o próximo episódio." | |
| 1237 | ||
| 1238 | msgctxt "#30835" | |
| 1239 | msgid "Authorize add-on to access Real-Debrid account." | |
| 1240 | msgstr "Autorizar a extensão a acessar a conta Real-Debrid." | |
| 1241 | ||
| 1242 | msgctxt "#30836" | |
| 1243 | msgid "Enable torrent-based media content" | |
| 1244 | msgstr "Habilitar conteúdo de mídia via torrent." | |
| 1245 | ||
| 1246 | msgctxt "#30837" | |
| 1247 | msgid "Indicates if Real-Debrid account is linked." | |
| 1248 | msgstr "Indica se a conta Real-Debrid está vinculada." | |
| 1249 | ||
| 1250 | msgctxt "#30838" | |
| 1251 | msgid "Automatically play next media item when current playback ends." | |
| 1252 | msgstr "Reproduzir automaticamente o próximo item ao terminar a reprodução atual." | |
| 1253 | ||
| 1254 | msgctxt "#30839" | |
| 1255 | msgid "Check for and install the latest add-on updates." | |
| 1256 | msgstr "Verificar e instalar as atualizações mais recentes da extensão." | |
| 1257 | ||
| 1258 | msgctxt "#30840" | |
| 1259 | msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)." | |
| 1260 | msgstr "IDs dos indexadores do Prowlarr a serem usados, separados por vírgula (ex.: 1,5,7)." | |
| 1261 | ||
| 1262 | msgctxt "#30841" | |
| 1263 | msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)." | |
| 1264 | msgstr "Tempo limite (segundos) para requisições do Prowlarr (ex.: 20)." | |
| 1265 | ||
| 1266 | msgctxt "#30842" | |
| 1267 | msgid "Trakt authorization expiration date (read-only)." | |
| 1268 | msgstr "Data de expiração da autorização do Trakt (somente leitura)." | |
| 1269 | ||
| 1270 | msgctxt "#30843" | |
| 1271 | msgid "Enable Torbox.de cloud torrent integration." | |
| 1272 | msgstr "Habilitar integração com torrents na nuvem do Torbox.de." | |
| 1273 | ||
| 1274 | msgctxt "#30844" | |
| 1275 | msgid "Trakt Access Token (auto-filled after auth)." | |
| 1276 | msgstr "Token de acesso da API Trakt (preenchido automaticamente após autenticação)." | |
| 1277 | ||
| 1278 | msgctxt "#30845" | |
| 1279 | msgid "Link Premiumize.me account to add-on." | |
| 1280 | msgstr "Vincular conta Premiumize.me à extensão." | |
| 1281 | ||
| 1282 | msgctxt "#30846" | |
| 1283 | msgid "Clear cached data automatically after updating the add-on." | |
| 1284 | msgstr "Limpar dados em cache automaticamente após atualizar a extensão." | |
| 1285 | ||
| 1286 | msgctxt "#30847" | |
| 1287 | msgid "Click to delete all cached data immediately." | |
| 1288 | msgstr "Clique para excluir todos os dados em cache imediatamente." | |
| 1289 | ||
| 1290 | msgctxt "#30848" | |
| 1291 | msgid "Real-Debrid Client Token (auto-filled after auth)." | |
| 1292 | msgstr "Token da API Real-Debrid (preenchido automaticamente após autenticação)." | |
| 1293 | ||
| 1294 | msgctxt "#30849" | |
| 1295 | msgid "Enable Jackett torrent proxy support." | |
| 1296 | msgstr "Habilitar suporte ao proxy de torrents do Jackett." | |
| 1297 | ||
| 1298 | msgctxt "#30850" | |
| 1299 | msgid "Enable Jackgram integration for enhanced search." | |
| 1300 | msgstr "Habilitar integração do Jackgram para buscas aprimoradas." | |
| 1301 | ||
| 1302 | msgctxt "#30851" | |
| 1303 | msgid "Prowlarr instance URL (e.g., http://localhost)." | |
| 1304 | msgstr "URL da instância Prowlarr (ex.: http://localhost:9696)." | |
| 1305 | ||
| 1306 | msgctxt "#30852" | |
| 1307 | msgid "Maximum number of search results to display (e.g., 50)." | |
| 1308 | msgstr "Número máximo de resultados de busca a exibir (ex.: 50)." | |
| 1309 | ||
| 1310 | msgctxt "#30853" | |
| 1311 | msgid "Revoke Real-Debrid account access." | |
| 1312 | msgstr "Revogar acesso à conta Real-Debrid." | |
| 1313 | ||
| 1314 | msgctxt "#30854" | |
| 1315 | msgid "Prowlarr Client Key (found in Settings > General)." | |
| 1316 | msgstr "Chave da API Prowlarr (encontrada em Configurações > Geral)." | |
| 1317 | ||
| 1318 | msgctxt "#30855" | |
| 1319 | msgid "Trakt.tv username (auto-populated after auth)." | |
| 1320 | msgstr "Nome de usuário Trakt.tv (preenchido automaticamente após autenticação)." | |
| 1321 | ||
| 1322 | msgctxt "#30856" | |
| 1323 | msgid "Disconnect Trakt.tv account from add-on." | |
| 1324 | msgstr "Desconectar conta Trakt.tv da extensão." | |
| 1325 | ||
| 1326 | msgctxt "#30857" | |
| 1327 | msgid "Enable Zilean integration for metadata fetching." | |
| 1328 | msgstr "Habilitar integração do Zilean para obtenção de metadados." | |
| 1329 | ||
| 1330 | msgctxt "#30858" | |
| 1331 | msgid "Your Client Key (required for metadata)" | |
| 1332 | msgstr "Sua chave da API TMDB (necessária para metadados)." | |
| 1333 | ||
| 1334 | msgctxt "#30859" | |
| 1335 | msgid "Enable Burst mode for accelerated searches." | |
| 1336 | msgstr "Habilitar modo Burst para buscas aceleradas." | |
| 1337 | ||
| 1338 | msgctxt "#30860" | |
| 1339 | msgid "Others" | |
| 1340 | msgstr "Outros" | |
| 1341 | ||
| 1342 | msgctxt "#30861" | |
| 1343 | msgid "Filters" | |
| 1344 | msgstr "Filters" | |
| 1345 | ||
| 1346 | msgctxt "#30862" | |
| 1347 | msgid "Torrent (P2P) Configuration" | |
| 1348 | msgstr "Torrent (P2P) Configuração" | |
| 1349 | ||
| 1350 | msgctxt "#30863" | |
| 1351 | msgid "Specify the torrent client" | |
| 1352 | msgstr "Especificar o cliente de torrent" | |
| 1353 | ||
| 1354 | msgctxt "#30864" | |
| 1355 | msgid "Sources" | |
| 1356 | msgstr "Pesquisar" | |
| 1357 | ||
| 1358 | msgctxt "#30865" | |
| 1359 | msgid "General Filters" | |
| 1360 | msgstr "Filtro e ordem dos resultados" | |
| 1361 | ||
| 1362 | msgctxt "#30866" | |
| 1363 | msgid "Sign in to your Stremio account to access add-ons and personalized features." | |
| 1364 | msgstr "Faça login na sua conta do Stremio para acessar complementos e recursos personalizados." | |
| 1365 | ||
| 1366 | msgctxt "#30867" | |
| 1367 | msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data." | |
| 1368 | msgstr "Faça logout da sua conta do Stremio. Isso desconectará sua conta, mas não excluirá seus dados." | |
| 1369 | ||
| 1370 | msgctxt "#30868" | |
| 1371 | msgid "Log Out of Stremio" | |
| 1372 | msgstr "Sair do Stremio" | |
| 1373 | ||
| 1374 | msgctxt "#30869" | |
| 1375 | msgid "Enable Stremio add-ons integration." | |
| 1376 | msgstr "Habilitar a integração de complementos do Stremio." | |
| 1377 | ||
| 1378 | msgctxt "#30870" | |
| 1379 | msgid "Select Stream Addons" | |
| 1380 | msgstr "Alternar complementos" | |
| 1381 | ||
| 1382 | msgctxt "#30871" | |
| 1383 | msgid "Log In to Stremio" | |
| 1384 | msgstr "Fazer login no Stremio" | |
| 1385 | ||
| 1386 | msgctxt "#30872" | |
| 1387 | msgid "Enable or disable Stremio add-ons." | |
| 1388 | msgstr "Habilitar ou desabilitar complementos do Stremio." | |
| 1389 | ||
| 1390 | msgctxt "#30873" | |
| 1391 | msgid "Select language for player subtitle selection" | |
| 1392 | msgstr "Priorizar resultados no idioma selecionado" | |
| 1393 | ||
| 1394 | msgctxt "#30874" | |
| 1395 | msgid "Update Addons" | |
| 1396 | msgstr "Atualizar Addons" | |
| 1397 | ||
| 1398 | msgctxt "#30875" | |
| 1399 | msgid "Select Catalogs Addons" | |
| 1400 | msgstr "Selecionar Catalógos e Addons" | |
| 1401 | ||
| 1402 | msgctxt "#30876" | |
| 1403 | msgid "Download directory" | |
| 1404 | msgstr "Download directory" | |
| 1405 | ||
| 1406 | msgctxt "#30877" | |
| 1407 | msgid "Enable Trakt.tv Scrobbling" | |
| 1408 | msgstr "Ativar Scrobbling do Trakt.tv" | |
| 1409 | ||
| 1410 | msgctxt "#30878" | |
| 1411 | msgid "Auto select subtitle when playing" | |
| 1412 | msgstr "Selecionar legenda automaticamente" | |
| 1413 | ||
| 1414 | msgctxt "#30879" | |
| 1415 | msgid "Auto-play quality" | |
| 1416 | msgstr "Qualidade de reprodução automática" | |
| 1417 | ||
| 1418 | msgctxt "#30880" | |
| 1419 | msgid "1080p" | |
| 1420 | msgstr "1080p" | |
| 1421 | ||
| 1422 | msgctxt "#30881" | |
| 1423 | msgid "720p" | |
| 1424 | msgstr "720p" | |
| 1425 | ||
| 1426 | msgctxt "#30882" | |
| 1427 | msgid "4k" | |
| 1428 | msgstr "4k" | |
| 1429 | ||
| 1430 | msgctxt "#30883" | |
| 1431 | msgid "480p" | |
| 1432 | msgstr "480p" | |
| 1433 | ||
| 1434 | msgctxt "#30884" | |
| 1435 | msgid "Subtitles Configuration" | |
| 1436 | msgstr "Configuração de legendas" | |
| 1437 | ||
| 1438 | msgctxt "#30885" | |
| 1439 | msgid "Addon Host" | |
| 1440 | msgstr "Host do addon" | |
| 1441 | ||
| 1442 | msgctxt "#30886" | |
| 1443 | msgid "Subtitles" | |
| 1444 | msgstr "Legendas" | |
| 1445 | ||
| 1446 | msgctxt "#30887" | |
| 1447 | msgid "Primary Language" | |
| 1448 | msgstr "Idioma principal" | |
| 1449 | ||
| 1450 | msgctxt "#30888" | |
| 1451 | msgid "Secondary Language" | |
| 1452 | msgstr "Idioma secundário" | |
| 1453 | ||
| 1454 | msgctxt "#30889" | |
| 1455 | msgid "Deepl Enabled" | |
| 1456 | msgstr "Deepl ativado" | |
| 1457 | ||
| 1458 | msgctxt "#30890" | |
| 1459 | msgid "Deepl Client Key" | |
| 1460 | msgstr "Chave API do Deepl" | |
| 1461 | ||
| 1462 | msgctxt "#30891" | |
| 1463 | msgid "Translator Configuration" | |
| 1464 | msgstr "Configuração do tradutor" | |
| 1465 | ||
| 1466 | msgctxt "#30892" | |
| 1467 | msgid "Deepl Target Language" | |
| 1468 | msgstr "Idioma de destino do Deepl" | |
| 1469 | ||
| 1470 | msgctxt "#30893" | |
| 1471 | msgid "Stremio Subtitle Configuration" | |
| 1472 | msgstr "Configuração de legendas do Stremio" | |
| 1473 | ||
| 1474 | msgctxt "#90001" | |
| 1475 | msgid "Add Custom Addon" | |
| 1476 | msgstr "Adicionar Addon Personalizado" | |
| 1477 | ||
| 1478 | msgctxt "#90002" | |
| 1479 | msgid "Add a custom Stremio addon by entering its URL" | |
| 1480 | msgstr "Adicionar um addon Stremio personalizado inserindo o seu URL" | |
| 1481 | ||
| 1482 | msgctxt "#90003" | |
| 1483 | msgid "Auto-select audio when playing" | |
| 1484 | msgstr "Selecionar áudio automaticamente ao reproduzir" | |
| 1485 | ||
| 1486 | msgctxt "#90004" | |
| 1487 | msgid "Audio Language" | |
| 1488 | msgstr "Idioma do Áudio" | |
| 1489 | ||
| 1490 | msgctxt "#90005" | |
| 1491 | msgid "Select audio language for playing the video" | |
| 1492 | msgstr "Selecionar o idioma do áudio para reproduzir o vídeo" | |
| 1493 | ||
| 1494 | msgctxt "#90006" | |
| 1495 | msgid "Search" | |
| 1496 | msgstr "Pesquisar" | |
| 1497 | ||
| 1498 | msgctxt "#90007" | |
| 1499 | msgid "TV Shows" | |
| 1500 | msgstr "Séries" | |
| 1501 | ||
| 1502 | msgctxt "#90008" | |
| 1503 | msgid "Movies" | |
| 1504 | msgstr "Filmes" | |
| 1505 | ||
| 1506 | msgctxt "#90009" | |
| 1507 | msgid "Anime" | |
| 1508 | msgstr "Anime" | |
| 1509 | ||
| 1510 | msgctxt "#90010" | |
| 1511 | msgid "Live TV" | |
| 1512 | msgstr "TV ao vivo" | |
| 1513 | ||
| 1514 | msgctxt "#90011" | |
| 1515 | msgid "Direct Search" | |
| 1516 | msgstr "Pesquisa Direta" | |
| 1517 | ||
| 1518 | msgctxt "#90012" | |
| 1519 | msgid "Torrents" | |
| 1520 | msgstr "Torrents" | |
| 1521 | ||
| 1522 | msgctxt "#90013" | |
| 1523 | msgid "Telegram" | |
| 1524 | msgstr "Telegram" | |
| 1525 | ||
| 1526 | msgctxt "#90014" | |
| 1527 | msgid "Cloud" | |
| 1528 | msgstr "Nuvem" | |
| 1529 | ||
| 1530 | msgctxt "#90015" | |
| 1531 | msgid "Downloads" | |
| 1532 | msgstr "Downloads" | |
| 1533 | ||
| 1534 | msgctxt "#90016" | |
| 1535 | msgid "Settings" | |
| 1536 | msgstr "Configurações" | |
| 1537 | ||
| 1538 | msgctxt "#90017" | |
| 1539 | msgid "History" | |
| 1540 | msgstr "Histórico" | |
| 1541 | ||
| 1542 | msgctxt "#90018" | |
| 1543 | msgid "Donate" | |
| 1544 | msgstr "Doar" | |
| 1545 | ||
| 1546 | msgctxt "#90019" | |
| 1547 | msgid "Recently Watched Files" | |
| 1548 | msgstr "Arquivos assistidos recentemente" | |
| 1549 | ||
| 1550 | msgctxt "#90020" | |
| 1551 | msgid "Recently Watched Titles" | |
| 1552 | msgstr "Títulos assistidos recentemente" | |
| 1553 | ||
| 1554 | msgctxt "#90021" | |
| 1555 | msgid "TV Calendar" | |
| 1556 | msgstr "Calendário de TV" | |
| 1557 | ||
| 1558 | msgctxt "#90022" | |
| 1559 | msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:" | |
| 1560 | msgstr "Se você gosta de usar o Jacktook e quer apoiar seu desenvolvimento, você pode me pagar um café! Seu apoio ajuda a manter o projeto ativo e permite futuras melhorias:" | |
| 1561 | ||
| 1562 | msgctxt "#90023" | |
| 1563 | msgid "Support Jacktook" | |
| 1564 | msgstr "Apoiar o Jacktook" | |
| 1565 | ||
| 1566 | msgctxt "#90024" | |
| 1567 | msgid "Trending Recent" | |
| 1568 | msgstr "Tendências recentes" | |
| 1569 | ||
| 1570 | msgctxt "#90025" | |
| 1571 | msgid "Genres" | |
| 1572 | msgstr "Gêneros" | |
| 1573 | ||
| 1574 | msgctxt "#90026" | |
| 1575 | msgid "Calendar" | |
| 1576 | msgstr "Calendário" | |
| 1577 | ||
| 1578 | msgctxt "#90027" | |
| 1579 | msgid "Years" | |
| 1580 | msgstr "Anos" | |
| 1581 | ||
| 1582 | msgctxt "#90028" | |
| 1583 | msgid "Trending" | |
| 1584 | msgstr "Tendências" | |
| 1585 | ||
| 1586 | msgctxt "#90029" | |
| 1587 | msgid "Most Watched" | |
| 1588 | msgstr "Mais assistidos" | |
| 1589 | ||
| 1590 | msgctxt "#90030" | |
| 1591 | msgid "Most Favorited" | |
| 1592 | msgstr "Mais favoritados" | |
| 1593 | ||
| 1594 | msgctxt "#90031" | |
| 1595 | msgid "Popular Lists" | |
| 1596 | msgstr "Listas populares" | |
| 1597 | ||
| 1598 | msgctxt "#90032" | |
| 1599 | msgid "Trending Lists" | |
| 1600 | msgstr "Listas de tendências" | |
| 1601 | ||
| 1602 | msgctxt "#90033" | |
| 1603 | msgid "Recomendations" | |
| 1604 | msgstr "Recomendações" | |
| 1605 | ||
| 1606 | msgctxt "#90034" | |
| 1607 | msgid "Watched History" | |
| 1608 | msgstr "Histórico de visualização" | |
| 1609 | ||
| 1610 | msgctxt "#90035" | |
| 1611 | msgid "Watchlist" | |
| 1612 | msgstr "Watchlist" | |
| 1613 | ||
| 1614 | msgctxt "#90036" | |
| 1615 | msgid "Top 10 BoxOffice" | |
| 1616 | msgstr "Top 10 Bilheteria" | |
| 1617 | ||
| 1618 | msgctxt "#90037" | |
| 1619 | msgid "Popular" | |
| 1620 | msgstr "Popular" | |
| 1621 | ||
| 1622 | msgctxt "#90038" | |
| 1623 | msgid "Popular Recent" | |
| 1624 | msgstr "Populares recentes" | |
| 1625 | ||
| 1626 | msgctxt "#90039" | |
| 1627 | msgid "Airing" | |
| 1628 | msgstr "No ar" | |
| 1629 | ||
| 1630 | msgctxt "#90040" | |
| 1631 | msgid "Years" | |
| 1632 | msgstr "Anos" | |
| 1633 | ||
| 1634 | msgctxt "#90041" | |
| 1635 | msgid "Genres" | |
| 1636 | msgstr "Gêneros" | |
| 1637 | ||
| 1638 | msgctxt "#90042" | |
| 1639 | msgid "Top Rated" | |
| 1640 | msgstr "Melhores avaliados" | |
| 1641 | ||
| 1642 | msgctxt "#90043" | |
| 1643 | msgid "Most Watched" | |
| 1644 | msgstr "Mais assistidos" | |
| 1645 | ||
| 1646 | msgctxt "#90044" | |
| 1647 | msgid "Torrentio Configuration" | |
| 1648 | msgstr "Torrentio Configuração" | |
| 1649 | ||
| 1650 | msgctxt "#90045" | |
| 1651 | msgid "Enable Torrentio Settings" | |
| 1652 | msgstr "Ativar definições Torrentio" | |
| 1653 | ||
| 1654 | msgctxt "#90046" | |
| 1655 | msgid "Enable or disable the Torrentio Configuration" | |
| 1656 | msgstr "Habilitar ou desabilitar the Torrentio Configuration" | |
| 1657 | ||
| 1658 | msgctxt "#90047" | |
| 1659 | msgid "Select Torrentio Providers" | |
| 1660 | msgstr "Selecionar provedores Torrentio" | |
| 1661 | ||
| 1662 | msgctxt "#90048" | |
| 1663 | msgid "Choose which providers to include in the stream request." | |
| 1664 | msgstr "Escolha quais os provedores a incluir no pedido de stream." | |
| 1665 | ||
| 1666 | msgctxt "#90049" | |
| 1667 | msgid "Scrape again" | |
| 1668 | msgstr "Pesquisar novamente" | |
| 1669 | ||
| 1670 | msgctxt "#90050" | |
| 1671 | msgid "Recommendations" | |
| 1672 | msgstr "Recomendações" | |
| 1673 | ||
| 1674 | msgctxt "#90051" | |
| 1675 | msgid "Similar" | |
| 1676 | msgstr "Semelhante" | |
| 1677 | ||
| 1678 | msgctxt "#90052" | |
| 1679 | msgid "Thread Number" | |
| 1680 | msgstr "Número de threads" | |
| 1681 | ||
| 1682 | msgctxt "#90053" | |
| 1683 | msgid "Select the number of threads (1-30)" | |
| 1684 | msgstr "Select the number of threads (1-30)" | |
| 1685 | ||
| 1686 | msgctxt "#90054" | |
| 1687 | msgid "Kodi logs" | |
| 1688 | msgstr "Logs do Kodi" | |
| 1689 | ||
| 1690 | msgctxt "#90055" | |
| 1691 | msgid "Kodi old logs" | |
| 1692 | msgstr "Logs antigos do Kodi" | |
| 1693 | ||
| 1694 | msgctxt "#90056" | |
| 1695 | msgid "Displays Kodi logs to help diagnose issues." | |
| 1696 | msgstr "Exibe os logs do Kodi para ajudar a diagnosticar problemas." | |
| 1697 | ||
| 1698 | msgctxt "#90057" | |
| 1699 | msgid "Logs" | |
| 1700 | msgstr "Logs" | |
| 1701 | ||
| 1702 | msgctxt "#90058" | |
| 1703 | msgid "Filter by Size" | |
| 1704 | msgstr "Filtrar por Tamanho" | |
| 1705 | ||
| 1706 | msgctxt "#90059" | |
| 1707 | msgid "Enable Size Filter" | |
| 1708 | msgstr "Ativar filtro de tamanho" | |
| 1709 | ||
| 1710 | msgctxt "#90060" | |
| 1711 | msgid "Minimum Size (MB)" | |
| 1712 | msgstr "Minimum Size (MB)" | |
| 1713 | ||
| 1714 | msgctxt "#90061" | |
| 1715 | msgid "Maximum Size (MB)" | |
| 1716 | msgstr "Maximum Size (MB)" | |
| 1717 | ||
| 1718 | msgctxt "#90062" | |
| 1719 | msgid "Enable Cached Search" | |
| 1720 | msgstr "Ativar pesquisa em cache" | |
| 1721 | ||
| 1722 | msgctxt "#90063" | |
| 1723 | msgid "Only show cached streams from external services" | |
| 1724 | msgstr "Mostrar apenas streams em cache de serviços externos" | |
| 1725 | ||
| 1726 | msgctxt "#90064" | |
| 1727 | msgid "Popular" | |
| 1728 | msgstr "Popular" | |
| 1729 | ||
| 1730 | msgctxt "#90065" | |
| 1731 | msgid "Languages" | |
| 1732 | msgstr "Idiomas" | |
| 1733 | ||
| 1734 | msgctxt "#90066" | |
| 1735 | msgid "Networks" | |
| 1736 | msgstr "Redes" | |
| 1737 | ||
| 1738 | msgctxt "#90067" | |
| 1739 | msgid "Collections" | |
| 1740 | msgstr "Coleções" | |
| 1741 | ||
| 1742 | msgctxt "#90068" | |
| 1743 | msgid "Search for collections:" | |
| 1744 | msgstr "Pesquisar coleções:" | |
| 1745 | ||
| 1746 | msgctxt "#90069" | |
| 1747 | msgid "Main Menu" | |
| 1748 | msgstr "Menu Principal" | |
| 1749 | ||
| 1750 | msgctxt "#90070" | |
| 1751 | msgid "Last Titles" | |
| 1752 | msgstr "Últimos Títulos" | |
| 1753 | ||
| 1754 | msgctxt "#90071" | |
| 1755 | msgid "Last Files" | |
| 1756 | msgstr "Últimos Arquivos" | |
| 1757 | ||
| 1758 | msgctxt "#90072" | |
| 1759 | msgid "Trakt Lists" | |
| 1760 | msgstr "Listas Trakt" | |
| 1761 | ||
| 1762 | msgctxt "#90073" | |
| 1763 | msgid "Metadata" | |
| 1764 | msgstr "Metadados" | |
| 1765 | ||
| 1766 | msgctxt "#90074" | |
| 1767 | msgid "Top Lists" | |
| 1768 | msgstr "Listas Top" | |
| 1769 | ||
| 1770 | msgctxt "#90075" | |
| 1771 | msgid "User Lists" | |
| 1772 | msgstr "Listas do Usuário" | |
| 1773 | ||
| 1774 | msgctxt "#90076" | |
| 1775 | msgid "Search Lists" | |
| 1776 | msgstr "Pesquisar Listas" | |
| 1777 | ||
| 1778 | msgctxt "#90077" | |
| 1779 | msgid "MDbList Configuration" | |
| 1780 | msgstr "MDbList Configuração" | |
| 1781 | ||
| 1782 | msgctxt "#90078" | |
| 1783 | msgid "People" | |
| 1784 | msgstr "Pessoas" | |
| 1785 | ||
| 1786 | msgctxt "#90079" | |
| 1787 | msgid "Popular People" | |
| 1788 | msgstr "Pessoas Populares" | |
| 1789 | ||
| 1790 | msgctxt "#90080" | |
| 1791 | msgid "Latest People" | |
| 1792 | msgstr "Pessoas Recentes" | |
| 1793 | ||
| 1794 | msgctxt "#90081" | |
| 1795 | msgid "Search People" | |
| 1796 | msgstr "Pesquisar Pessoas" | |
| 1797 | ||
| 1798 | msgctxt "#90082" | |
| 1799 | msgid "Download subtitles" | |
| 1800 | msgstr "Baixar legendas" | |
| 1801 | ||
| 1802 | msgctxt "#90083" | |
| 1803 | msgid "Download video" | |
| 1804 | msgstr "Baixar vídeo" | |
| 1805 | ||
| 1806 | msgctxt "#90084" | |
| 1807 | msgid "Browse into" | |
| 1808 | msgstr "Explorar" | |
| 1809 | ||
| 1810 | msgctxt "#90085" | |
| 1811 | msgid "Include TV show Specials" | |
| 1812 | msgstr "Incluir Especiais de Séries de TV" | |
| 1813 | ||
| 1814 | msgctxt "#90086" | |
| 1815 | msgid "Airing Today" | |
| 1816 | msgstr "Hoje no Ar" | |
| 1817 | ||
| 1818 | msgctxt "#90099" | |
| 1819 | msgid "Filter by Source Type" | |
| 1820 | msgstr "Filtrar por Tipo de Fonte" | |
| 1821 | ||
| 1822 | msgctxt "#90100" | |
| 1823 | msgid "Include BluRay/UHD sources" | |
| 1824 | msgstr "Incluir fontes BluRay/UHD" | |
| 1825 | ||
| 1826 | msgctxt "#90105" | |
| 1827 | msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1828 | msgstr "Inclui lançamentos como: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1829 | ||
| 1830 | msgctxt "#90101" | |
| 1831 | msgid "Include WEB/HD sources" | |
| 1832 | msgstr "Incluir fontes WEB/HD" | |
| 1833 | ||
| 1834 | msgctxt "#90106" | |
| 1835 | msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1836 | msgstr "Inclui lançamentos como: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1837 | ||
| 1838 | msgctxt "#90102" | |
| 1839 | msgid "Include DVD/TV/SAT sources" | |
| 1840 | msgstr "Incluir fontes DVD/TV/SAT" | |
| 1841 | ||
| 1842 | msgctxt "#90107" | |
| 1843 | msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1844 | msgstr "Inclui lançamentos como: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1845 | ||
| 1846 | msgctxt "#90103" | |
| 1847 | msgid "Include CAM/Screener sources" | |
| 1848 | msgstr "Incluir fontes CAM/Screener" | |
| 1849 | ||
| 1850 | msgctxt "#90108" | |
| 1851 | msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR" | |
| 1852 | msgstr "Inclui lançamentos como: CAM, TeleSync, TeleCine, SCR" | |
| 1853 | ||
| 1854 | msgctxt "#90104" | |
| 1855 | msgid "Include Unknown sources" | |
| 1856 | msgstr "Incluir fontes desconhecidas" | |
| 1857 | ||
| 1858 | msgctxt "#90109" | |
| 1859 | msgid "Includes uncategorized or unidentified sources" | |
| 1860 | msgstr "Inclui fontes não categorizadas ou não identificadas" | |
| 1861 | ||
| 1862 | msgctxt "#90110" | |
| 1863 | msgid "Do you want to clear your watched history?." | |
| 1864 | msgstr "Deseja limpar seu histórico de watched history.?" | |
| 1865 | ||
| 1866 | msgctxt "#90111" | |
| 1867 | msgid "Do you want to clear your history?." | |
| 1868 | msgstr "Deseja limpar seu histórico de history.?" | |
| 1869 | ||
| 1870 | msgctxt "#90112" | |
| 1871 | msgid "Clear History" | |
| 1872 | msgstr "Limpar Histórico" | |
| 1873 | ||
| 1874 | msgctxt "#90113" | |
| 1875 | msgid "Click to delete all watched history immediately." | |
| 1876 | msgstr "Clique para apagar todo o histórico de visualização imediatamente." | |
| 1877 | ||
| 1878 | msgctxt "#90114" | |
| 1879 | msgid "History cleared" | |
| 1880 | msgstr "Histórico apagado" | |
| 1881 | ||
| 1882 | msgctxt "#90115" | |
| 1883 | msgid "Source Select" | |
| 1884 | msgstr "Seleção de Fonte" | |
| 1885 | ||
| 1886 | msgctxt "#90116" | |
| 1887 | msgid "Open Settings" | |
| 1888 | msgstr "Abrir Configurações" | |
| 1889 | ||
| 1890 | msgctxt "#90117" | |
| 1891 | msgid "Select Torrentio Providers" | |
| 1892 | msgstr "Selecionar provedores Torrentio" | |
| 1893 | ||
| 1894 | msgctxt "#90118" | |
| 1895 | msgid "Port" | |
| 1896 | msgstr "Porta" | |
| 1897 | ||
| 1898 | msgctxt "#90119" | |
| 1899 | msgid "Jackett port (default:9117)" | |
| 1900 | msgstr "Jackett port (default:9117)" | |
| 1901 | ||
| 1902 | msgctxt "#90120" | |
| 1903 | msgid "Prowlarr port (default:9696)" | |
| 1904 | msgstr "Prowlarr port (default:9696)" | |
| 1905 | ||
| 1906 | msgctxt "#90121" | |
| 1907 | msgid "All Debrid Configuration" | |
| 1908 | msgstr "All Debrid Configuração" | |
| 1909 | ||
| 1910 | msgctxt "#90122" | |
| 1911 | msgid "Auto download subtitles" | |
| 1912 | msgstr "Baixar legendas automaticamente" | |
| 1913 | ||
| 1914 | msgctxt "#90123" | |
| 1915 | msgid "Stremio Timeout" | |
| 1916 | msgstr "Stremio Tempo limite" | |
| 1917 | ||
| 1918 | msgctxt "#90124" | |
| 1919 | msgid "Webdav Client Configuration" | |
| 1920 | msgstr "Webdav Client Configuração" | |
| 1921 | ||
| 1922 | msgctxt "#90126" | |
| 1923 | msgid "Host" | |
| 1924 | msgstr "Host" | |
| 1925 | ||
| 1926 | msgctxt "#90127" | |
| 1927 | msgid "Username" | |
| 1928 | msgstr "Nome de usuário" | |
| 1929 | ||
| 1930 | msgctxt "#90128" | |
| 1931 | msgid "Password" | |
| 1932 | msgstr "Senha" | |
| 1933 | ||
| 1934 | msgctxt "#90129" | |
| 1935 | msgid "Test Connection" | |
| 1936 | msgstr "" | |
| 1937 | ||
| 1938 | msgctxt "#90131" | |
| 1939 | msgid "Webdav" | |
| 1940 | msgstr "" | |
| 1941 | ||
| 1942 | msgctxt "#90132" | |
| 1943 | msgid "Remote Path" | |
| 1944 | msgstr "" | |
| 1945 | ||
| 1946 | msgctxt "#90133" | |
| 1947 | msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)." | |
| 1948 | msgstr "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)" | |
| 1949 | ||
| 1950 | msgctxt "#90134" | |
| 1951 | msgid "The username required for authenticating with your WebDAV server" | |
| 1952 | msgstr "" | |
| 1953 | ||
| 1954 | msgctxt "#90135" | |
| 1955 | msgid "The password for the specified username to authenticate with your WebDAV server" | |
| 1956 | msgstr "" | |
| 1957 | ||
| 1958 | msgctxt "#90136" | |
| 1959 | msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)." | |
| 1960 | msgstr "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)" | |
| 1961 | ||
| 1962 | msgctxt "#90137" | |
| 1963 | msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials." | |
| 1964 | msgstr "" | |
| 1965 | ||
| 1966 | msgctxt "#90138" | |
| 1967 | msgid "Remove Custom Addon" | |
| 1968 | msgstr "" | |
| 1969 | ||
| 1970 | msgctxt "#90139" | |
| 1971 | msgid "Playback" | |
| 1972 | msgstr "Reprodução" | |
| 1973 | ||
| 1974 | msgctxt "#90140" | |
| 1975 | msgid "Super quick play" | |
| 1976 | msgstr "" | |
| 1977 | ||
| 1978 | msgctxt "#90141" | |
| 1979 | msgid "Re-ask to play last played video" | |
| 1980 | msgstr "" | |
| 1981 | ||
| 1982 | msgctxt "#90142" | |
| 1983 | msgid "Replay Dialog" | |
| 1984 | msgstr "" | |
| 1985 | ||
| 1986 | msgctxt "#90143" | |
| 1987 | msgid "Do you want to play last watched?" | |
| 1988 | msgstr "" | |
| 1989 | ||
| 1990 | msgctxt "#90144" | |
| 1991 | msgid "Could not resolve playback URL" | |
| 1992 | msgstr "" | |
| 1993 | ||
| 1994 | msgctxt "#90145" | |
| 1995 | msgid "Precaching Enabled" | |
| 1996 | msgstr "Pré-carregamento Ativado" | |
| 1997 | ||
| 1998 | msgctxt "#90146" | |
| 1999 | msgid "Enable or disable precaching of next episodes." | |
| 2000 | msgstr "Ativar ou desativar o pré-carregamento dos próximos episódios." | |
| 2001 | ||
| 2002 | msgctxt "#90147" | |
| 2003 | msgid "Precaching Episode Count" | |
| 2004 | msgstr "Contagem de Episódios para Pré-carregar" | |
| 2005 | ||
| 2006 | msgctxt "#90148" | |
| 2007 | msgid "Number of next episodes to precache." | |
| 2008 | msgstr "Número de próximos episódios para pré-carregar." | |
| 2009 | ||
| 2010 | msgctxt "#30901" | |
| 2011 | msgid "Show Only Stremio Catalogs" | |
| 2012 | msgstr "" | |
| 2013 | ||
| 2014 | msgctxt "#30902" | |
| 2015 | msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus." | |
| 2016 | msgstr "Oculta itens incorporados (TMDb, Trakt, etc.) e mostra apenas catálogos do Stremio nos menus." | |
| 2017 | ||
| 2018 | msgctxt "#30903" | |
| 2019 | msgid "Select Live TV Addons" | |
| 2020 | msgstr "Selecionar add-ons de TV ao vivo" | |
| 2021 | ||
| 2022 | msgctxt "#30904" | |
| 2023 | msgid "Choose specific Stremio addons for Live TV content" | |
| 2024 | msgstr "Escolher add-ons específicos do Stremio para conteúdo de TV ao vivo" | |
| 2025 | ||
| 2026 | msgctxt "#30920" | |
| 2027 | msgid "Clear Trakt Cache" | |
| 2028 | msgstr "Limpar cache do Trakt" | |
| 2029 | ||
| 2030 | msgctxt "#30921" | |
| 2031 | msgid "Clear TMDB Cache" | |
| 2032 | msgstr "Limpar cache do TMDB" | |
| 2033 | ||
| 2034 | msgctxt "#30922" | |
| 2035 | msgid "Clear Providers Cache" | |
| 2036 | msgstr "Limpar cache de Provedores" | |
| 2037 | ||
| 2038 | msgctxt "#30923" | |
| 2039 | msgid "Clear Settings Cache" | |
| 2040 | msgstr "Limpar cache de Configurações" | |
| 2041 | ||
| 2042 | msgctxt "#90149" | |
| 2043 | msgid "Maintenance" | |
| 2044 | msgstr "Manutenção" | |
| 2045 | ||
| 2046 | msgctxt "#90150" | |
| 2047 | msgid "Stremio: General" | |
| 2048 | msgstr "Stremio: Geral" | |
| 2049 | ||
| 2050 | msgctxt "#90151" | |
| 2051 | msgid "Stremio: Account" | |
| 2052 | msgstr "Stremio: Conta" | |
| 2053 | ||
| 2054 | msgctxt "#90152" | |
| 2055 | msgid "Stremio: Addons & Catalogs" | |
| 2056 | msgstr "Stremio: Addons e catálogos" | |
| 2057 | ||
| 2058 | msgctxt "#90153" | |
| 2059 | msgid "Stremio: Torrentio" | |
| 2060 | msgstr "Stremio: Torrentio" | |
| 2061 | ||
| 2062 | msgctxt "#90154" | |
| 2063 | msgid "Trending Recent" | |
| 2064 | msgstr "Tendências recentes" | |
| 2065 | ||
| 2066 | msgctxt "#90155" | |
| 2067 | msgid "Stremio: Filters" | |
| 2068 | msgstr "Stremio: Filters" | |
| 2069 | ||
| 2070 | msgctxt "#32300" | |
| 2071 | msgid "Filter by Quality" | |
| 2072 | msgstr "Filtrar por Qualidade" | |
| 2073 | ||
| 2074 | msgctxt "#32301" | |
| 2075 | msgid "Filter by Provider" | |
| 2076 | msgstr "" | |
| 2077 | ||
| 2078 | msgctxt "#32302" | |
| 2079 | msgid "Filter by Source" | |
| 2080 | msgstr "" | |
| 2081 | ||
| 2082 | msgctxt "#32303" | |
| 2083 | msgid "Filter by Language" | |
| 2084 | msgstr "" | |
| 2085 | ||
| 2086 | msgctxt "#32304" | |
| 2087 | msgid "Filter by Torrents" | |
| 2088 | msgstr "" | |
| 2089 | ||
| 2090 | msgctxt "#32305" | |
| 2091 | msgid "Filter by Debrid" | |
| 2092 | msgstr "Filtrar por Debrid" | |
| 2093 | ||
| 2094 | msgctxt "#32306" | |
| 2095 | msgid "Reset Filter" | |
| 2096 | msgstr "" | |
| 2097 | ||
| 2098 | msgctxt "#30930" | |
| 2099 | msgid "Ping Addons" | |
| 2100 | msgstr "Testar conexão dos add-ons" | |
| 2101 | ||
| 2102 | msgctxt "#30931" | |
| 2103 | msgid "Check availability of addons and show only reachable ones" | |
| 2104 | msgstr "Verificar a disponibilidade dos add-ons e mostrar apenas os alcançáveis." | |
| 2105 | ||
| 2106 | msgctxt "#90164" | |
| 2107 | msgid "Skip Intro / Recap" | |
| 2108 | msgstr "Pular Intro / Recapitulação" | |
| 2109 | ||
| 2110 | msgctxt "#90165" | |
| 2111 | msgid "Enable Skip Intro" | |
| 2112 | msgstr "Habilitar Pular Intro" | |
| 2113 | ||
| 2114 | msgctxt "#90166" | |
| 2115 | msgid "Detect intros and recaps using IntroDB and offer to skip them during playback" | |
| 2116 | msgstr "Detectar intros e recapitulações usando IntroDB e oferecer para pular durante a reprodução" | |
| 2117 | ||
| 2118 | msgctxt "#90167" | |
| 2119 | msgid "Auto-skip" | |
| 2120 | msgstr "Pular automaticamente" | |
| 2121 | ||
| 2122 | msgctxt "#90168" | |
| 2123 | msgid "Automatically skip intros and recaps without showing the button" | |
| 2124 | msgstr "Pular automaticamente intros e recapitulações sem mostrar o botão" | |
| 2125 | ||
| 2126 | msgctxt "#90160" | |
| 2127 | msgid "Skip Intro" | |
| 2128 | msgstr "Pular Intro" | |
| 2129 | ||
| 2130 | msgctxt "#90161" | |
| 2131 | msgid "Skip Recap" | |
| 2132 | msgstr "Pular Recapitulação" | |
| 2133 | ||
| 2134 | msgctxt "#90162" | |
| 2135 | msgid "Manage from Phone" | |
| 2136 | msgstr "Gerenciar pelo celular" | |
| 2137 | ||
| 2138 | msgctxt "#90163" | |
| 2139 | msgid "Start a local web server and show a QR code to manage Stremio sources from your phone" | |
| 2140 | msgstr "Iniciar um servidor web local e mostrar um código QR para gerenciar as fontes do Stremio pelo seu celular" | |
| 2141 | ||
| 2142 | msgctxt "#90200" | |
| 2143 | msgid "Continue Watching" | |
| 2144 | msgstr "Continuar Assistindo" | |
| 2145 | ||
| 2146 | msgctxt "#90201" | |
| 2147 | msgid "Library" | |
| 2148 | msgstr "Biblioteca" | |
| 2149 | ||
| 2150 | msgctxt "#90202" | |
| 2151 | msgid "My Shows" | |
| 2152 | msgstr "Minhas Séries" | |
| 2153 | ||
| 2154 | msgctxt "#90203" | |
| 2155 | msgid "My Movies" | |
| 2156 | msgstr "Meus Filmes" | |
| 2157 | ||
| 2158 | msgctxt "#90204" | |
| 2159 | msgid "Remove from Library" | |
| 2160 | msgstr "Remover da Biblioteca" | |
| 2161 | ||
| 2162 | msgctxt "#90205" | |
| 2163 | msgid "Add to Library" | |
| 2164 | msgstr "Adicionar à Biblioteca" | |
| 2165 | ||
| 2166 | msgctxt "#90206" | |
| 2167 | msgid "Remove" | |
| 2168 | msgstr "Remover" | |
| 2169 | ||
| 2170 | msgctxt "#90207" | |
| 2171 | msgid "Search Movies & TV" | |
| 2172 | msgstr "Pesquisar Filmes e Séries" | |
| 2173 | ||
| 2174 | msgctxt "#90208" | |
| 2175 | msgid "Recent Searches" | |
| 2176 | msgstr "Pesquisas Recentes" | |
| 2177 | ||
| 2178 | msgctxt "#90209" | |
| 2179 | msgid "Recent Direct Searches" | |
| 2180 | msgstr "Pesquisas Diretas Recentes" | |
| 2181 | ||
| 2182 | msgctxt "#90210" | |
| 2183 | msgid "Clear All Search History" | |
| 2184 | msgstr "Limpar todo o histórico de pesquisa" | |
| 2185 | ||
| 2186 | msgctxt "#90211" | |
| 2187 | msgid "Bypass Stremio Addons Native Sorting" | |
| 2188 | msgstr "Ignorar a ordenação nativa dos add-ons do Stremio" | |
| 2189 | ||
| 2190 | msgctxt "#90212" | |
| 2191 | msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds." | |
| 2192 | msgstr "" | |
| 2193 | ||
| 2194 | msgctxt "#90213" | |
| 2195 | msgid "Select Bypassed Addons" | |
| 2196 | msgstr "Selecionar add-ons a ignorar" | |
| 2197 | ||
| 2198 | msgctxt "#90214" | |
| 2199 | msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering." | |
| 2200 | msgstr "" | |
| 2201 | ||
| 2202 | msgctxt "#90215" | |
| 2203 | msgid "Downgrade" | |
| 2204 | msgstr "Reverter" | |
| 2205 | ||
| 2206 | msgctxt "#90671" | |
| 2207 | msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails." | |
| 2208 | msgstr "Instala uma versao publicada anterior do Jacktook. A versao atual e salva e restaurada automaticamente se o downgrade falhar." | |
| 2209 | ||
| 2210 | msgctxt "#30932" | |
| 2211 | msgid "Update Action" | |
| 2212 | msgstr "" | |
| 2213 | ||
| 2214 | msgctxt "#30933" | |
| 2215 | msgid "Choose how to handle background update checks." | |
| 2216 | msgstr "" | |
| 2217 | ||
| 2218 | msgctxt "#30934" | |
| 2219 | msgid "Ask" | |
| 2220 | msgstr "" | |
| 2221 | ||
| 2222 | msgctxt "#30935" | |
| 2223 | msgid "Notify" | |
| 2224 | msgstr "" | |
| 2225 | ||
| 2226 | msgctxt "#90216" | |
| 2227 | msgid "Volume Check Enabled" | |
| 2228 | msgstr "" | |
| 2229 | ||
| 2230 | msgctxt "#90217" | |
| 2231 | msgid "Automatically limits volume if it is above the threshold before playback." | |
| 2232 | msgstr "" | |
| 2233 | ||
| 2234 | msgctxt "#90218" | |
| 2235 | msgid "Volume Threshold" | |
| 2236 | msgstr "Limite de volume" | |
| 2237 | ||
| 2238 | msgctxt "#90219" | |
| 2239 | msgid "The volume percentage above which the warning triggers." | |
| 2240 | msgstr "" | |
| 2241 | ||
| 2242 | msgctxt "#90220" | |
| 2243 | msgid "High Volume Warning" | |
| 2244 | msgstr "" | |
| 2245 | ||
| 2246 | msgctxt "#90221" | |
| 2247 | msgid "High volume detected. Adjusting to %s%% for safety." | |
| 2248 | msgstr "" | |
| 2249 | ||
| 2250 | msgctxt "#90222" | |
| 2251 | msgid "Fanart.tv Configuration" | |
| 2252 | msgstr "Fanart.tv Configuração" | |
| 2253 | ||
| 2254 | msgctxt "#90223" | |
| 2255 | msgid "Client Key" | |
| 2256 | msgstr "" | |
| 2257 | ||
| 2258 | msgctxt "#90224" | |
| 2259 | msgid "Your personal Fanart.tv client key for higher quality assets." | |
| 2260 | msgstr "" | |
| 2261 | ||
| 2262 | msgctxt "#90225" | |
| 2263 | msgid "Debrid Notifications" | |
| 2264 | msgstr "" | |
| 2265 | ||
| 2266 | msgctxt "#90226" | |
| 2267 | msgid "Debrid Expiration Notification" | |
| 2268 | msgstr "" | |
| 2269 | ||
| 2270 | msgctxt "#90227" | |
| 2271 | msgid "Enable or disable notifications when your Debrid subscription is about to expire." | |
| 2272 | msgstr "Habilitar ou desabilitar notifications when your Debrid subscription is about to expire." | |
| 2273 | ||
| 2274 | msgctxt "#90228" | |
| 2275 | msgid "Expiration Threshold (Days)" | |
| 2276 | msgstr "Expiration Threshold (Days)" | |
| 2277 | ||
| 2278 | msgctxt "#90229" | |
| 2279 | msgid "How many days before expiration to start notifying." | |
| 2280 | msgstr "" | |
| 2281 | ||
| 2282 | msgctxt "#90230" | |
| 2283 | msgid "%s expires in %d days." | |
| 2284 | msgstr "" | |
| 2285 | ||
| 2286 | msgctxt "#90231" | |
| 2287 | msgid "%s expires today!" | |
| 2288 | msgstr "" | |
| 2289 | ||
| 2290 | msgctxt "#90232" | |
| 2291 | msgid "Easynews" | |
| 2292 | msgstr "Easynews" | |
| 2293 | ||
| 2294 | msgctxt "#90233" | |
| 2295 | msgid "Enable EasyNews as a direct stream provider." | |
| 2296 | msgstr "" | |
| 2297 | ||
| 2298 | msgctxt "#90234" | |
| 2299 | msgid "Your EasyNews username." | |
| 2300 | msgstr "" | |
| 2301 | ||
| 2302 | msgctxt "#90235" | |
| 2303 | msgid "Your EasyNews password." | |
| 2304 | msgstr "" | |
| 2305 | ||
| 2306 | msgctxt "#90236" | |
| 2307 | msgid "Request timeout in seconds." | |
| 2308 | msgstr "" | |
| 2309 | ||
| 2310 | msgctxt "#90237" | |
| 2311 | msgid "Account Information" | |
| 2312 | msgstr "Informações da conta" | |
| 2313 | ||
| 2314 | msgctxt "#90238" | |
| 2315 | msgid "Search Dialog Style" | |
| 2316 | msgstr "" | |
| 2317 | ||
| 2318 | msgctxt "#90239" | |
| 2319 | msgid "Background" | |
| 2320 | msgstr "" | |
| 2321 | ||
| 2322 | msgctxt "#90240" | |
| 2323 | msgid "Detailed Status" | |
| 2324 | msgstr "" | |
| 2325 | ||
| 2326 | msgctxt "#90241" | |
| 2327 | msgid "Search Status" | |
| 2328 | msgstr "Status da pesquisa" | |
| 2329 | ||
| 2330 | msgctxt "#90242" | |
| 2331 | msgid "Cancel" | |
| 2332 | msgstr "Cancelar" | |
| 2333 | ||
| 2334 | msgctxt "#90243" | |
| 2335 | msgid "Results found: " | |
| 2336 | msgstr "Resultados encontrados: " | |
| 2337 | ||
| 2338 | msgctxt "#90244" | |
| 2339 | msgid "Pending" | |
| 2340 | msgstr "Pendente" | |
| 2341 | ||
| 2342 | msgctxt "#90245" | |
| 2343 | msgid "In Progress" | |
| 2344 | msgstr "Em progresso" | |
| 2345 | ||
| 2346 | msgctxt "#90246" | |
| 2347 | msgid "Completed" | |
| 2348 | msgstr "Concluído" | |
| 2349 | ||
| 2350 | msgctxt "#90247" | |
| 2351 | msgid "Failed" | |
| 2352 | msgstr "Falhou" | |
| 2353 | ||
| 2354 | msgctxt "#90248" | |
| 2355 | msgid "Cancelled" | |
| 2356 | msgstr "Cancelado" | |
| 2357 | ||
| 2358 | msgctxt "#90249" | |
| 2359 | msgid "Your personal Jackgram API token for authentication." | |
| 2360 | msgstr "" | |
| 2361 | ||
| 2362 | msgctxt "#90250" | |
| 2363 | msgid "Subtitles Found" | |
| 2364 | msgstr "Legendas Encontradas" | |
| 2365 | ||
| 2366 | msgctxt "#90251" | |
| 2367 | msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?" | |
| 2368 | msgstr "Ja existem legendas baixadas para este video.\nDeseja usar as legendas existentes?" | |
| 2369 | ||
| 2370 | msgctxt "#90252" | |
| 2371 | msgid "No subtitles found" | |
| 2372 | msgstr "Nenhuma legenda encontrada" | |
| 2373 | ||
| 2374 | msgctxt "#90253" | |
| 2375 | msgid "No subtitles selected" | |
| 2376 | msgstr "Nenhuma legenda selecionada" | |
| 2377 | ||
| 2378 | msgctxt "#90254" | |
| 2379 | msgid "Translate Subtitles" | |
| 2380 | msgstr "Traduzir Legendas" | |
| 2381 | ||
| 2382 | msgctxt "#90255" | |
| 2383 | msgid "Do you want to also translate the selected subtitles?" | |
| 2384 | msgstr "Deseja tambem traduzir as legendas selecionadas?" | |
| 2385 | ||
| 2386 | msgctxt "#90256" | |
| 2387 | msgid "Select Subtitle to Download" | |
| 2388 | msgstr "Selecionar Legenda para Baixar" | |
| 2389 | ||
| 2390 | msgctxt "#90257" | |
| 2391 | msgid "Subtitles Loaded" | |
| 2392 | msgstr "Legendas Carregadas" | |
| 2393 | ||
| 2394 | msgctxt "#90258" | |
| 2395 | msgid "Backup" | |
| 2396 | msgstr "Backup das Configuracoes" | |
| 2397 | ||
| 2398 | msgctxt "#90259" | |
| 2399 | msgid "Export Backup" | |
| 2400 | msgstr "Exportar Backup" | |
| 2401 | ||
| 2402 | msgctxt "#90260" | |
| 2403 | msgid "Export addon settings and custom Stremio addons to a JSON backup file." | |
| 2404 | msgstr "Exporta as configuracoes do addon e os addons personalizados do Stremio para um arquivo JSON de backup." | |
| 2405 | ||
| 2406 | msgctxt "#90261" | |
| 2407 | msgid "Restore Backup" | |
| 2408 | msgstr "Restaurar Backup das Configuracoes" | |
| 2409 | ||
| 2410 | msgctxt "#90262" | |
| 2411 | msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file." | |
| 2412 | msgstr "Restaura as configuracoes do addon e substitui os addons personalizados do Stremio a partir de um arquivo JSON de backup." | |
| 2413 | ||
| 2414 | msgctxt "#90263" | |
| 2415 | msgid "Remove Sensitive Data" | |
| 2416 | msgstr "Remover Dados Sensiveis" | |
| 2417 | ||
| 2418 | msgctxt "#90264" | |
| 2419 | msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups." | |
| 2420 | msgstr "Remove chaves de API, senhas, nomes de usuario, emails, estado de autenticacao e URLs de addons personalizados do Stremio dos backups exportados." | |
| 2421 | ||
| 2422 | msgctxt "#90265" | |
| 2423 | msgid "Settings backup exported" | |
| 2424 | msgstr "Backup exportado" | |
| 2425 | ||
| 2426 | msgctxt "#90266" | |
| 2427 | msgid "Settings backup restored" | |
| 2428 | msgstr "Backup restaurado" | |
| 2429 | ||
| 2430 | msgctxt "#90267" | |
| 2431 | msgid "Settings Backup Failed" | |
| 2432 | msgstr "Falha no Backup das Configuracoes" | |
| 2433 | ||
| 2434 | msgctxt "#90268" | |
| 2435 | msgid "Select export folder" | |
| 2436 | msgstr "Selecionar pasta de exportacao" | |
| 2437 | ||
| 2438 | msgctxt "#90269" | |
| 2439 | msgid "Select backup file" | |
| 2440 | msgstr "Selecionar arquivo de backup" | |
| 2441 | ||
| 2442 | msgctxt "#90270" | |
| 2443 | msgid "Backup file is invalid." | |
| 2444 | msgstr "O arquivo de backup e invalido." | |
| 2445 | ||
| 2446 | msgctxt "#90271" | |
| 2447 | msgid "No export folder selected." | |
| 2448 | msgstr "Nenhuma pasta de exportacao selecionada." | |
| 2449 | ||
| 2450 | msgctxt "#90272" | |
| 2451 | msgid "No backup file selected." | |
| 2452 | msgstr "Nenhum arquivo de backup selecionado." | |
| 2453 | ||
| 2454 | msgctxt "#90273" | |
| 2455 | msgid "Unable to export settings backup." | |
| 2456 | msgstr "Nao foi possivel exportar o backup." | |
| 2457 | ||
| 2458 | msgctxt "#90274" | |
| 2459 | msgid "Unable to restore settings backup." | |
| 2460 | msgstr "Nao foi possivel restaurar o backup." | |
| 2461 | ||
| 2462 | msgctxt "#90275" | |
| 2463 | msgid "Restore Backup" | |
| 2464 | msgstr "Restaurar Backup das Configuracoes" | |
| 2465 | ||
| 2466 | msgctxt "#90276" | |
| 2467 | msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?" | |
| 2468 | msgstr "Isso vai sobrescrever as configuracoes atuais do addon e substituir seus addons personalizados do Stremio pelos do backup. Deseja continuar?" | |
| 2469 | ||
| 2470 | msgctxt "#90277" | |
| 2471 | msgid "Restore cancelled" | |
| 2472 | msgstr "Restauracao cancelada" | |
| 2473 | ||
| 2474 | msgctxt "#90278" | |
| 2475 | msgid "Reset All Settings" | |
| 2476 | msgstr "Redefinir Todas as Configuracoes" | |
| 2477 | ||
| 2478 | msgctxt "#90279" | |
| 2479 | msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?" | |
| 2480 | msgstr "Isso vai redefinir todas as configuracoes do addon para os valores padrao e remover seus addons personalizados do Stremio. Deseja continuar?" | |
| 2481 | ||
| 2482 | msgctxt "#90280" | |
| 2483 | msgid "Reset cancelled" | |
| 2484 | msgstr "Redefinicao cancelada" | |
| 2485 | ||
| 2486 | msgctxt "#90281" | |
| 2487 | msgid "All addon settings reset" | |
| 2488 | msgstr "Todas as configuracoes do addon foram redefinidas" | |
| 2489 | ||
| 2490 | msgctxt "#90282" | |
| 2491 | msgid "Reset Failed" | |
| 2492 | msgstr "Falha ao Redefinir" | |
| 2493 | ||
| 2494 | msgctxt "#90283" | |
| 2495 | msgid "Unable to reset addon settings." | |
| 2496 | msgstr "Nao foi possivel redefinir as configuracoes do addon." | |
| 2497 | ||
| 2498 | msgctxt "#90284" | |
| 2499 | msgid "Reset every addon setting back to its default value and clear custom Stremio addons." | |
| 2500 | msgstr "Redefine cada configuracao do addon para o valor padrao e limpa os addons personalizados do Stremio." | |
| 2501 | ||
| 2502 | msgctxt "#90285" | |
| 2503 | msgid "Factory Reset" | |
| 2504 | msgstr "Redefinicao de Fabrica" | |
| 2505 | ||
| 2506 | msgctxt "#90286" | |
| 2507 | msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?" | |
| 2508 | msgstr "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 | ||
| 2510 | msgctxt "#90287" | |
| 2511 | msgid "Factory reset cancelled" | |
| 2512 | msgstr "Redefinicao de fabrica cancelada" | |
| 2513 | ||
| 2514 | msgctxt "#90288" | |
| 2515 | msgid "Factory reset completed" | |
| 2516 | msgstr "Redefinicao de fabrica concluida" | |
| 2517 | ||
| 2518 | msgctxt "#90289" | |
| 2519 | msgid "Factory Reset Failed" | |
| 2520 | msgstr "Falha na Redefinicao de Fabrica" | |
| 2521 | ||
| 2522 | msgctxt "#90290" | |
| 2523 | msgid "Unable to complete factory reset." | |
| 2524 | msgstr "Nao foi possivel concluir a redefinicao de fabrica." | |
| 2525 | ||
| 2526 | msgctxt "#90291" | |
| 2527 | msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data." | |
| 2528 | msgstr "Redefine as configuracoes para os valores padrao, limpa caches e apaga o historico e os dados locais do addon." | |
| 2529 | ||
| 2530 | msgctxt "#90292" | |
| 2531 | msgid "Trakt Library" | |
| 2532 | msgstr "Biblioteca do Trakt" | |
| 2533 | ||
| 2534 | msgctxt "#90293" | |
| 2535 | msgid "Trakt Discovery" | |
| 2536 | msgstr "Descoberta do Trakt" | |
| 2537 | ||
| 2538 | msgctxt "#90294" | |
| 2539 | msgid "Collection" | |
| 2540 | msgstr "Colecao" | |
| 2541 | ||
| 2542 | msgctxt "#90295" | |
| 2543 | msgid "Favorites" | |
| 2544 | msgstr "Favoritos" | |
| 2545 | ||
| 2546 | msgctxt "#90296" | |
| 2547 | msgid "Up Next" | |
| 2548 | msgstr "A Seguir" | |
| 2549 | ||
| 2550 | msgctxt "#90297" | |
| 2551 | msgid "My Lists" | |
| 2552 | msgstr "Minhas Listas" | |
| 2553 | ||
| 2554 | msgctxt "#90298" | |
| 2555 | msgid "Liked Lists" | |
| 2556 | msgstr "Listas Curtidas" | |
| 2557 | ||
| 2558 | msgctxt "#90331" | |
| 2559 | msgid "Stremio Addons" | |
| 2560 | msgstr "Addons do Stremio" | |
| 2561 | ||
| 2562 | msgctxt "#90332" | |
| 2563 | msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first." | |
| 2564 | msgstr "Ainda nao ha addons disponiveis. Use Iniciar sessao no Stremio ou Adicionar Addon Personalizado primeiro." | |
| 2565 | ||
| 2566 | msgctxt "#90333" | |
| 2567 | msgid "Stremio Stream Addons" | |
| 2568 | msgstr "Addons de streams do Stremio" | |
| 2569 | ||
| 2570 | msgctxt "#90334" | |
| 2571 | msgid "Stremio Catalog Addons" | |
| 2572 | msgstr "Addons de catalogo do Stremio" | |
| 2573 | ||
| 2574 | msgctxt "#90335" | |
| 2575 | msgid "Stremio Live TV Addons" | |
| 2576 | msgstr "Addons de TV ao vivo do Stremio" | |
| 2577 | ||
| 2578 | msgctxt "#90336" | |
| 2579 | msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged." | |
| 2580 | msgstr "Atualiza os addons importados da sua conta Stremio. Os addons personalizados permanecem inalterados." | |
| 2581 | ||
| 2582 | msgctxt "#90337" | |
| 2583 | msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first." | |
| 2584 | msgstr "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 | ||
| 2586 | msgctxt "#90338" | |
| 2587 | msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first." | |
| 2588 | msgstr "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 | ||
| 2590 | msgctxt "#90339" | |
| 2591 | msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first." | |
| 2592 | msgstr "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 | ||
| 2594 | msgctxt "#90340" | |
| 2595 | msgid "Remove custom Stremio addons you previously added manually." | |
| 2596 | msgstr "Remova os addons personalizados do Stremio que voce adicionou manualmente." | |
| 2597 | ||
| 2598 | msgctxt "#90341" | |
| 2599 | msgid "Choose Torrent Client" | |
| 2600 | msgstr "Escolher cliente de torrent" | |
| 2601 | ||
| 2602 | msgctxt "#90342" | |
| 2603 | msgid "Clear Database Cache" | |
| 2604 | msgstr "Limpar cache do banco de dados" | |
| 2605 | ||
| 2606 | msgctxt "#90343" | |
| 2607 | msgid "Clear Stremio Cache" | |
| 2608 | msgstr "Limpar cache do Stremio" | |
| 2609 | ||
| 2610 | msgctxt "#90344" | |
| 2611 | msgid "Clear Debrid Cache" | |
| 2612 | msgstr "Limpar cache de Debrid" | |
| 2613 | ||
| 2614 | msgctxt "#90345" | |
| 2615 | msgid "Clear MDBList Cache" | |
| 2616 | msgstr "Limpar cache do MDBList" | |
| 2617 | ||
| 2618 | msgctxt "#90346" | |
| 2619 | msgid "Restore backup from" | |
| 2620 | msgstr "Restaurar backup de" | |
| 2621 | ||
| 2622 | msgctxt "#90347" | |
| 2623 | msgid "File" | |
| 2624 | msgstr "Arquivo" | |
| 2625 | ||
| 2626 | msgctxt "#90348" | |
| 2627 | msgid "URL" | |
| 2628 | msgstr "URL" | |
| 2629 | ||
| 2630 | msgctxt "#90349" | |
| 2631 | msgid "Enter backup URL" | |
| 2632 | msgstr "Informe a URL do backup" | |
| 2633 | ||
| 2634 | msgctxt "#90350" | |
| 2635 | msgid "No backup URL provided." | |
| 2636 | msgstr "Nenhuma URL de backup foi informada." | |
| 2637 | ||
| 2638 | msgctxt "#90351" | |
| 2639 | msgid "Unable to download settings backup." | |
| 2640 | msgstr "Nao foi possivel baixar o backup de configuracoes." | |
| 2641 | ||
| 2642 | msgctxt "#90352" | |
| 2643 | msgid "Source Select Style" | |
| 2644 | msgstr "" | |
| 2645 | ||
| 2646 | msgctxt "#90353" | |
| 2647 | msgid "Modern" | |
| 2648 | msgstr "" | |
| 2649 | ||
| 2650 | msgctxt "#90354" | |
| 2651 | msgid "OLED" | |
| 2652 | msgstr "" | |
| 2653 | ||
| 2654 | msgctxt "#90355" | |
| 2655 | msgid "Cyberpunk" | |
| 2656 | msgstr "" | |
| 2657 | ||
| 2658 | msgctxt "#90356" | |
| 2659 | msgid "Forest" | |
| 2660 | msgstr "" | |
| 2661 | ||
| 2662 | msgctxt "#90357" | |
| 2663 | msgid "Gold" | |
| 2664 | msgstr "" | |
| 2665 | ||
| 2666 | msgctxt "#90358" | |
| 2667 | msgid "No playable results found" | |
| 2668 | msgstr "Nenhum resultado reproduzivel foi encontrado" | |
| 2669 | ||
| 2670 | msgctxt "#90359" | |
| 2671 | msgid "Add to TorrServer" | |
| 2672 | msgstr "Adicionar ao TorrServer" | |
| 2673 | ||
| 2674 | msgctxt "#90360" | |
| 2675 | msgid "Added to TorrServer" | |
| 2676 | msgstr "Adicionado ao TorrServer" | |
| 2677 | ||
| 2678 | msgctxt "#90361" | |
| 2679 | msgid "Could not resolve torrent details" | |
| 2680 | msgstr "Nao foi possivel resolver os detalhes do torrent" | |
| 2681 | ||
| 2682 | msgctxt "#90362" | |
| 2683 | msgid "Added to %s cloud" | |
| 2684 | msgstr "Adicionado a nuvem do %s" | |
| 2685 | ||
| 2686 | msgctxt "#90363" | |
| 2687 | msgid "Choose Debrid Service" | |
| 2688 | msgstr "Escolher servico Debrid" | |
| 2689 | ||
| 2690 | msgctxt "#90364" | |
| 2691 | msgid "No supported debrid service is enabled" | |
| 2692 | msgstr "Nenhum servico Debrid compativel esta habilitado" | |
| 2693 | ||
| 2694 | msgctxt "#90365" | |
| 2695 | msgid "Download to Debrid" | |
| 2696 | msgstr "Transferir para Debrid" | |
| 2697 | ||
| 2698 | msgctxt "#90366" | |
| 2699 | msgid "Include Unknown quality" | |
| 2700 | msgstr "Incluir qualidade desconhecida" | |
| 2701 | ||
| 2702 | msgctxt "#90367" | |
| 2703 | msgid "Includes releases where video quality could not be identified" | |
| 2704 | msgstr "Inclui releases onde a qualidade do video nao pode ser identificada" | |
| 2705 | ||
| 2706 | msgctxt "#90368" | |
| 2707 | msgid "Search by Keyword" | |
| 2708 | msgstr "Pesquisar por palavra-chave" | |
| 2709 | ||
| 2710 | msgctxt "#90369" | |
| 2711 | msgid "Latest Movies" | |
| 2712 | msgstr "Últimos filmes" | |
| 2713 | ||
| 2714 | msgctxt "#90370" | |
| 2715 | msgid "Latest Series" | |
| 2716 | msgstr "Últimas séries" | |
| 2717 | ||
| 2718 | msgctxt "#90371" | |
| 2719 | msgid "Raw Files" | |
| 2720 | msgstr "Arquivos brutos" | |
| 2721 | ||
| 2722 | msgctxt "#90372" | |
| 2723 | msgid "MDblist" | |
| 2724 | msgstr "MDblist" | |
| 2725 | ||
| 2726 | msgctxt "#90373" | |
| 2727 | msgid "Active Downloads" | |
| 2728 | msgstr "Downloads ativos" | |
| 2729 | ||
| 2730 | msgctxt "#90374" | |
| 2731 | msgid "Cancel Download" | |
| 2732 | msgstr "Cancelar download" | |
| 2733 | ||
| 2734 | msgctxt "#90375" | |
| 2735 | msgid "Delete File" | |
| 2736 | msgstr "Excluir arquivo" | |
| 2737 | ||
| 2738 | msgctxt "#90376" | |
| 2739 | msgid "Search Collections" | |
| 2740 | msgstr "Pesquisar coleções" | |
| 2741 | ||
| 2742 | msgctxt "#90377" | |
| 2743 | msgid "Popular Collections" | |
| 2744 | msgstr "Coleções populares" | |
| 2745 | ||
| 2746 | msgctxt "#90378" | |
| 2747 | msgid "Top Rated Collections" | |
| 2748 | msgstr "Coleções mais bem avaliadas" | |
| 2749 | ||
| 2750 | msgctxt "#90379" | |
| 2751 | msgid "Recommendations" | |
| 2752 | msgstr "Recomendações" | |
| 2753 | ||
| 2754 | msgctxt "#90380" | |
| 2755 | msgid "Similar" | |
| 2756 | msgstr "Semelhantes" | |
| 2757 | ||
| 2758 | msgctxt "#90381" | |
| 2759 | msgid "Keyword: %s" | |
| 2760 | msgstr "Palavra-chave: %s" | |
| 2761 | ||
| 2762 | msgctxt "#90410" | |
| 2763 | msgid "Error modifying Trakt Watchlist" | |
| 2764 | msgstr "Erro ao modificar a Watchlist do Trakt" | |
| 2765 | ||
| 2766 | msgctxt "#90411" | |
| 2767 | msgid "Marked as Watched on Trakt" | |
| 2768 | msgstr "Marcado como Visto no Trakt" | |
| 2769 | ||
| 2770 | msgctxt "#90412" | |
| 2771 | msgid "Error modifying Trakt Watched status" | |
| 2772 | msgstr "Erro ao modificar o estado de Visto no Trakt" | |
| 2773 | ||
| 2774 | msgctxt "#90413" | |
| 2775 | msgid "Play" | |
| 2776 | msgstr "Reproduzir" | |
| 2777 | ||
| 2778 | msgctxt "#90414" | |
| 2779 | msgid "Trailer" | |
| 2780 | msgstr "Trailer" | |
| 2781 | ||
| 2782 | msgctxt "#90415" | |
| 2783 | msgid "Watched" | |
| 2784 | msgstr "Visto" | |
| 2785 | ||
| 2786 | msgctxt "#90416" | |
| 2787 | msgid "Refresh" | |
| 2788 | msgstr "Atualizar" | |
| 2789 | ||
| 2790 | msgctxt "#90418" | |
| 2791 | msgid "No credits found for this ID" | |
| 2792 | msgstr "Nenhum crédito encontrado para este ID" | |
| 2793 | ||
| 2794 | msgctxt "#90419" | |
| 2795 | msgid "Credited People" | |
| 2796 | msgstr "Pessoas Creditadas" | |
| 2797 | ||
| 2798 | msgctxt "#90420" | |
| 2799 | msgid "Person Details" | |
| 2800 | msgstr "Detalhes da Pessoa" | |
| 2801 | ||
| 2802 | msgctxt "#90421" | |
| 2803 | msgid "Person not found" | |
| 2804 | msgstr "Pessoa não encontrada" | |
| 2805 | ||
| 2806 | msgctxt "#90422" | |
| 2807 | msgid "Jackgram not activated" | |
| 2808 | msgstr "Jackgram não ativado." | |
| 2809 | ||
| 2810 | msgctxt "#90423" | |
| 2811 | msgid "Not yet implemented" | |
| 2812 | msgstr "Ainda não implementado" | |
| 2813 | ||
| 2814 | msgctxt "#90424" | |
| 2815 | msgid "Unsupported debrid type" | |
| 2816 | msgstr "Tipo de debrid não suportado" | |
| 2817 | ||
| 2818 | msgctxt "#90425" | |
| 2819 | msgid "Easynews credentials required" | |
| 2820 | msgstr "Credenciais do Easynews obrigatórias" | |
| 2821 | ||
| 2822 | msgctxt "#90426" | |
| 2823 | msgid "No Corrupt or Missing Databases" | |
| 2824 | msgstr "Nenhum banco de dados corrompido ou ausente" | |
| 2825 | ||
| 2826 | msgctxt "#90427" | |
| 2827 | msgid "Otaku (script.otaku.mappings) not found" | |
| 2828 | msgstr "Otaku (script.otaku.mappings) não encontrado" | |
| 2829 | ||
| 2830 | msgctxt "#90431" | |
| 2831 | msgid "No items found in this list" | |
| 2832 | msgstr "Nenhum item encontrado nesta lista" | |
| 2833 | ||
| 2834 | msgctxt "#90432" | |
| 2835 | msgid "Databases Rebuilt" | |
| 2836 | msgstr "Bancos de dados reconstruídos" | |
| 2837 | ||
| 2838 | msgctxt "#90433" | |
| 2839 | msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s" | |
| 2840 | msgstr "[B]Os seguintes bancos de dados foram reconstruídos:[/B][CR][CR]%s" | |
| 2841 | ||
| 2842 | msgctxt "#90434" | |
| 2843 | msgid "Added to Trakt watchlist" | |
| 2844 | msgstr "Adicionado à watchlist do Trakt" | |
| 2845 | ||
| 2846 | msgctxt "#90435" | |
| 2847 | msgid "Failed to add to Trakt watchlist" | |
| 2848 | msgstr "Falha ao adicionar à watchlist do Trakt" | |
| 2849 | ||
| 2850 | msgctxt "#90436" | |
| 2851 | msgid "Removed from Trakt watchlist" | |
| 2852 | msgstr "Removido da watchlist do Trakt" | |
| 2853 | ||
| 2854 | msgctxt "#90437" | |
| 2855 | msgid "Failed to remove from Trakt watchlist" | |
| 2856 | msgstr "Falha ao remover da watchlist do Trakt" | |
| 2857 | ||
| 2858 | msgctxt "#90438" | |
| 2859 | msgid "Failed to mark as watched on Trakt" | |
| 2860 | msgstr "Falha ao marcar como assistido no Trakt" | |
| 2861 | ||
| 2862 | msgctxt "#90439" | |
| 2863 | msgid "Marked as unwatched on Trakt" | |
| 2864 | msgstr "Marcado como não assistido no Trakt" | |
| 2865 | ||
| 2866 | msgctxt "#90440" | |
| 2867 | msgid "Failed to mark as unwatched on Trakt" | |
| 2868 | msgstr "Falha ao marcar como não assistido no Trakt" | |
| 2869 | ||
| 2870 | msgctxt "#90441" | |
| 2871 | msgid "Added to Trakt collection" | |
| 2872 | msgstr "Adicionado à coleção do Trakt" | |
| 2873 | ||
| 2874 | msgctxt "#90442" | |
| 2875 | msgid "Removed from Trakt collection" | |
| 2876 | msgstr "Removido da coleção do Trakt" | |
| 2877 | ||
| 2878 | msgctxt "#90443" | |
| 2879 | msgid "Failed to add to Trakt collection" | |
| 2880 | msgstr "Falha ao adicionar à coleção do Trakt" | |
| 2881 | ||
| 2882 | msgctxt "#90444" | |
| 2883 | msgid "Failed to remove from Trakt collection" | |
| 2884 | msgstr "Falha ao remover da coleção do Trakt" | |
| 2885 | ||
| 2886 | msgctxt "#90445" | |
| 2887 | msgid "Added to Trakt favorites" | |
| 2888 | msgstr "Adicionado aos favoritos do Trakt" | |
| 2889 | ||
| 2890 | msgctxt "#90446" | |
| 2891 | msgid "Removed from Trakt favorites" | |
| 2892 | msgstr "Removido dos favoritos do Trakt" | |
| 2893 | ||
| 2894 | msgctxt "#90447" | |
| 2895 | msgid "Failed to add to Trakt favorites" | |
| 2896 | msgstr "Falha ao adicionar aos favoritos do Trakt" | |
| 2897 | ||
| 2898 | msgctxt "#90448" | |
| 2899 | msgid "Failed to remove from Trakt favorites" | |
| 2900 | msgstr "Falha ao remover dos favoritos do Trakt" | |
| 2901 | ||
| 2902 | msgctxt "#90449" | |
| 2903 | msgid "Created Trakt list" | |
| 2904 | msgstr "Lista do Trakt criada" | |
| 2905 | ||
| 2906 | msgctxt "#90450" | |
| 2907 | msgid "Failed to create Trakt list" | |
| 2908 | msgstr "Falha ao criar lista do Trakt" | |
| 2909 | ||
| 2910 | msgctxt "#90451" | |
| 2911 | msgid "Deleted Trakt list" | |
| 2912 | msgstr "Lista do Trakt excluída" | |
| 2913 | ||
| 2914 | msgctxt "#90452" | |
| 2915 | msgid "Failed to delete Trakt list" | |
| 2916 | msgstr "Falha ao excluir lista do Trakt" | |
| 2917 | ||
| 2918 | msgctxt "#90453" | |
| 2919 | msgid "Liked Trakt list" | |
| 2920 | msgstr "Curtiu a lista do Trakt" | |
| 2921 | ||
| 2922 | msgctxt "#90454" | |
| 2923 | msgid "Failed to like Trakt list" | |
| 2924 | msgstr "Falha ao curtir a lista do Trakt" | |
| 2925 | ||
| 2926 | msgctxt "#90455" | |
| 2927 | msgid "Unliked Trakt list" | |
| 2928 | msgstr "Curtir removido da lista do Trakt" | |
| 2929 | ||
| 2930 | msgctxt "#90456" | |
| 2931 | msgid "Failed to unlike Trakt list" | |
| 2932 | msgstr "Falha ao remover curtida da lista do Trakt" | |
| 2933 | ||
| 2934 | msgctxt "#90457" | |
| 2935 | msgid "Added to Trakt list" | |
| 2936 | msgstr "Adicionado à lista do Trakt" | |
| 2937 | ||
| 2938 | msgctxt "#90459" | |
| 2939 | msgid "Failed to add to Trakt list" | |
| 2940 | msgstr "Falha ao adicionar à lista do Trakt" | |
| 2941 | ||
| 2942 | msgctxt "#90461" | |
| 2943 | msgid "No Trakt lists found" | |
| 2944 | msgstr "Nenhuma lista do Trakt encontrada" | |
| 2945 | ||
| 2946 | msgctxt "#90462" | |
| 2947 | msgid "Item not found in Trakt collection" | |
| 2948 | msgstr "Item não encontrado na coleção do Trakt" | |
| 2949 | ||
| 2950 | msgctxt "#90463" | |
| 2951 | msgid "Trakt could not match this item for collection" | |
| 2952 | msgstr "O Trakt não conseguiu associar este item à coleção" | |
| 2953 | ||
| 2954 | msgctxt "#90464" | |
| 2955 | msgid "No Trakt collection changes were applied" | |
| 2956 | msgstr "Nenhuma alteração na coleção do Trakt foi aplicada" | |
| 2957 | ||
| 2958 | msgctxt "#90465" | |
| 2959 | msgid "Could not build Trakt collection payload for show" | |
| 2960 | msgstr "Não foi possível montar a payload da coleção do Trakt para a série" | |
| 2961 | ||
| 2962 | msgctxt "#90466" | |
| 2963 | msgid "Delete this Trakt list?" | |
| 2964 | msgstr "Excluir esta lista do Trakt?" | |
| 2965 | ||
| 2966 | msgctxt "#90510" | |
| 2967 | msgid "No meta available" | |
| 2968 | msgstr "Sem metadados disponíveis" | |
| 2969 | ||
| 2970 | msgctxt "#90511" | |
| 2971 | msgid "No content available for" | |
| 2972 | msgstr "Sem conteúdo disponível para" | |
| 2973 | ||
| 2974 | msgctxt "#90512" | |
| 2975 | msgid "Season" | |
| 2976 | msgstr "Temporada" | |
| 2977 | ||
| 2978 | msgctxt "#90513" | |
| 2979 | msgid "No episodes available" | |
| 2980 | msgstr "Sem episódios disponíveis" | |
| 2981 | ||
| 2982 | msgctxt "#90514" | |
| 2983 | msgid "No videos available" | |
| 2984 | msgstr "Sem vídeos disponíveis" | |
| 2985 | ||
| 2986 | msgctxt "#90515" | |
| 2987 | msgid "Next Page" | |
| 2988 | msgstr "Próxima página" | |
| 2989 | ||
| 2990 | msgctxt "#90516" | |
| 2991 | msgid "No metas available" | |
| 2992 | msgstr "Sem metadados disponíveis" | |
| 2993 | ||
| 2994 | msgctxt "#90517" | |
| 2995 | msgid "No seasons available" | |
| 2996 | msgstr "Sem temporadas disponíveis" | |
| 2997 | ||
| 2998 | msgctxt "#90518" | |
| 2999 | msgid "Invalid query" | |
| 3000 | msgstr "Consulta inválida" | |
| 3001 | ||
| 3002 | msgctxt "#90519" | |
| 3003 | msgid "No lists found" | |
| 3004 | msgstr "Nenhuma lista encontrada" | |
| 3005 | ||
| 3006 | msgctxt "#90520" | |
| 3007 | msgid "No user lists found" | |
| 3008 | msgstr "Nenhuma lista de usuário encontrada" | |
| 3009 | ||
| 3010 | msgctxt "#90521" | |
| 3011 | msgid "No top lists found" | |
| 3012 | msgstr "Nenhuma lista principal encontrada" | |
| 3013 | ||
| 3014 | msgctxt "#90522" | |
| 3015 | msgid "Custom Addon" | |
| 3016 | msgstr "Addon personalizado" | |
| 3017 | ||
| 3018 | msgctxt "#90523" | |
| 3019 | msgid "Enter the custom Stremio addon URL" | |
| 3020 | msgstr "Digite a URL do addon personalizado do Stremio" | |
| 3021 | ||
| 3022 | msgctxt "#90524" | |
| 3023 | msgid "No URL provided." | |
| 3024 | msgstr "Nenhuma URL fornecida." | |
| 3025 | ||
| 3026 | msgctxt "#90525" | |
| 3027 | msgid "Failed to fetch manifest: %s" | |
| 3028 | msgstr "Falha ao buscar o manifesto: %s" | |
| 3029 | ||
| 3030 | msgctxt "#90526" | |
| 3031 | msgid "Manifest missing 'id' or 'name'." | |
| 3032 | msgstr "O manifesto nao possui 'id' ou 'name'." | |
| 3033 | ||
| 3034 | msgctxt "#90527" | |
| 3035 | msgid "Custom Stremio addon added successfully!" | |
| 3036 | msgstr "Addon personalizado do Stremio adicionado com sucesso!" | |
| 3037 | ||
| 3038 | msgctxt "#90528" | |
| 3039 | msgid "Addon does not provide 'stream' or 'catalog' resources." | |
| 3040 | msgstr "O addon nao fornece recursos 'stream' ou 'catalog'." | |
| 3041 | ||
| 3042 | msgctxt "#90529" | |
| 3043 | msgid "This addon is already added to your list." | |
| 3044 | msgstr "Este addon ja foi adicionado a sua lista." | |
| 3045 | ||
| 3046 | msgctxt "#90530" | |
| 3047 | msgid "Failed to add custom addon: %s" | |
| 3048 | msgstr "Falha ao adicionar addon personalizado: %s" | |
| 3049 | ||
| 3050 | msgctxt "#90531" | |
| 3051 | msgid "Remove Addon" | |
| 3052 | msgstr "Remover addon" | |
| 3053 | ||
| 3054 | msgctxt "#90532" | |
| 3055 | msgid "No custom Stremio addons to remove." | |
| 3056 | msgstr "Nao ha addons personalizados do Stremio para remover." | |
| 3057 | ||
| 3058 | msgctxt "#90533" | |
| 3059 | msgid "Remove Stremio Addons" | |
| 3060 | msgstr "Remover addons do Stremio" | |
| 3061 | ||
| 3062 | msgctxt "#90534" | |
| 3063 | msgid "Selected addon(s) removed." | |
| 3064 | msgstr "Addon(s) selecionado(s) removido(s)." | |
| 3065 | ||
| 3066 | msgctxt "#90535" | |
| 3067 | msgid "Untitled List" | |
| 3068 | msgstr "Lista sem titulo" | |
| 3069 | ||
| 3070 | msgctxt "#90536" | |
| 3071 | msgid "Removed from Trakt list" | |
| 3072 | msgstr "Removido da lista do Trakt" | |
| 3073 | ||
| 3074 | msgctxt "#90537" | |
| 3075 | msgid "Failed to remove from Trakt list" | |
| 3076 | msgstr "Falha ao remover da lista do Trakt" | |
| 3077 | ||
| 3078 | msgctxt "#90538" | |
| 3079 | msgid "Resume from %.1f%%" | |
| 3080 | msgstr "Retomar de %.1f%%" | |
| 3081 | ||
| 3082 | msgctxt "#90539" | |
| 3083 | msgid "Start from Beginning" | |
| 3084 | msgstr "Comecar do inicio" | |
| 3085 | ||
| 3086 | msgctxt "#90540" | |
| 3087 | msgid "Authorize Debrid Services" | |
| 3088 | msgstr "Autorizar servicos Debrid" | |
| 3089 | ||
| 3090 | msgctxt "#90541" | |
| 3091 | msgid "Navigate to: [B]%s[/B]" | |
| 3092 | msgstr "Acesse: [B]%s[/B]" | |
| 3093 | ||
| 3094 | msgctxt "#90542" | |
| 3095 | msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3096 | msgstr "Digite o seguinte codigo: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3097 | ||
| 3098 | msgctxt "#90543" | |
| 3099 | msgid "Premiumize Auth" | |
| 3100 | msgstr "Autenticacao Premiumize" | |
| 3101 | ||
| 3102 | msgctxt "#90544" | |
| 3103 | msgid "Success" | |
| 3104 | msgstr "Sucesso" | |
| 3105 | ||
| 3106 | msgctxt "#90545" | |
| 3107 | msgid "Authentication completed." | |
| 3108 | msgstr "Autenticacao concluida." | |
| 3109 | ||
| 3110 | msgctxt "#90546" | |
| 3111 | msgid "Error:" | |
| 3112 | msgstr "Erro:" | |
| 3113 | ||
| 3114 | msgctxt "#90547" | |
| 3115 | msgid "Error: %s" | |
| 3116 | msgstr "Erro: %s" | |
| 3117 | ||
| 3118 | msgctxt "#90548" | |
| 3119 | msgid "Auth Error" | |
| 3120 | msgstr "Erro de autenticacao" | |
| 3121 | ||
| 3122 | msgctxt "#90549" | |
| 3123 | msgid "Torbox authentication successful." | |
| 3124 | msgstr "Autenticacao do Torbox concluida com sucesso." | |
| 3125 | ||
| 3126 | msgctxt "#90554" | |
| 3127 | msgid "Please wait..." | |
| 3128 | msgstr "Por favor, aguarde..." | |
| 3129 | ||
| 3130 | msgctxt "#90555" | |
| 3131 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3132 | msgstr "Acesse:\n[COLOR cyan]%s[/COLOR]\nDigite o codigo: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3133 | ||
| 3134 | msgctxt "#90556" | |
| 3135 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3136 | msgstr "Acesse:\n[COLOR cyan]%s[/COLOR]\n\nDigite o codigo: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3137 | ||
| 3138 | msgctxt "#90557" | |
| 3139 | msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]" | |
| 3140 | msgstr "Link do Pastebin:\n[COLOR cyan]%s[/COLOR]" | |
| 3141 | ||
| 3142 | msgctxt "#90670" | |
| 3143 | msgid "Local URL:\n[COLOR cyan]%s[/COLOR]" | |
| 3144 | msgstr "URL local:\n[COLOR cyan]%s[/COLOR]" | |
| 3145 | ||
| 3146 | msgctxt "#90558" | |
| 3147 | msgid "QR Code Authentication" | |
| 3148 | msgstr "Autenticacao por codigo QR" | |
| 3149 | ||
| 3150 | msgctxt "#90559" | |
| 3151 | msgid "Kodi 20 or above required. Please update Kodi to use this addon." | |
| 3152 | msgstr "E necessario Kodi 20 ou superior. Atualize o Kodi para usar este addon." | |
| 3153 | ||
| 3154 | msgctxt "#90560" | |
| 3155 | msgid "Failed to start web server. Port may be in use." | |
| 3156 | msgstr "Falha ao iniciar o servidor web. A porta pode estar em uso." | |
| 3157 | ||
| 3158 | msgctxt "#90561" | |
| 3159 | msgid "Authentification Removed." | |
| 3160 | msgstr "Autenticacao removida." | |
| 3161 | ||
| 3162 | msgctxt "#90562" | |
| 3163 | msgid "WebDAV Error" | |
| 3164 | msgstr "Erro de WebDAV" | |
| 3165 | ||
| 3166 | msgctxt "#90563" | |
| 3167 | msgid "Trakt Authorization" | |
| 3168 | msgstr "Autorizacao do Trakt" | |
| 3169 | ||
| 3170 | msgctxt "#90564" | |
| 3171 | msgid "Do you want to continue transfer in background?" | |
| 3172 | msgstr "Deseja continuar a transferencia em segundo plano?" | |
| 3173 | ||
| 3174 | msgctxt "#90565" | |
| 3175 | msgid "Cloud Transfer" | |
| 3176 | msgstr "Transferencia para a nuvem" | |
| 3177 | ||
| 3178 | msgctxt "#90566" | |
| 3179 | msgid "Kodi Logs" | |
| 3180 | msgstr "Logs do Kodi" | |
| 3181 | ||
| 3182 | msgctxt "#90567" | |
| 3183 | msgid "Show Logs" | |
| 3184 | msgstr "Mostrar logs" | |
| 3185 | ||
| 3186 | msgctxt "#90568" | |
| 3187 | msgid "Export to paste.kodi.tv" | |
| 3188 | msgstr "Exportar para paste.kodi.tv" | |
| 3189 | ||
| 3190 | msgctxt "#90569" | |
| 3191 | msgid "Kodi Logs Exported" | |
| 3192 | msgstr "Logs do Kodi exportados" | |
| 3193 | ||
| 3194 | msgctxt "#90570" | |
| 3195 | msgid "Providers Selection Dialog" | |
| 3196 | msgstr "Dialogo de selecao de provedores" | |
| 3197 | ||
| 3198 | msgctxt "#90571" | |
| 3199 | msgid "Your current Providers are: \n%s\n\nDo you want to change?" | |
| 3200 | msgstr "Seus provedores atuais sao: \n%s\n\nDeseja alterá-los?" | |
| 3201 | ||
| 3202 | msgctxt "#90572" | |
| 3203 | msgid "Select Providers" | |
| 3204 | msgstr "Selecionar provedores" | |
| 3205 | ||
| 3206 | msgctxt "#90573" | |
| 3207 | msgid "Selection Dialog" | |
| 3208 | msgstr "Dialogo de selecao" | |
| 3209 | ||
| 3210 | msgctxt "#90574" | |
| 3211 | msgid "Successfully selected: %s" | |
| 3212 | msgstr "Selecionado com sucesso: %s" | |
| 3213 | ||
| 3214 | msgctxt "#90575" | |
| 3215 | msgid "Selection" | |
| 3216 | msgstr "Selecao" | |
| 3217 | ||
| 3218 | msgctxt "#90576" | |
| 3219 | msgid "No providers selected" | |
| 3220 | msgstr "Nenhum provedor selecionado" | |
| 3221 | ||
| 3222 | msgctxt "#90577" | |
| 3223 | msgid "Changelog" | |
| 3224 | msgstr "Registro de alteracoes" | |
| 3225 | ||
| 3226 | msgctxt "#90578" | |
| 3227 | msgid "[B]Unable to check for updates[/B]" | |
| 3228 | msgstr "[B]Nao foi possivel verificar atualizacoes[/B]" | |
| 3229 | ||
| 3230 | msgctxt "#90579" | |
| 3231 | msgid "[B]No update available[/B]" | |
| 3232 | msgstr "[B]Nenhuma atualizacao disponivel[/B]" | |
| 3233 | ||
| 3234 | msgctxt "#90580" | |
| 3235 | msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3236 | msgstr "Instalado: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3237 | ||
| 3238 | msgctxt "#90581" | |
| 3239 | msgid "[B]Update available. Do you want to update?[/B]" | |
| 3240 | msgstr "[B]Atualizacao disponivel. Deseja atualizar?[/B]" | |
| 3241 | ||
| 3242 | msgctxt "#90582" | |
| 3243 | msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]" | |
| 3244 | msgstr "[B]Atualizacao disponivel (v%s).[/B] [I]Verifique as configuracoes para atualizar.[/I]" | |
| 3245 | ||
| 3246 | msgctxt "#90583" | |
| 3247 | msgid "Error fetching versions: %s" | |
| 3248 | msgstr "Erro ao obter versoes: %s" | |
| 3249 | ||
| 3250 | msgctxt "#90584" | |
| 3251 | msgid "No older/different versions found." | |
| 3252 | msgstr "Nenhuma versao anterior ou diferente encontrada." | |
| 3253 | ||
| 3254 | msgctxt "#90585" | |
| 3255 | msgid "Select Version to Downgrade" | |
| 3256 | msgstr "Selecione a versao para downgrade" | |
| 3257 | ||
| 3258 | msgctxt "#90586" | |
| 3259 | msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?" | |
| 3260 | msgstr "Tem certeza de que deseja fazer downgrade ou reinstalar a versao [B]%s[/B]?" | |
| 3261 | ||
| 3262 | msgctxt "#90587" | |
| 3263 | msgid "[B]Downloading update...[/B]" | |
| 3264 | msgstr "[B]Baixando atualizacao...[/B]" | |
| 3265 | ||
| 3266 | msgctxt "#90588" | |
| 3267 | msgid "Error: Unable to download update." | |
| 3268 | msgstr "Erro: nao foi possivel baixar a atualizacao." | |
| 3269 | ||
| 3270 | msgctxt "#90589" | |
| 3271 | msgid "Error saving update file: %s" | |
| 3272 | msgstr "Erro ao salvar o arquivo de atualizacao: %s" | |
| 3273 | ||
| 3274 | msgctxt "#90590" | |
| 3275 | msgid "Error extracting update. Please install manually." | |
| 3276 | msgstr "Erro ao extrair a atualizacao. Instale manualmente." | |
| 3277 | ||
| 3278 | msgctxt "#90591" | |
| 3279 | msgid "Do you want to view the changelog for the new version?" | |
| 3280 | msgstr "Deseja ver o registro de alteracoes da nova versao?" | |
| 3281 | ||
| 3282 | msgctxt "#90592" | |
| 3283 | msgid "New Release v%s - Changelog" | |
| 3284 | msgstr "Nova versao v%s - Registro de alteracoes" | |
| 3285 | ||
| 3286 | msgctxt "#90593" | |
| 3287 | msgid "[B]Update complete.[/B]" | |
| 3288 | msgstr "[B]Atualizacao concluida.[/B]" | |
| 3289 | ||
| 3290 | msgctxt "#90594" | |
| 3291 | msgid "Real Debrid Auth" | |
| 3292 | msgstr "Autenticacao do Real Debrid" | |
| 3293 | ||
| 3294 | msgctxt "#90595" | |
| 3295 | msgid "Torbox Auth" | |
| 3296 | msgstr "Autenticacao do Torbox" | |
| 3297 | ||
| 3298 | msgctxt "#90596" | |
| 3299 | msgid "Debrider Auth" | |
| 3300 | msgstr "Autenticacao do Debrider" | |
| 3301 | ||
| 3302 | msgctxt "#90597" | |
| 3303 | msgid "Download Error" | |
| 3304 | msgstr "Erro de download" | |
| 3305 | ||
| 3306 | msgctxt "#90598" | |
| 3307 | msgid "Failed to download text: %s" | |
| 3308 | msgstr "Falha ao baixar o texto: %s" | |
| 3309 | ||
| 3310 | msgctxt "#90599" | |
| 3311 | msgid "General Error" | |
| 3312 | msgstr "Erro geral" | |
| 3313 | ||
| 3314 | msgctxt "#90600" | |
| 3315 | msgid "Could not display text file." | |
| 3316 | msgstr "Nao foi possivel exibir o arquivo de texto." | |
| 3317 | ||
| 3318 | msgctxt "#90601" | |
| 3319 | msgid "Failed to export logs." | |
| 3320 | msgstr "Falha ao exportar os logs." | |
| 3321 | ||
| 3322 | msgctxt "#90602" | |
| 3323 | msgid "Error reading log: %s" | |
| 3324 | msgstr "Erro ao ler o log: %s" | |
| 3325 | ||
| 3326 | msgctxt "#90603" | |
| 3327 | msgid "Kodi log file not found." | |
| 3328 | msgstr "Arquivo de log do Kodi nao encontrado." | |
| 3329 | ||
| 3330 | msgctxt "#90604" | |
| 3331 | msgid "%s Auth" | |
| 3332 | msgstr "Autenticacao de %s" | |
| 3333 | ||
| 3334 | msgctxt "#90605" | |
| 3335 | msgid "Ok" | |
| 3336 | msgstr "Ok" | |
| 3337 | ||
| 3338 | msgctxt "#90606" | |
| 3339 | msgid "No" | |
| 3340 | msgstr "Nao" | |
| 3341 | ||
| 3342 | msgctxt "#90607" | |
| 3343 | msgid "Pinging addons for availability..." | |
| 3344 | msgstr "Verificando disponibilidade dos addons..." | |
| 3345 | ||
| 3346 | msgctxt "#90608" | |
| 3347 | msgid "Pinging addons... %d/%d" | |
| 3348 | msgstr "Verificando addons... %d/%d" | |
| 3349 | ||
| 3350 | msgctxt "#90609" | |
| 3351 | msgid "Ping Results" | |
| 3352 | msgstr "Resultados do ping" | |
| 3353 | ||
| 3354 | msgctxt "#90610" | |
| 3355 | msgid "Reachable: %d\nUnreachable: %d" | |
| 3356 | msgstr "Acessiveis: %d\nInacessiveis: %d" | |
| 3357 | ||
| 3358 | msgctxt "#90611" | |
| 3359 | msgid "Select Category to Filter" | |
| 3360 | msgstr "Selecione a categoria para filtrar" | |
| 3361 | ||
| 3362 | msgctxt "#90612" | |
| 3363 | msgid "Stremio Add-ons Import" | |
| 3364 | msgstr "Importacao de addons do Stremio" | |
| 3365 | ||
| 3366 | msgctxt "#90613" | |
| 3367 | msgid "To import your add-ons, please log in with your Stremio email and password." | |
| 3368 | msgstr "Para importar seus addons, faca login com seu email e senha do Stremio." | |
| 3369 | ||
| 3370 | msgctxt "#90614" | |
| 3371 | msgid "Enter your Email" | |
| 3372 | msgstr "Digite seu email" | |
| 3373 | ||
| 3374 | msgctxt "#90615" | |
| 3375 | msgid "Enter your Password" | |
| 3376 | msgstr "Digite sua senha" | |
| 3377 | ||
| 3378 | msgctxt "#90616" | |
| 3379 | msgid "Login Failed" | |
| 3380 | msgstr "Falha no login" | |
| 3381 | ||
| 3382 | msgctxt "#90617" | |
| 3383 | msgid "Failed to login: %s" | |
| 3384 | msgstr "Falha ao fazer login: %s" | |
| 3385 | ||
| 3386 | msgctxt "#90618" | |
| 3387 | msgid "Add-ons Import Failed" | |
| 3388 | msgstr "Falha na importacao de addons" | |
| 3389 | ||
| 3390 | msgctxt "#90619" | |
| 3391 | msgid "Please try again later and report the issue if the problem persists. For more details, check the log file." | |
| 3392 | msgstr "Tente novamente mais tarde e relate o problema se ele persistir. Para mais detalhes, consulte o arquivo de log." | |
| 3393 | ||
| 3394 | msgctxt "#90620" | |
| 3395 | msgid "Addons Imported" | |
| 3396 | msgstr "Addons importados" | |
| 3397 | ||
| 3398 | msgctxt "#90621" | |
| 3399 | msgid "Successfully imported addons from your account." | |
| 3400 | msgstr "Os addons da sua conta foram importados com sucesso." | |
| 3401 | ||
| 3402 | msgctxt "#90622" | |
| 3403 | msgid "Update Stremio Addons" | |
| 3404 | msgstr "Atualizar addons do Stremio" | |
| 3405 | ||
| 3406 | msgctxt "#90623" | |
| 3407 | msgid "Do you want to update the Addons from your account?" | |
| 3408 | msgstr "Deseja atualizar os addons da sua conta?" | |
| 3409 | ||
| 3410 | msgctxt "#90624" | |
| 3411 | msgid "Log Out from Stremio" | |
| 3412 | msgstr "Sair do Stremio" | |
| 3413 | ||
| 3414 | msgctxt "#90625" | |
| 3415 | msgid "Are you sure you want to log out? You can continue using Stremio without logging in, but your settings will be reset to the default configuration." | |
| 3416 | msgstr "Tem certeza de que deseja sair? Voce pode continuar usando o Stremio sem fazer login, mas suas configuracoes serao redefinidas para o padrao." | |
| 3417 | ||
| 3418 | msgctxt "#90626" | |
| 3419 | msgid "Log Out" | |
| 3420 | msgstr "Sair" | |
| 3421 | ||
| 3422 | msgctxt "#90627" | |
| 3423 | msgid "Use Existing" | |
| 3424 | msgstr "Usar existentes" | |
| 3425 | ||
| 3426 | msgctxt "#90628" | |
| 3427 | msgid "Download New" | |
| 3428 | msgstr "Baixar novos" | |
| 3429 | ||
| 3430 | msgctxt "#90629" | |
| 3431 | msgid "Playback Failed" | |
| 3432 | msgstr "Falha na reproducao" | |
| 3433 | ||
| 3434 | msgctxt "#90630" | |
| 3435 | msgid "The video could not be played.\nDo you want to retry?" | |
| 3436 | msgstr "O video nao pode ser reproduzido.\nDeseja tentar novamente?" | |
| 3437 | ||
| 3438 | msgctxt "#90631" | |
| 3439 | msgid "Retry" | |
| 3440 | msgstr "Tentar novamente" | |
| 3441 | ||
| 3442 | msgctxt "#90632" | |
| 3443 | msgid "Information" | |
| 3444 | msgstr "Informacao" | |
| 3445 | ||
| 3446 | msgctxt "#90633" | |
| 3447 | msgid "Review" | |
| 3448 | msgstr "Resenha" | |
| 3449 | ||
| 3450 | msgctxt "#90634" | |
| 3451 | msgid "Comment" | |
| 3452 | msgstr "Comentario" | |
| 3453 | ||
| 3454 | msgctxt "#90635" | |
| 3455 | msgid "Trivia" | |
| 3456 | msgstr "Trivia" | |
| 3457 | ||
| 3458 | msgctxt "#90636" | |
| 3459 | msgid "Goof" | |
| 3460 | msgstr "Erro" | |
| 3461 | ||
| 3462 | msgctxt "#90637" | |
| 3463 | msgid "Parental Guide" | |
| 3464 | msgstr "Guia parental" | |
| 3465 | ||
| 3466 | msgctxt "#90638" | |
| 3467 | msgid "Converting Magnet...\n\n%s" | |
| 3468 | msgstr "Convertendo magnet...\n\n%s" | |
| 3469 | ||
| 3470 | msgctxt "#90639" | |
| 3471 | msgid "Real Debrid Error." | |
| 3472 | msgstr "Erro do Real Debrid." | |
| 3473 | ||
| 3474 | msgctxt "#90640" | |
| 3475 | msgid "File already cached" | |
| 3476 | msgstr "Arquivo ja em cache" | |
| 3477 | ||
| 3478 | msgctxt "#90641" | |
| 3479 | msgid "Saving file to the Real Debrid Cloud" | |
| 3480 | msgstr "Salvando arquivo na nuvem do Real Debrid" | |
| 3481 | ||
| 3482 | msgctxt "#90642" | |
| 3483 | msgid "File cached" | |
| 3484 | msgstr "Arquivo em cache" | |
| 3485 | ||
| 3486 | msgctxt "#90643" | |
| 3487 | msgid "Saving File to the Real Debrid Cloud...\n%s\n\n" | |
| 3488 | msgstr "Salvando arquivo na nuvem do Real Debrid...\n%s\n\n" | |
| 3489 | ||
| 3490 | msgctxt "#90644" | |
| 3491 | msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed" | |
| 3492 | msgstr "Baixando %s GB a %s mbps de %s peers, %s %% concluido" | |
| 3493 | ||
| 3494 | msgctxt "#90645" | |
| 3495 | msgid "Trakt" | |
| 3496 | msgstr "Trakt" | |
| 3497 | ||
| 3498 | msgctxt "#90651" | |
| 3499 | msgid "Easynews Account Info" | |
| 3500 | msgstr "Informacoes da conta Easynews" | |
| 3501 | ||
| 3502 | msgctxt "#90652" | |
| 3503 | msgid "DeepL API Error" | |
| 3504 | msgstr "Erro da API do DeepL" | |
| 3505 | ||
| 3506 | msgctxt "#90653" | |
| 3507 | msgid "Download" | |
| 3508 | msgstr "Download" | |
| 3509 | ||
| 3510 | msgctxt "#90654" | |
| 3511 | msgid "Failed to start download: %s" | |
| 3512 | msgstr "Falha ao iniciar o download: %s" | |
| 3513 | ||
| 3514 | msgctxt "#90655" | |
| 3515 | msgid "Modify Search" | |
| 3516 | msgstr "Modificar busca" | |
| 3517 | ||
| 3518 | msgctxt "#90656" | |
| 3519 | msgid "Torbox" | |
| 3520 | msgstr "Torbox" | |
| 3521 | ||
| 3522 | msgctxt "#90657" | |
| 3523 | msgid "Easy-Debrid" | |
| 3524 | msgstr "Easy-Debrid" | |
| 3525 | ||
| 3526 | msgctxt "#90658" | |
| 3527 | msgid "All-Debrid" | |
| 3528 | msgstr "All-Debrid" | |
| 3529 | ||
| 3530 | msgctxt "#90659" | |
| 3531 | msgid "Real-Debrid" | |
| 3532 | msgstr "Real-Debrid" | |
| 3533 | ||
| 3534 | msgctxt "#90660" | |
| 3535 | msgid "Getting results from providers..." | |
| 3536 | msgstr "Obtendo resultados dos provedores..." | |
| 3537 | ||
| 3538 | msgctxt "#90661" | |
| 3539 | msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3540 | msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3541 | ||
| 3542 | msgctxt "#90662" | |
| 3543 | msgid "Downloads Viewer" | |
| 3544 | msgstr "Visualizador de downloads" | |
| 3545 | ||
| 3546 | msgctxt "#90663" | |
| 3547 | msgid "WebDAV Text Viewer" | |
| 3548 | msgstr "Visualizador de texto WebDAV" | |
| 3549 | ||
| 3550 | msgctxt "#90664" | |
| 3551 | msgid "Selected Item" | |
| 3552 | msgstr "Item selecionado" | |
| 3553 | ||
| 3554 | msgctxt "#90665" | |
| 3555 | msgid "Subtitle No. %d" | |
| 3556 | msgstr "Legenda n.o %d" | |
| 3557 | ||
| 3558 | msgctxt "#90666" | |
| 3559 | msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?" | |
| 3560 | msgstr "Uso gratuito do DeepL disponivel.\nCaracteres restantes na cota gratuita: %s\nCaracteres para traduzir: %s\n\nDeseja continuar com a traducao?" | |
| 3561 | ||
| 3562 | msgctxt "#90667" | |
| 3563 | msgid "Free DeepL Usage" | |
| 3564 | msgstr "Uso gratuito do DeepL" | |
| 3565 | ||
| 3566 | msgctxt "#90668" | |
| 3567 | msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?" | |
| 3568 | msgstr "Total de caracteres para traduzir: %s\nCusto estimado: $%.2f USD\n\nDeseja continuar com a traducao?" | |
| 3569 | ||
| 3570 | msgctxt "#90669" | |
| 3571 | msgid "Translation Cost" | |
| 3572 | msgstr "Custo da traducao" | |
| 3573 | ||
| 3574 | msgctxt "#90672" | |
| 3575 | msgid "Play Trailer" | |
| 3576 | msgstr "Reproduzir trailer" | |
| 3577 | ||
| 3578 | msgctxt "#90673" | |
| 3579 | msgid "Trailer unavailable" | |
| 3580 | msgstr "Trailer indisponivel" | |
| 3581 | ||
| 3582 | msgctxt "#90690" | |
| 3583 | msgid "Clear All Items" | |
| 3584 | msgstr "Clear All Items" | |
| 3585 | ||
| 3586 | msgctxt "#90691" | |
| 3587 | msgid "Remove all items from this library?" | |
| 3588 | msgstr "Remove all items from this library?" | |
| 3589 | ||
| 3590 | msgctxt "#90692" | |
| 3591 | msgid "Library cleared" | |
| 3592 | msgstr "Library cleared" | |
| 3593 | ||
| 3594 | msgctxt "#90693" | |
| 3595 | msgid "No items to remove" | |
| 3596 | msgstr "No items to remove" | |
| 3597 | ||
| 3598 | msgctxt "#90694" | |
| 3599 | msgid "Added to %s cloud but still downloading. Please wait before playing." | |
| 3600 | msgstr "Adicionado à nuvem do %s mas ainda baixando. Por favor aguarde antes de reproduzir." | |
| 3601 | ||
| 3602 | msgctxt "#90695" | |
| 3603 | msgid "Torrent Not Ready" | |
| 3604 | msgstr "Torrent Não Pronto" | |
| 3605 | ||
| 3606 | msgctxt "#90696" | |
| 3607 | msgid "This torrent is still downloading in %s and is not ready yet." | |
| 3608 | msgstr "Este torrent ainda está baixando no %s e não está pronto ainda." | |
| 3609 | ||
| 3610 | msgctxt "#90697" | |
| 3611 | msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?" | |
| 3612 | msgstr "Deseja reproduzir usando o cliente torrent (pode ser mais lento) ou cancelar e esperar o debrid terminar?" | |
| 3613 | ||
| 3614 | msgctxt "#90698" | |
| 3615 | msgid "Play with Torrent" | |
| 3616 | msgstr "Reproduzir com Torrent" | |
| 3617 | ||
| 3618 | msgctxt "#90699" | |
| 3619 | msgid "Cancel & Wait" | |
| 3620 | msgstr "Cancelar e Esperar" | |
| 3621 | ||
| 3622 | msgctxt "#90742" | |
| 3623 | msgid "Addon Settings" | |
| 3624 | msgstr "Configurações do addon" | |
| 3625 | ||
| 3626 | msgctxt "#90743" | |
| 3627 | msgid "Set Views" | |
| 3628 | msgstr "Definir visualizações" | |
| 3629 | ||
| 3630 | msgctxt "#90744" | |
| 3631 | msgid "Upload Subtitle" | |
| 3632 | msgstr "Enviar legendas" | |
| 3633 | ||
| 3634 | msgctxt "#90745" | |
| 3635 | msgid "Select Subtitle File" | |
| 3636 | msgstr "Selecionar arquivo de legendas" | |
| 3637 | ||
| 3638 | msgctxt "#90746" | |
| 3639 | msgid "Local File" | |
| 3640 | msgstr "Arquivo local" | |
| 3641 | ||
| 3642 | msgctxt "#90747" | |
| 3643 | msgid "Upload from Browser" | |
| 3644 | msgstr "Enviar do navegador" | |
| 3645 | ||
| 3646 | msgctxt "#90748" | |
| 3647 | msgid "Upload subtitle from your browser" | |
| 3648 | msgstr "Enviar legenda do seu navegador" | |
| 3649 | ||
| 3650 | msgctxt "#90749" | |
| 3651 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" | |
| 3652 | msgstr "Escaneie o código QR ou abra a URL (%s) no seu navegador para enviar um arquivo de legendas" | |
| 3653 | ||
| 3654 | msgctxt "#90750" | |
| 3655 | msgid "Search Title Language" | |
| 3656 | msgstr "Idioma do título de busca" | |
| 3657 | ||
| 3658 | msgctxt "#90751" | |
| 3659 | msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches." | |
| 3660 | msgstr "Controla como as buscas por título de Easynews/Jackett/Prowlarr são priorizadas nas buscas de contexto." | |
| 3661 | ||
| 3662 | msgctxt "#90752" | |
| 3663 | msgid "English first" | |
| 3664 | msgstr "Inglês primeiro" | |
| 3665 | ||
| 3666 | msgctxt "#90753" | |
| 3667 | msgid "English only" | |
| 3668 | msgstr "Somente inglês" | |
| 3669 | ||
| 3670 | msgctxt "#90754" | |
| 3671 | msgid "Localized first" | |
| 3672 | msgstr "Localizado primeiro" |
| … | … | @@ -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 | # | |
| 6 | msgid "" | |
| 7 | msgstr "" | |
| 8 | "Report-Msgid-Bugs-To: \n" | |
| 9 | "POT-Creation-Date: 2023-09-19 19:53+0200\n" | |
| 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | |
| 11 | "Language: 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 | ||
| 18 | msgctxt "#30000" | |
| 19 | msgid "Players" | |
| 20 | msgstr "Players" | |
| 21 | ||
| 22 | msgctxt "#30001" | |
| 23 | msgid "Clients" | |
| 24 | msgstr "Clientes:" | |
| 25 | ||
| 26 | msgctxt "#30002" | |
| 27 | msgid "Torrent Client" | |
| 28 | msgstr "Cliente" | |
| 29 | ||
| 30 | msgctxt "#30020" | |
| 31 | msgid "Indexers" | |
| 32 | msgstr "Indexadores" | |
| 33 | ||
| 34 | msgctxt "#30021" | |
| 35 | msgid "Indexer" | |
| 36 | msgstr "Indexador" | |
| 37 | ||
| 38 | msgctxt "#30022" | |
| 39 | msgid "Jackett Configuration:" | |
| 40 | msgstr "Configuração do Jackett:" | |
| 41 | ||
| 42 | msgctxt "#30023" | |
| 43 | msgid "Client Key" | |
| 44 | msgstr "Chave de API do Jackett" | |
| 45 | ||
| 46 | msgctxt "#30025" | |
| 47 | msgid "Host" | |
| 48 | msgstr "Host do Jackett" | |
| 49 | ||
| 50 | msgctxt "#30026" | |
| 51 | msgid "Description length" | |
| 52 | msgstr "Tamanho da descrição" | |
| 53 | ||
| 54 | msgctxt "#30027" | |
| 55 | msgid "Insecured connection" | |
| 56 | msgstr "Conexão insegura" | |
| 57 | ||
| 58 | msgctxt "#30028" | |
| 59 | msgid "Sort by" | |
| 60 | msgstr "Ordenar por" | |
| 61 | ||
| 62 | msgctxt "#30029" | |
| 63 | msgid "Total Results" | |
| 64 | msgstr "Total de Resultados" | |
| 65 | ||
| 66 | msgctxt "#30030" | |
| 67 | msgid "Timeout" | |
| 68 | msgstr "Tempo limite" | |
| 69 | ||
| 70 | msgctxt "#30031" | |
| 71 | msgid "Timeout" | |
| 72 | msgstr "Tempo limite" | |
| 73 | ||
| 74 | msgctxt "#30050" | |
| 75 | msgid "Prowlarr Configuration:" | |
| 76 | msgstr "Configuração do Prowlarr:" | |
| 77 | ||
| 78 | msgctxt "#30051" | |
| 79 | msgid "Client Key" | |
| 80 | msgstr "Chave de API do Prowlarr" | |
| 81 | ||
| 82 | msgctxt "#30052" | |
| 83 | msgid "Host" | |
| 84 | msgstr "Host do Prowlarr" | |
| 85 | ||
| 86 | msgctxt "#30053" | |
| 87 | msgid "Indexers-Ids" | |
| 88 | msgstr "IDs dos Indexadores " | |
| 89 | ||
| 90 | msgctxt "#30054" | |
| 91 | msgid "Anime IndexersIds" | |
| 92 | msgstr "IDs dos Indexadores para Anime" | |
| 93 | ||
| 94 | msgctxt "#30100" | |
| 95 | msgid "TMDB" | |
| 96 | msgstr "TMDB" | |
| 97 | ||
| 98 | msgctxt "#30102" | |
| 99 | msgid "TMDB Configuration" | |
| 100 | msgstr "Configuração do TMDB:" | |
| 101 | ||
| 102 | msgctxt "#30103" | |
| 103 | msgid "Client Key" | |
| 104 | msgstr "Chave de API do TMDB" | |
| 105 | ||
| 106 | msgctxt "#30104" | |
| 107 | msgid "Clear tmdb cache" | |
| 108 | msgstr "Limpar cache do TMDB" | |
| 109 | ||
| 110 | msgctxt "#30105" | |
| 111 | msgid "Cache expiration hours" | |
| 112 | msgstr "Expiração do cache (Horas)" | |
| 113 | ||
| 114 | msgctxt "#30106" | |
| 115 | msgid "Clear Cache" | |
| 116 | msgstr "Limpar o cache" | |
| 117 | ||
| 118 | msgctxt "#30107" | |
| 119 | msgid "Enable Cache" | |
| 120 | msgstr "Ativar cache" | |
| 121 | ||
| 122 | msgctxt "#30140" | |
| 123 | msgid "AniList" | |
| 124 | msgstr "AniList" | |
| 125 | ||
| 126 | msgctxt "#30142" | |
| 127 | msgid "AniList Configuration:" | |
| 128 | msgstr "Configuração do AniList:" | |
| 129 | ||
| 130 | msgctxt "#30143" | |
| 131 | msgid "Client ID" | |
| 132 | msgstr "ID do cliente" | |
| 133 | ||
| 134 | msgctxt "#30144" | |
| 135 | msgid "Client Secret" | |
| 136 | msgstr "Segredo do cliente" | |
| 137 | ||
| 138 | msgctxt "#30150" | |
| 139 | msgid "Services" | |
| 140 | msgstr "Services" | |
| 141 | ||
| 142 | msgctxt "#30152" | |
| 143 | msgid "Real Debrid Configuration" | |
| 144 | msgstr "Configuração do Real Debrid" | |
| 145 | ||
| 146 | msgctxt "#30154" | |
| 147 | msgid "Token" | |
| 148 | msgstr "Token:" | |
| 149 | ||
| 150 | msgctxt "#30200" | |
| 151 | msgid "Fanart.TV" | |
| 152 | msgstr "Fanart.TV" | |
| 153 | ||
| 154 | msgctxt "#30202" | |
| 155 | msgid "Fanart.TV Configuration:" | |
| 156 | msgstr "Configuração do Fanart.TV:" | |
| 157 | ||
| 158 | msgctxt "#30203" | |
| 159 | msgid "Client ID" | |
| 160 | msgstr "ID do cliente" | |
| 161 | ||
| 162 | msgctxt "#30221" | |
| 163 | msgid "No Client Key provided for" | |
| 164 | msgstr "Nenhuma chave de API fornecida para" | |
| 165 | ||
| 166 | msgctxt "#30223" | |
| 167 | msgid "You need to configure" | |
| 168 | msgstr "Você precisa configurar" | |
| 169 | ||
| 170 | msgctxt "#30228" | |
| 171 | msgid "The request to Torrentio failed." | |
| 172 | msgstr "A requisição para o Torrentio falhou." | |
| 173 | ||
| 174 | msgctxt "#30229" | |
| 175 | msgid "The request to Jackett failed." | |
| 176 | msgstr "A requisição para o Jackett falhou." | |
| 177 | ||
| 178 | msgctxt "#30230" | |
| 179 | msgid "The request to Prowlarr failed." | |
| 180 | msgstr "A requisição para o Prowlarr falhou." | |
| 181 | ||
| 182 | msgctxt "#30231" | |
| 183 | msgid "The request to Elhosted failed." | |
| 184 | msgstr "A requisição para o Elhosted falhou." | |
| 185 | ||
| 186 | msgctxt "#30232" | |
| 187 | msgid "The request to Jackgram failed." | |
| 188 | msgstr "A requisição para o Jackgram falhou" | |
| 189 | ||
| 190 | msgctxt "#30233" | |
| 191 | msgid "The request to Mediafusion failed." | |
| 192 | msgstr "A requisição para o Mediafusion falhou" | |
| 193 | ||
| 194 | msgctxt "#30234" | |
| 195 | msgid "The request to Peerflix failed." | |
| 196 | msgstr "A requisição para o Peerflix falhou" | |
| 197 | ||
| 198 | msgctxt "#30235" | |
| 199 | msgid "Stremio" | |
| 200 | msgstr "Stremio" | |
| 201 | ||
| 202 | msgctxt "#30236" | |
| 203 | msgid "Stremio add-ons" | |
| 204 | msgstr "Add-ons do Stremio" | |
| 205 | ||
| 206 | msgctxt "#30237" | |
| 207 | msgid "Import your config" | |
| 208 | msgstr "Importar as suas definições" | |
| 209 | ||
| 210 | msgctxt "#30238" | |
| 211 | msgid "Manage add-ons" | |
| 212 | msgstr "Gerir add-ons" | |
| 213 | ||
| 214 | msgctxt "#30240" | |
| 215 | msgid "TMDB cache cleared" | |
| 216 | msgstr "Cache do TMDB limpo" | |
| 217 | ||
| 218 | msgctxt "#30241" | |
| 219 | msgid "Search on TMDB:" | |
| 220 | msgstr "Pesquisar no TMDB:" | |
| 221 | ||
| 222 | msgctxt "#30242" | |
| 223 | msgid "Search on AniList:" | |
| 224 | msgstr "Pesquisar no AniList:" | |
| 225 | ||
| 226 | msgctxt "#30243" | |
| 227 | msgid "Search for a keyword:" | |
| 228 | msgstr "Pesquisar por uma palavra-chave:" | |
| 229 | ||
| 230 | msgctxt "#30244" | |
| 231 | msgid "Cache cleared" | |
| 232 | msgstr "Cache limpo" | |
| 233 | ||
| 234 | msgctxt "#30249" | |
| 235 | msgid "Addon Jacktook Burst not found - (script.jacktook.burst)" | |
| 236 | msgstr "Addon Jacktook Burst not found - (script.jacktook.burst)" | |
| 237 | ||
| 238 | msgctxt "#30250" | |
| 239 | msgid "Addon Torrest not found - (plugin.video.torrest)" | |
| 240 | msgstr "Addon Torrest não encontrado - (plugin.video.torrest)" | |
| 241 | ||
| 242 | msgctxt "#30251" | |
| 243 | msgid "No sources found to play" | |
| 244 | msgstr "Nenhuma fonte encontrada para reproduzir" | |
| 245 | ||
| 246 | msgctxt "#30252" | |
| 247 | msgid "Addon Elementum not found - (plugin.video.elementum)" | |
| 248 | msgstr "Addon Elementum não encontrado - (plugin.video.elementum)" | |
| 249 | ||
| 250 | msgctxt "#30253" | |
| 251 | msgid "Addon Jacktorr not found - (plugin.video.jacktorr)" | |
| 252 | msgstr "Addon Jacktorr não encontrado - (plugin.video.jacktorr)" | |
| 253 | ||
| 254 | msgctxt "#30254" | |
| 255 | msgid "Do you want to install Elementum?" | |
| 256 | msgstr "Deseja instalar Elementum?" | |
| 257 | ||
| 258 | msgctxt "#30255" | |
| 259 | msgid "Do you want to install Jacktorr?" | |
| 260 | msgstr "Deseja instalar Jacktorr?" | |
| 261 | ||
| 262 | msgctxt "#30256" | |
| 263 | msgid "Do you want to install Torrest?" | |
| 264 | msgstr "Deseja instalar Torrest?" | |
| 265 | ||
| 266 | msgctxt "#30257" | |
| 267 | msgid "Do you want to install Jacktook Burst?" | |
| 268 | msgstr "Deseja instalar Jacktook Burst?" | |
| 269 | ||
| 270 | msgctxt "#30650" | |
| 271 | msgid "Queued" | |
| 272 | msgstr "Adicionado à fila" | |
| 273 | ||
| 274 | msgctxt "#30651" | |
| 275 | msgid "Checking" | |
| 276 | msgstr "Verificando" | |
| 277 | ||
| 278 | msgctxt "#30652" | |
| 279 | msgid "Finding" | |
| 280 | msgstr "Encontrando" | |
| 281 | ||
| 282 | msgctxt "#30653" | |
| 283 | msgid "Downloading" | |
| 284 | msgstr "Baixando" | |
| 285 | ||
| 286 | msgctxt "#30654" | |
| 287 | msgid "Finished" | |
| 288 | msgstr "Finalizado" | |
| 289 | ||
| 290 | msgctxt "#30655" | |
| 291 | msgid "Seeding" | |
| 292 | msgstr "Semeando" | |
| 293 | ||
| 294 | msgctxt "#30656" | |
| 295 | msgid "Allocating" | |
| 296 | msgstr "Alocando" | |
| 297 | ||
| 298 | msgctxt "#30657" | |
| 299 | msgid "Checking resume data" | |
| 300 | msgstr "Verificando dados para reproduzir" | |
| 301 | ||
| 302 | msgctxt "#30658" | |
| 303 | msgid "Paused" | |
| 304 | msgstr "Pausado" | |
| 305 | ||
| 306 | msgctxt "#30659" | |
| 307 | msgid "Buffering" | |
| 308 | msgstr "Bufferizando" | |
| 309 | ||
| 310 | msgctxt "#30660" | |
| 311 | msgid "Unknown" | |
| 312 | msgstr "Desconhecido" | |
| 313 | ||
| 314 | msgctxt "#30700" | |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "Bufferizar e reproduzir" | |
| 317 | ||
| 318 | msgctxt "#30701" | |
| 319 | msgid "Stop downloading" | |
| 320 | msgstr "Parar o download" | |
| 321 | ||
| 322 | msgctxt "#30702" | |
| 323 | msgid "Downloads" | |
| 324 | msgstr "Descarregamentos" | |
| 325 | ||
| 326 | msgctxt "#30703" | |
| 327 | msgid "Resume" | |
| 328 | msgstr "Retomar" | |
| 329 | ||
| 330 | msgctxt "#30704" | |
| 331 | msgid "Pause" | |
| 332 | msgstr "Pausar" | |
| 333 | ||
| 334 | msgctxt "#30705" | |
| 335 | msgid "Remove torrent" | |
| 336 | msgstr "Remover torrent" | |
| 337 | ||
| 338 | msgctxt "#30706" | |
| 339 | msgid "Remove torrent and files" | |
| 340 | msgstr "Remover torrent e arquivos" | |
| 341 | ||
| 342 | msgctxt "#30707" | |
| 343 | msgid "Torrent status" | |
| 344 | msgstr "Status do Torrent" | |
| 345 | ||
| 346 | msgctxt "#30708" | |
| 347 | msgid "Stop" | |
| 348 | msgstr "Parar" | |
| 349 | ||
| 350 | msgctxt "#30709" | |
| 351 | msgid "Drop torrent" | |
| 352 | msgstr "Drop torrent" | |
| 353 | ||
| 354 | msgctxt "#30710" | |
| 355 | msgid "Include Season Packs" | |
| 356 | msgstr "Incluir pacotes de temporadas" | |
| 357 | ||
| 358 | msgctxt "#30711" | |
| 359 | msgid "Torrent Settings" | |
| 360 | msgstr "Definições de Torrent" | |
| 361 | ||
| 362 | msgctxt "#30712" | |
| 363 | msgid "General" | |
| 364 | msgstr "Geral" | |
| 365 | ||
| 366 | msgctxt "#30713" | |
| 367 | msgid "Enable" | |
| 368 | msgstr "Ativar" | |
| 369 | ||
| 370 | msgctxt "#30714" | |
| 371 | msgid "Jacktorr" | |
| 372 | msgstr "Jacktorr" | |
| 373 | ||
| 374 | msgctxt "#30715" | |
| 375 | msgid "Torrest" | |
| 376 | msgstr "Torrest" | |
| 377 | ||
| 378 | msgctxt "#30716" | |
| 379 | msgid "Elementum" | |
| 380 | msgstr "Elementum" | |
| 381 | ||
| 382 | msgctxt "#30717" | |
| 383 | msgid "General" | |
| 384 | msgstr "Geral" | |
| 385 | ||
| 386 | msgctxt "#30718" | |
| 387 | msgid "Auto-Play" | |
| 388 | msgstr "Reprodução automática" | |
| 389 | ||
| 390 | msgctxt "#30719" | |
| 391 | msgid "Enable Next Episode Dialog" | |
| 392 | msgstr "Ativar diálogo de próximo episódio" | |
| 393 | ||
| 394 | msgctxt "#30720" | |
| 395 | msgid "Seconds left before show Next Episode Dialog" | |
| 396 | msgstr "Segundos restantes antes de mostrar diálogo de próximo episódio" | |
| 397 | ||
| 398 | msgctxt "#30721" | |
| 399 | msgid "Cache Enabled" | |
| 400 | msgstr "Cache ativado" | |
| 401 | ||
| 402 | msgctxt "#30722" | |
| 403 | msgid "Cache Expiration (hours)" | |
| 404 | msgstr "Expiração do cache (horas)" | |
| 405 | ||
| 406 | msgctxt "#30723" | |
| 407 | msgid "Cache" | |
| 408 | msgstr "Cache" | |
| 409 | ||
| 410 | msgctxt "#30724" | |
| 411 | msgid "Update" | |
| 412 | msgstr "Atualizar" | |
| 413 | ||
| 414 | msgctxt "#30725" | |
| 415 | msgid "Indexers Configuration" | |
| 416 | msgstr "Indexers Configuração" | |
| 417 | ||
| 418 | msgctxt "#30726" | |
| 419 | msgid "Sort" | |
| 420 | msgstr "Ordenar" | |
| 421 | ||
| 422 | msgctxt "#30727" | |
| 423 | msgid "Quality" | |
| 424 | msgstr "Qualidade" | |
| 425 | ||
| 426 | msgctxt "#30938" | |
| 427 | msgid "Silent Resume" | |
| 428 | msgstr "Retomar Silenciosamente" | |
| 429 | ||
| 430 | msgctxt "#30939" | |
| 431 | msgid "Auto-resume from last position without asking" | |
| 432 | msgstr "Retomar automaticamente da última posição sem perguntar" | |
| 433 | ||
| 434 | msgctxt "#30728" | |
| 435 | msgid "Seeds" | |
| 436 | msgstr "Sementes" | |
| 437 | ||
| 438 | msgctxt "#30729" | |
| 439 | msgid "Size" | |
| 440 | msgstr "Tamanho" | |
| 441 | ||
| 442 | msgctxt "#30730" | |
| 443 | msgid "Cached" | |
| 444 | msgstr "Em cache" | |
| 445 | ||
| 446 | msgctxt "#30731" | |
| 447 | msgid "Date" | |
| 448 | msgstr "Data" | |
| 449 | ||
| 450 | msgctxt "#30732" | |
| 451 | msgid "None" | |
| 452 | msgstr "Nenhum" | |
| 453 | ||
| 454 | msgctxt "#30733" | |
| 455 | msgid "Torrentio Configuration" | |
| 456 | msgstr "Torrentio Configuração" | |
| 457 | ||
| 458 | msgctxt "#30734" | |
| 459 | msgid "Enable" | |
| 460 | msgstr "Ativar" | |
| 461 | ||
| 462 | msgctxt "#30736" | |
| 463 | msgid "Subtitle Language" | |
| 464 | msgstr "Idioma das legendas" | |
| 465 | ||
| 466 | msgctxt "#30737" | |
| 467 | msgid "GB" | |
| 468 | msgstr "GB" | |
| 469 | ||
| 470 | msgctxt "#30738" | |
| 471 | msgid "JP" | |
| 472 | msgstr "JP" | |
| 473 | ||
| 474 | msgctxt "#30739" | |
| 475 | msgid "RU" | |
| 476 | msgstr "RU" | |
| 477 | ||
| 478 | msgctxt "#30740" | |
| 479 | msgid "IT" | |
| 480 | msgstr "IT" | |
| 481 | ||
| 482 | msgctxt "#30741" | |
| 483 | msgid "PT" | |
| 484 | msgstr "PT" | |
| 485 | ||
| 486 | msgctxt "#30742" | |
| 487 | msgid "ES" | |
| 488 | msgstr "ES" | |
| 489 | ||
| 490 | msgctxt "#30743" | |
| 491 | msgid "MX" | |
| 492 | msgstr "MX" | |
| 493 | ||
| 494 | msgctxt "#30744" | |
| 495 | msgid "KR" | |
| 496 | msgstr "KR" | |
| 497 | ||
| 498 | msgctxt "#30745" | |
| 499 | msgid "CN" | |
| 500 | msgstr "CN" | |
| 501 | ||
| 502 | msgctxt "#30746" | |
| 503 | msgid "TW" | |
| 504 | msgstr "TW" | |
| 505 | ||
| 506 | msgctxt "#30747" | |
| 507 | msgid "FR" | |
| 508 | msgstr "FR" | |
| 509 | ||
| 510 | msgctxt "#30748" | |
| 511 | msgid "DE" | |
| 512 | msgstr "DE" | |
| 513 | ||
| 514 | msgctxt "#30749" | |
| 515 | msgid "NL" | |
| 516 | msgstr "NL" | |
| 517 | ||
| 518 | msgctxt "#30750" | |
| 519 | msgid "IN" | |
| 520 | msgstr "IN" | |
| 521 | ||
| 522 | msgctxt "#30751" | |
| 523 | msgid "PL" | |
| 524 | msgstr "PL" | |
| 525 | ||
| 526 | msgctxt "#30752" | |
| 527 | msgid "LT" | |
| 528 | msgstr "LT" | |
| 529 | ||
| 530 | msgctxt "#30753" | |
| 531 | msgid "CZ" | |
| 532 | msgstr "CZ" | |
| 533 | ||
| 534 | msgctxt "#30754" | |
| 535 | msgid "SK" | |
| 536 | msgstr "SK" | |
| 537 | ||
| 538 | msgctxt "#30755" | |
| 539 | msgid "SI" | |
| 540 | msgstr "SI" | |
| 541 | ||
| 542 | msgctxt "#30756" | |
| 543 | msgid "HU" | |
| 544 | msgstr "HU" | |
| 545 | ||
| 546 | msgctxt "#30757" | |
| 547 | msgid "RO" | |
| 548 | msgstr "RO" | |
| 549 | ||
| 550 | msgctxt "#30758" | |
| 551 | msgid "HR" | |
| 552 | msgstr "HR" | |
| 553 | ||
| 554 | msgctxt "#30759" | |
| 555 | msgid "UA" | |
| 556 | msgstr "UA" | |
| 557 | ||
| 558 | msgctxt "#30760" | |
| 559 | msgid "GR" | |
| 560 | msgstr "GR" | |
| 561 | ||
| 562 | msgctxt "#30761" | |
| 563 | msgid "DK" | |
| 564 | msgstr "DK" | |
| 565 | ||
| 566 | msgctxt "#30762" | |
| 567 | msgid "FI" | |
| 568 | msgstr "FI" | |
| 569 | ||
| 570 | msgctxt "#30763" | |
| 571 | msgid "SE" | |
| 572 | msgstr "SE" | |
| 573 | ||
| 574 | msgctxt "#30764" | |
| 575 | msgid "NO" | |
| 576 | msgstr "NO" | |
| 577 | ||
| 578 | msgctxt "#30765" | |
| 579 | msgid "TR" | |
| 580 | msgstr "TR" | |
| 581 | ||
| 582 | msgctxt "#30766" | |
| 583 | msgid "Torrentio Providers" | |
| 584 | msgstr "Provedores Torrentio" | |
| 585 | ||
| 586 | msgctxt "#30767" | |
| 587 | msgid "Jackgram Configuration" | |
| 588 | msgstr "Jackgram Configuração" | |
| 589 | ||
| 590 | msgctxt "#30768" | |
| 591 | msgid "Host" | |
| 592 | msgstr "Host" | |
| 593 | ||
| 594 | msgctxt "#30769" | |
| 595 | msgid "Elfhosted/KnightCrawler Configuration" | |
| 596 | msgstr "Elfhosted/KnightCrawler Configuração" | |
| 597 | ||
| 598 | msgctxt "#30770" | |
| 599 | msgid "Language" | |
| 600 | msgstr "Idioma" | |
| 601 | ||
| 602 | msgctxt "#30771" | |
| 603 | msgid "Zilean Configuration" | |
| 604 | msgstr "Zilean Configuração" | |
| 605 | ||
| 606 | msgctxt "#30772" | |
| 607 | msgid "Zilean Host" | |
| 608 | msgstr "Host Zilean" | |
| 609 | ||
| 610 | msgctxt "#30774" | |
| 611 | msgid "Zilean Timeout" | |
| 612 | msgstr "Zilean Tempo limite" | |
| 613 | ||
| 614 | msgctxt "#30775" | |
| 615 | msgid "Jackett Configuration" | |
| 616 | msgstr "Jackett Configuração" | |
| 617 | ||
| 618 | msgctxt "#30776" | |
| 619 | msgid "Prowlarr Configuration" | |
| 620 | msgstr "Prowlarr Configuração" | |
| 621 | ||
| 622 | msgctxt "#30777" | |
| 623 | msgid "MediaFusion configuration" | |
| 624 | msgstr "Configuração MediaFusion" | |
| 625 | ||
| 626 | msgctxt "#30779" | |
| 627 | msgid "Manifest Url" | |
| 628 | msgstr "URL do Manifesto" | |
| 629 | ||
| 630 | msgctxt "#30780" | |
| 631 | msgid "Jacktook Burst Configuration" | |
| 632 | msgstr "Jacktook Burst Configuração" | |
| 633 | ||
| 634 | msgctxt "#30781" | |
| 635 | msgid "Burst Configuration" | |
| 636 | msgstr "Burst Configuração" | |
| 637 | ||
| 638 | msgctxt "#30782" | |
| 639 | msgid "User" | |
| 640 | msgstr "Utilizador" | |
| 641 | ||
| 642 | msgctxt "#30783" | |
| 643 | msgid "Authorize" | |
| 644 | msgstr "Autorizar" | |
| 645 | ||
| 646 | msgctxt "#30784" | |
| 647 | msgid "Remove Authorization" | |
| 648 | msgstr "Remover Autorização" | |
| 649 | ||
| 650 | msgctxt "#30785" | |
| 651 | msgid "Premiumize Configuration" | |
| 652 | msgstr "Premiumize Configuração" | |
| 653 | ||
| 654 | msgctxt "#30786" | |
| 655 | msgid "Enabled" | |
| 656 | msgstr "Ativado" | |
| 657 | ||
| 658 | msgctxt "#30787" | |
| 659 | msgid "Token" | |
| 660 | msgstr "Token" | |
| 661 | ||
| 662 | msgctxt "#30788" | |
| 663 | msgid "TorBox Configuration" | |
| 664 | msgstr "TorBox Configuração" | |
| 665 | ||
| 666 | msgctxt "#30789" | |
| 667 | msgid "Debrider Configuration" | |
| 668 | msgstr "Debrider Configuração" | |
| 669 | ||
| 670 | msgctxt "#30790" | |
| 671 | msgid "Uncached" | |
| 672 | msgstr "Não em cache" | |
| 673 | ||
| 674 | msgctxt "#30791" | |
| 675 | msgid "Show uncached torrents" | |
| 676 | msgstr "Mostrar torrents não em cache" | |
| 677 | ||
| 678 | msgctxt "#30792" | |
| 679 | msgid "Use Kodi Language" | |
| 680 | msgstr "Usar idioma do Kodi" | |
| 681 | ||
| 682 | msgctxt "#30793" | |
| 683 | msgid "Trakt" | |
| 684 | msgstr "Trakt" | |
| 685 | ||
| 686 | msgctxt "#30794" | |
| 687 | msgid "Trakt Configuration" | |
| 688 | msgstr "Trakt Configuração" | |
| 689 | ||
| 690 | msgctxt "#30795" | |
| 691 | msgid "Expires" | |
| 692 | msgstr "Expira" | |
| 693 | ||
| 694 | msgctxt "#30796" | |
| 695 | msgid "Refresh" | |
| 696 | msgstr "Atualizar" | |
| 697 | ||
| 698 | msgctxt "#30797" | |
| 699 | msgid "Token" | |
| 700 | msgstr "Token" | |
| 701 | ||
| 702 | msgctxt "#30798" | |
| 703 | msgid "Trakt Client" | |
| 704 | msgstr "Cliente Trakt" | |
| 705 | ||
| 706 | msgctxt "#30800" | |
| 707 | msgid "Trakt Secret" | |
| 708 | msgstr "Segredo Trakt" | |
| 709 | ||
| 710 | msgctxt "#30802" | |
| 711 | msgid "Log out" | |
| 712 | msgstr "Sair" | |
| 713 | ||
| 714 | msgctxt "#30804" | |
| 715 | msgid "Menus" | |
| 716 | msgstr "Menus" | |
| 717 | ||
| 718 | msgctxt "#30805" | |
| 719 | msgid "Show Telegram Menu" | |
| 720 | msgstr "Mostrar menu Telegram" | |
| 721 | ||
| 722 | msgctxt "#30806" | |
| 723 | msgid "Clear cache when update" | |
| 724 | msgstr "Limpar cache na atualização" | |
| 725 | ||
| 726 | msgctxt "#30807" | |
| 727 | msgid "Peerfix Configuration" | |
| 728 | msgstr "Peerfix Configuração" | |
| 729 | ||
| 730 | msgctxt "#30894" | |
| 731 | msgid "Show Changelog" | |
| 732 | msgstr "Mostrar registo de alterações" | |
| 733 | ||
| 734 | msgctxt "#30895" | |
| 735 | msgid "View the latest changes and updates to the addon." | |
| 736 | msgstr "Ver as últimas alterações e atualizações do addon." | |
| 737 | ||
| 738 | msgctxt "#30896" | |
| 739 | msgid "Calendar" | |
| 740 | msgstr "Calendário" | |
| 741 | ||
| 742 | msgctxt "#30897" | |
| 743 | msgid "Use broadcast day adjustment" | |
| 744 | msgstr "Usar ajuste de dia de transmissão" | |
| 745 | ||
| 746 | msgctxt "#30898" | |
| 747 | msgid "Adjust broadcast day based on manual offset" | |
| 748 | msgstr "Ajustar o dia da transmissão com base no desvio manual" | |
| 749 | ||
| 750 | msgctxt "#30899" | |
| 751 | msgid "Broadcast Offset (Hours)" | |
| 752 | msgstr "Broadcast Offset (Hours)" | |
| 753 | ||
| 754 | msgctxt "#30900" | |
| 755 | msgid "Manual hour adjustment for broadcast day simulation" | |
| 756 | msgstr "Ajuste manual da hora para simulação do dia de transmissão" | |
| 757 | ||
| 758 | msgctxt "#30905" | |
| 759 | msgid "Trakt Calendar Previous Days" | |
| 760 | msgstr "Dias passados do calendário Trakt" | |
| 761 | ||
| 762 | msgctxt "#30906" | |
| 763 | msgid "Number of days in the past to show in the Trakt calendar." | |
| 764 | msgstr "Número de dias passados a mostrar no calendário Trakt." | |
| 765 | ||
| 766 | msgctxt "#30907" | |
| 767 | msgid "Trakt Calendar Future Days" | |
| 768 | msgstr "Dias futuros do calendario Trakt" | |
| 769 | ||
| 770 | msgctxt "#30908" | |
| 771 | msgid "Number of days in the future to show in the Trakt calendar." | |
| 772 | msgstr "Número de dias futuros a mostrar no calendário Trakt." | |
| 773 | ||
| 774 | msgctxt "#30909" | |
| 775 | msgid "Show All Shows in Calendar" | |
| 776 | msgstr "Mostrar todas as séries no calendário" | |
| 777 | ||
| 778 | msgctxt "#30918" | |
| 779 | msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead." | |
| 780 | msgstr "{provider} não pode reproduzir diretamente releases compactados (.rar/.zip). Escolha uma fonte com arquivos de vídeo." | |
| 781 | ||
| 782 | msgctxt "#30910" | |
| 783 | msgid "Show all airing shows instead of just your watchlist/collection." | |
| 784 | msgstr "Mostra todas as séries em exibição em vez de apenas as da sua lista/coleção." | |
| 785 | ||
| 786 | msgctxt "#30911" | |
| 787 | msgid "Your Torbox account username/email." | |
| 788 | msgstr "Nome de utilizador/e-mail da sua conta Torbox." | |
| 789 | ||
| 790 | msgctxt "#30912" | |
| 791 | msgid "Authorize add-on to access Torbox account." | |
| 792 | msgstr "Autorizar o add-on a aceder à conta Torbox." | |
| 793 | ||
| 794 | msgctxt "#30913" | |
| 795 | msgid "Revoke Torbox account access." | |
| 796 | msgstr "Revogar o acesso à conta Torbox." | |
| 797 | ||
| 798 | msgctxt "#30914" | |
| 799 | msgid "Trakt Sync Interval (minutes)" | |
| 800 | msgstr "Intervalo de sincronizacao do Trakt (minutos)" | |
| 801 | ||
| 802 | msgctxt "#30915" | |
| 803 | msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running." | |
| 804 | msgstr "Com que frequencia o Jacktook verifica alteracoes remotas de atividade no Trakt enquanto o Kodi estiver em execucao." | |
| 805 | ||
| 806 | msgctxt "#30916" | |
| 807 | msgid "Account Info" | |
| 808 | msgstr "Informacoes da conta Trakt" | |
| 809 | ||
| 810 | msgctxt "#30917" | |
| 811 | msgid "View your Trakt account profile and library stats." | |
| 812 | msgstr "Veja o perfil da sua conta Trakt e as estatisticas da biblioteca." | |
| 813 | ||
| 814 | msgctxt "#30924" | |
| 815 | msgid "Trakt List Name" | |
| 816 | msgstr "Nome da lista Trakt" | |
| 817 | ||
| 818 | msgctxt "#30925" | |
| 819 | msgid "Trakt List Description" | |
| 820 | msgstr "Descricao da lista Trakt" | |
| 821 | ||
| 822 | msgctxt "#30926" | |
| 823 | msgid "Create Trakt List" | |
| 824 | msgstr "Criar lista Trakt" | |
| 825 | ||
| 826 | msgctxt "#30927" | |
| 827 | msgid "Choose Trakt List" | |
| 828 | msgstr "Escolher lista Trakt" | |
| 829 | ||
| 830 | msgctxt "#30936" | |
| 831 | msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position." | |
| 832 | msgstr "Ativa ou desativa o scrobbling do Trakt e retoma a reproducao a partir da ultima posicao registada." | |
| 833 | ||
| 834 | msgctxt "#30937" | |
| 835 | msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view." | |
| 836 | msgstr "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 | ||
| 838 | msgctxt "#40000" | |
| 839 | msgid "Arabic (United Arab Emirates)" | |
| 840 | msgstr "Árabe (Emirados Árabes Unidos)" | |
| 841 | ||
| 842 | msgctxt "#40001" | |
| 843 | msgid "Arabic (Saudi Arabia)" | |
| 844 | msgstr "Árabe (Arábia Saudita)" | |
| 845 | ||
| 846 | msgctxt "#40002" | |
| 847 | msgid "Belarusian (Belarus)" | |
| 848 | msgstr "Belarusian (Belarus)" | |
| 849 | ||
| 850 | msgctxt "#40003" | |
| 851 | msgid "Bulgarian (Bulgaria)" | |
| 852 | msgstr "Búlgaro (Bulgaria)" | |
| 853 | ||
| 854 | msgctxt "#40004" | |
| 855 | msgid "Bengali (Bangladesh)" | |
| 856 | msgstr "Bengali (Bangladesh)" | |
| 857 | ||
| 858 | msgctxt "#40005" | |
| 859 | msgid "Catalan (Spain)" | |
| 860 | msgstr "Catalão (Espanha)" | |
| 861 | ||
| 862 | msgctxt "#40006" | |
| 863 | msgid "Chamorro (Guam)" | |
| 864 | msgstr "Chamorro (Guam)" | |
| 865 | ||
| 866 | msgctxt "#40007" | |
| 867 | msgid "Czech (Czech Republic)" | |
| 868 | msgstr "Checo (República Checa)" | |
| 869 | ||
| 870 | msgctxt "#40008" | |
| 871 | msgid "Danish (Denmark)" | |
| 872 | msgstr "Dinamarquês (Dinamarca)" | |
| 873 | ||
| 874 | msgctxt "#40009" | |
| 875 | msgid "German (Austria)" | |
| 876 | msgstr "Alemão (Austria)" | |
| 877 | ||
| 878 | msgctxt "#40010" | |
| 879 | msgid "German (Switzerland)" | |
| 880 | msgstr "Alemão (Suíça)" | |
| 881 | ||
| 882 | msgctxt "#40011" | |
| 883 | msgid "German (Germany)" | |
| 884 | msgstr "Alemão (Alemanha)" | |
| 885 | ||
| 886 | msgctxt "#40012" | |
| 887 | msgid "Greek (Greece)" | |
| 888 | msgstr "Grego (Greece)" | |
| 889 | ||
| 890 | msgctxt "#40013" | |
| 891 | msgid "English (Australia)" | |
| 892 | msgstr "Inglês (Austrália)" | |
| 893 | ||
| 894 | msgctxt "#40014" | |
| 895 | msgid "English (Canada)" | |
| 896 | msgstr "Inglês (Canadá)" | |
| 897 | ||
| 898 | msgctxt "#40015" | |
| 899 | msgid "English (United Kingdom)" | |
| 900 | msgstr "Inglês (Reino Unido)" | |
| 901 | ||
| 902 | msgctxt "#40016" | |
| 903 | msgid "English (Ireland)" | |
| 904 | msgstr "Inglês (Ireland)" | |
| 905 | ||
| 906 | msgctxt "#40017" | |
| 907 | msgid "English (New Zealand)" | |
| 908 | msgstr "Inglês (New Zealand)" | |
| 909 | ||
| 910 | msgctxt "#40018" | |
| 911 | msgid "English (United States of America)" | |
| 912 | msgstr "Inglês (United States of America)" | |
| 913 | ||
| 914 | msgctxt "#40019" | |
| 915 | msgid "Esperanto (None)" | |
| 916 | msgstr "Esperanto (None)" | |
| 917 | ||
| 918 | msgctxt "#40020" | |
| 919 | msgid "Spanish (Spain)" | |
| 920 | msgstr "Espanhol (Espanha)" | |
| 921 | ||
| 922 | msgctxt "#40021" | |
| 923 | msgid "Spanish (Mexico)" | |
| 924 | msgstr "Espanhol (México)" | |
| 925 | ||
| 926 | msgctxt "#40022" | |
| 927 | msgid "Estonian (Estonia)" | |
| 928 | msgstr "Estonian (Estonia)" | |
| 929 | ||
| 930 | msgctxt "#40023" | |
| 931 | msgid "Basque (Spain)" | |
| 932 | msgstr "Basque (Espanha)" | |
| 933 | ||
| 934 | msgctxt "#40024" | |
| 935 | msgid "Persian (Iran)" | |
| 936 | msgstr "Persian (Iran)" | |
| 937 | ||
| 938 | msgctxt "#40025" | |
| 939 | msgid "Finnish (Finland)" | |
| 940 | msgstr "Finlandês (Finland)" | |
| 941 | ||
| 942 | msgctxt "#40026" | |
| 943 | msgid "French (Canada)" | |
| 944 | msgstr "Francês (Canadá)" | |
| 945 | ||
| 946 | msgctxt "#40027" | |
| 947 | msgid "French (France)" | |
| 948 | msgstr "Francês (França)" | |
| 949 | ||
| 950 | msgctxt "#40028" | |
| 951 | msgid "Galician (Spain)" | |
| 952 | msgstr "Galician (Espanha)" | |
| 953 | ||
| 954 | msgctxt "#40029" | |
| 955 | msgid "Hebrew (Israel)" | |
| 956 | msgstr "Hebrew (Israel)" | |
| 957 | ||
| 958 | msgctxt "#40030" | |
| 959 | msgid "Hindi (India)" | |
| 960 | msgstr "Hindi (Índia)" | |
| 961 | ||
| 962 | msgctxt "#40031" | |
| 963 | msgid "Hungarian (Hungary)" | |
| 964 | msgstr "Húngaro (Hungary)" | |
| 965 | ||
| 966 | msgctxt "#40032" | |
| 967 | msgid "Indonesian (Indonesia)" | |
| 968 | msgstr "Indonésio (Indonesia)" | |
| 969 | ||
| 970 | msgctxt "#40033" | |
| 971 | msgid "Italian (Italy)" | |
| 972 | msgstr "Italiano (Itália)" | |
| 973 | ||
| 974 | msgctxt "#40034" | |
| 975 | msgid "Japanese (Japan)" | |
| 976 | msgstr "Japonês (Japão)" | |
| 977 | ||
| 978 | msgctxt "#40035" | |
| 979 | msgid "Georgian (Georgia)" | |
| 980 | msgstr "Georgian (Georgia)" | |
| 981 | ||
| 982 | msgctxt "#40036" | |
| 983 | msgid "Kazakh (Kazakhstan)" | |
| 984 | msgstr "Kazakh (Kazakhstan)" | |
| 985 | ||
| 986 | msgctxt "#40037" | |
| 987 | msgid "Kannada (India)" | |
| 988 | msgstr "Kannada (Índia)" | |
| 989 | ||
| 990 | msgctxt "#40038" | |
| 991 | msgid "Korean (South Korea)" | |
| 992 | msgstr "Coreano (Coreia do Sul)" | |
| 993 | ||
| 994 | msgctxt "#40039" | |
| 995 | msgid "Lithuanian (Lithuania)" | |
| 996 | msgstr "Lithuanian (Lithuania)" | |
| 997 | ||
| 998 | msgctxt "#40040" | |
| 999 | msgid "Latvian (Latvia)" | |
| 1000 | msgstr "Latvian (Latvia)" | |
| 1001 | ||
| 1002 | msgctxt "#40041" | |
| 1003 | msgid "Malayalam (India)" | |
| 1004 | msgstr "Malayalam (Índia)" | |
| 1005 | ||
| 1006 | msgctxt "#40042" | |
| 1007 | msgid "Malay (Malaysia)" | |
| 1008 | msgstr "Malay (Malaysia)" | |
| 1009 | ||
| 1010 | msgctxt "#40043" | |
| 1011 | msgid "Malay (Singapore)" | |
| 1012 | msgstr "Malay (Singapore)" | |
| 1013 | ||
| 1014 | msgctxt "#40044" | |
| 1015 | msgid "Norwegian Bokmål (Norway)" | |
| 1016 | msgstr "Norwegian Bokmål (Noruega)" | |
| 1017 | ||
| 1018 | msgctxt "#40045" | |
| 1019 | msgid "Dutch (Netherlands)" | |
| 1020 | msgstr "Holandês (Países Baixos)" | |
| 1021 | ||
| 1022 | msgctxt "#40046" | |
| 1023 | msgid "Norwegian (Norway)" | |
| 1024 | msgstr "Norueguês (Noruega)" | |
| 1025 | ||
| 1026 | msgctxt "#40047" | |
| 1027 | msgid "Polish (Poland)" | |
| 1028 | msgstr "Polaco (Polónia)" | |
| 1029 | ||
| 1030 | msgctxt "#40048" | |
| 1031 | msgid "Portuguese (Brazil)" | |
| 1032 | msgstr "Português (Brasil)" | |
| 1033 | ||
| 1034 | msgctxt "#40049" | |
| 1035 | msgid "Portuguese (Portugal)" | |
| 1036 | msgstr "Português (Portugal)" | |
| 1037 | ||
| 1038 | msgctxt "#40050" | |
| 1039 | msgid "Romanian (Romania)" | |
| 1040 | msgstr "Romeno (Romania)" | |
| 1041 | ||
| 1042 | msgctxt "#40051" | |
| 1043 | msgid "Russian (Russia)" | |
| 1044 | msgstr "Russo (Rússia)" | |
| 1045 | ||
| 1046 | msgctxt "#40052" | |
| 1047 | msgid "Sinhalese (Sri Lanka)" | |
| 1048 | msgstr "Sinhalese (Sri Lanka)" | |
| 1049 | ||
| 1050 | msgctxt "#40053" | |
| 1051 | msgid "Slovak (Slovakia)" | |
| 1052 | msgstr "Slovak (Slovakia)" | |
| 1053 | ||
| 1054 | msgctxt "#40054" | |
| 1055 | msgid "Slovenian (Slovenia)" | |
| 1056 | msgstr "Slovenian (Slovenia)" | |
| 1057 | ||
| 1058 | msgctxt "#40055" | |
| 1059 | msgid "Serbian (Serbia)" | |
| 1060 | msgstr "Serbian (Serbia)" | |
| 1061 | ||
| 1062 | msgctxt "#40056" | |
| 1063 | msgid "Swedish (Sweden)" | |
| 1064 | msgstr "Sueco (Suécia)" | |
| 1065 | ||
| 1066 | msgctxt "#40057" | |
| 1067 | msgid "Tamil (India)" | |
| 1068 | msgstr "Tamil (Índia)" | |
| 1069 | ||
| 1070 | msgctxt "#40058" | |
| 1071 | msgid "Telugu (India)" | |
| 1072 | msgstr "Telugu (Índia)" | |
| 1073 | ||
| 1074 | msgctxt "#40059" | |
| 1075 | msgid "Thai (Thailand)" | |
| 1076 | msgstr "Tailandês (Tailândia)" | |
| 1077 | ||
| 1078 | msgctxt "#40060" | |
| 1079 | msgid "Tagalog (Philippines)" | |
| 1080 | msgstr "Tagalog (Philippines)" | |
| 1081 | ||
| 1082 | msgctxt "#40061" | |
| 1083 | msgid "Turkish (Turkey)" | |
| 1084 | msgstr "Turco (Turkey)" | |
| 1085 | ||
| 1086 | msgctxt "#40062" | |
| 1087 | msgid "Ukrainian (Ukraine)" | |
| 1088 | msgstr "Ucraniano (Ucrânia)" | |
| 1089 | ||
| 1090 | msgctxt "#40063" | |
| 1091 | msgid "Vietnamese (Vietnam)" | |
| 1092 | msgstr "Vietnamita (Vietname)" | |
| 1093 | ||
| 1094 | msgctxt "#40064" | |
| 1095 | msgid "Mandarin (China)" | |
| 1096 | msgstr "Mandarin (China)" | |
| 1097 | ||
| 1098 | msgctxt "#40065" | |
| 1099 | msgid "Mandarin (Hong Kong)" | |
| 1100 | msgstr "Mandarin (Hong Kong)" | |
| 1101 | ||
| 1102 | msgctxt "#40066" | |
| 1103 | msgid "Mandarin (Taiwan)" | |
| 1104 | msgstr "Mandarin (Taiwan)" | |
| 1105 | ||
| 1106 | msgctxt "#40067" | |
| 1107 | msgid "Zulu (South Africa)" | |
| 1108 | msgstr "Zulu (South Africa)" | |
| 1109 | ||
| 1110 | msgctxt "#42418" | |
| 1111 | msgid "Language and country" | |
| 1112 | msgstr "Idioma e país" | |
| 1113 | ||
| 1114 | msgctxt "#42419" | |
| 1115 | msgid "The language and country settings used when looking up data from TMDb" | |
| 1116 | msgstr "As definições de idioma e país utilizadas ao procurar dados no TMDb" | |
| 1117 | ||
| 1118 | msgctxt "#42420" | |
| 1119 | msgid "EN" | |
| 1120 | msgstr "EN" | |
| 1121 | ||
| 1122 | msgctxt "#30801" | |
| 1123 | msgid "Zilean server address (include port if required)." | |
| 1124 | msgstr "Endereço do servidor Zilean (inclua a porta se necessário)." | |
| 1125 | ||
| 1126 | msgctxt "#30803" | |
| 1127 | msgid "Display torrents not yet cached on your debrid service." | |
| 1128 | msgstr "Exibir torrents ainda não armazenados em cache no seu serviço debrid." | |
| 1129 | ||
| 1130 | msgctxt "#30808" | |
| 1131 | msgid "Fanart.tv Client Key (for artwork)." | |
| 1132 | msgstr "ID do cliente da API Fanart.tv (para imagens)." | |
| 1133 | ||
| 1134 | msgctxt "#30809" | |
| 1135 | msgid "Improve performance by caching metadata." | |
| 1136 | msgstr "Melhore o desempenho armazenando metadados em cache." | |
| 1137 | ||
| 1138 | msgctxt "#30810" | |
| 1139 | msgid "Seconds before Jackett requests time out (e.g., 30)." | |
| 1140 | msgstr "Segundos antes do tempo limite das requisições do Jackett (ex.: 30)." | |
| 1141 | ||
| 1142 | msgctxt "#30811" | |
| 1143 | msgid "Trakt client secret from OAuth application settings." | |
| 1144 | msgstr "Segredo do cliente Trakt das configurações do aplicativo OAuth." | |
| 1145 | ||
| 1146 | msgctxt "#30812" | |
| 1147 | msgid "Limit TV show results to specific seasons/episodes." | |
| 1148 | msgstr "Limitar resultados de séries a temporadas/episódios específicos." | |
| 1149 | ||
| 1150 | msgctxt "#30813" | |
| 1151 | msgid "Hours before cached data is automatically refreshed (e.g., 24)." | |
| 1152 | msgstr "Horas antes dos dados em cache serem atualizados automaticamente (ex.: 24)." | |
| 1153 | ||
| 1154 | msgctxt "#30814" | |
| 1155 | msgid "Premiumize Client Token (get from account page)." | |
| 1156 | msgstr "Token da API Premiumize (obtenha na página da conta)." | |
| 1157 | ||
| 1158 | msgctxt "#30815" | |
| 1159 | msgid "Jackett server URL (e.g., http://localhost)." | |
| 1160 | msgstr "URL do servidor Jackett (ex.: http://localhost:9117)." | |
| 1161 | ||
| 1162 | msgctxt "#30816" | |
| 1163 | msgid "Display Telegram integration options in UI." | |
| 1164 | msgstr "Exibir opções de integração do Telegram na interface." | |
| 1165 | ||
| 1166 | msgctxt "#30817" | |
| 1167 | msgid "Your Real-Debrid account username/email." | |
| 1168 | msgstr "Nome de usuário/e-mail da sua conta Real-Debrid." | |
| 1169 | ||
| 1170 | msgctxt "#30818" | |
| 1171 | msgid "Trakt client ID from https://trakt.tv/oauth/apps." | |
| 1172 | msgstr "ID do cliente Trakt de https://trakt.tv/oauth/apps." | |
| 1173 | ||
| 1174 | msgctxt "#30819" | |
| 1175 | msgid "Connect Trakt.tv account to sync watch progress." | |
| 1176 | msgstr "Conecte sua conta Trakt.tv para sincronizar o progresso de visualização." | |
| 1177 | ||
| 1178 | msgctxt "#30820" | |
| 1179 | msgid "Seconds before Zilean requests time out (e.g., 30)." | |
| 1180 | msgstr "Segundos antes do tempo limite das requisições do Zilean (ex.: 30)." | |
| 1181 | ||
| 1182 | msgctxt "#30821" | |
| 1183 | msgid "Your Jackett Client Key (found in dashboard)." | |
| 1184 | msgstr "Sua chave da API Jackett (encontrada no painel)." | |
| 1185 | ||
| 1186 | msgctxt "#30822" | |
| 1187 | msgid "Sort results by criteria (e.g., 'date', 'title', 'size')." | |
| 1188 | msgstr "Classificar resultados por critérios (ex.: 'data', 'título', 'tamanho')." | |
| 1189 | ||
| 1190 | msgctxt "#30823" | |
| 1191 | msgid "Configure Burst mode settings and providers." | |
| 1192 | msgstr "Configurar ajustes e provedores do modo Burst." | |
| 1193 | ||
| 1194 | msgctxt "#30824" | |
| 1195 | msgid "Enable Premiumize.me cloud service." | |
| 1196 | msgstr "Habilitar o serviço de nuvem Premiumize.me." | |
| 1197 | ||
| 1198 | msgctxt "#30825" | |
| 1199 | msgid "Truncate descriptions after [X] characters (0 for full text)." | |
| 1200 | msgstr "Truncar descrições após [X] caracteres (0 para texto completo)." | |
| 1201 | ||
| 1202 | msgctxt "#30826" | |
| 1203 | msgid "Enable Prowlarr indexer management." | |
| 1204 | msgstr "Habilitar gerenciamento de indexadores do Prowlarr." | |
| 1205 | ||
| 1206 | msgctxt "#30827" | |
| 1207 | msgid "Jackgram server URL (e.g., http://localhost:9117)." | |
| 1208 | msgstr "URL do servidor Jackgram (ex.: http://localhost:9117)." | |
| 1209 | ||
| 1210 | msgctxt "#30828" | |
| 1211 | msgid "Debrider Client Key for authentication." | |
| 1212 | msgstr "Chave da API Debrider para autenticação." | |
| 1213 | ||
| 1214 | msgctxt "#30829" | |
| 1215 | msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)." | |
| 1216 | msgstr "Atraso (em segundos) antes do diálogo do próximo episódio aparecer (ex.: 10)." | |
| 1217 | ||
| 1218 | msgctxt "#30830" | |
| 1219 | msgid "Specify the torrent client." | |
| 1220 | msgstr "Especifique o executável do cliente de torrent." | |
| 1221 | ||
| 1222 | msgctxt "#30831" | |
| 1223 | msgid "Enable Debrider cached torrent support." | |
| 1224 | msgstr "Habilitar suporte a torrents em cache do EasyDebrid." | |
| 1225 | ||
| 1226 | msgctxt "#30832" | |
| 1227 | msgid "Torbox Client Token (found in account settings)." | |
| 1228 | msgstr "Token da API Torbox (encontrado nas configurações da conta)." | |
| 1229 | ||
| 1230 | msgctxt "#30833" | |
| 1231 | msgid "Trakt refresh token (auto-managed)." | |
| 1232 | msgstr "Token de atualização do Trakt (gerenciado automaticamente)." | |
| 1233 | ||
| 1234 | msgctxt "#30834" | |
| 1235 | msgid "Show confirmation dialog before playing next episode." | |
| 1236 | msgstr "Mostrar diálogo de confirmação antes de reproduzir o próximo episódio." | |
| 1237 | ||
| 1238 | msgctxt "#30835" | |
| 1239 | msgid "Authorize add-on to access Real-Debrid account." | |
| 1240 | msgstr "Autorizar a extensão a acessar a conta Real-Debrid." | |
| 1241 | ||
| 1242 | msgctxt "#30836" | |
| 1243 | msgid "Enable torrent-based media content" | |
| 1244 | msgstr "Habilitar conteúdo de mídia via torrent." | |
| 1245 | ||
| 1246 | msgctxt "#30837" | |
| 1247 | msgid "Indicates if Real-Debrid account is linked." | |
| 1248 | msgstr "Indica se a conta Real-Debrid está vinculada." | |
| 1249 | ||
| 1250 | msgctxt "#30838" | |
| 1251 | msgid "Automatically play next media item when current playback ends." | |
| 1252 | msgstr "Reproduzir automaticamente o próximo item ao terminar a reprodução atual." | |
| 1253 | ||
| 1254 | msgctxt "#30839" | |
| 1255 | msgid "Check for and install the latest add-on updates." | |
| 1256 | msgstr "Verificar e instalar as atualizações mais recentes da extensão." | |
| 1257 | ||
| 1258 | msgctxt "#30840" | |
| 1259 | msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)." | |
| 1260 | msgstr "IDs dos indexadores do Prowlarr a serem usados, separados por vírgula (ex.: 1,5,7)." | |
| 1261 | ||
| 1262 | msgctxt "#30841" | |
| 1263 | msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)." | |
| 1264 | msgstr "Tempo limite (segundos) para requisições do Prowlarr (ex.: 20)." | |
| 1265 | ||
| 1266 | msgctxt "#30842" | |
| 1267 | msgid "Trakt authorization expiration date (read-only)." | |
| 1268 | msgstr "Data de expiração da autorização do Trakt (somente leitura)." | |
| 1269 | ||
| 1270 | msgctxt "#30843" | |
| 1271 | msgid "Enable Torbox.de cloud torrent integration." | |
| 1272 | msgstr "Habilitar integração com torrents na nuvem do Torbox.de." | |
| 1273 | ||
| 1274 | msgctxt "#30844" | |
| 1275 | msgid "Trakt Access Token (auto-filled after auth)." | |
| 1276 | msgstr "Token de acesso da API Trakt (preenchido automaticamente após autenticação)." | |
| 1277 | ||
| 1278 | msgctxt "#30845" | |
| 1279 | msgid "Link Premiumize.me account to add-on." | |
| 1280 | msgstr "Vincular conta Premiumize.me à extensão." | |
| 1281 | ||
| 1282 | msgctxt "#30846" | |
| 1283 | msgid "Clear cached data automatically after updating the add-on." | |
| 1284 | msgstr "Limpar dados em cache automaticamente após atualizar a extensão." | |
| 1285 | ||
| 1286 | msgctxt "#30847" | |
| 1287 | msgid "Click to delete all cached data immediately." | |
| 1288 | msgstr "Clique para excluir todos os dados em cache imediatamente." | |
| 1289 | ||
| 1290 | msgctxt "#30848" | |
| 1291 | msgid "Real-Debrid Client Token (auto-filled after auth)." | |
| 1292 | msgstr "Token da API Real-Debrid (preenchido automaticamente após autenticação)." | |
| 1293 | ||
| 1294 | msgctxt "#30849" | |
| 1295 | msgid "Enable Jackett torrent proxy support." | |
| 1296 | msgstr "Habilitar suporte ao proxy de torrents do Jackett." | |
| 1297 | ||
| 1298 | msgctxt "#30850" | |
| 1299 | msgid "Enable Jackgram integration for enhanced search." | |
| 1300 | msgstr "Habilitar integração do Jackgram para buscas aprimoradas." | |
| 1301 | ||
| 1302 | msgctxt "#30851" | |
| 1303 | msgid "Prowlarr instance URL (e.g., http://localhost)." | |
| 1304 | msgstr "URL da instância Prowlarr (ex.: http://localhost:9696)." | |
| 1305 | ||
| 1306 | msgctxt "#30852" | |
| 1307 | msgid "Maximum number of search results to display (e.g., 50)." | |
| 1308 | msgstr "Número máximo de resultados de busca a exibir (ex.: 50)." | |
| 1309 | ||
| 1310 | msgctxt "#30853" | |
| 1311 | msgid "Revoke Real-Debrid account access." | |
| 1312 | msgstr "Revogar acesso à conta Real-Debrid." | |
| 1313 | ||
| 1314 | msgctxt "#30854" | |
| 1315 | msgid "Prowlarr Client Key (found in Settings > General)." | |
| 1316 | msgstr "Chave da API Prowlarr (encontrada em Configurações > Geral)." | |
| 1317 | ||
| 1318 | msgctxt "#30855" | |
| 1319 | msgid "Trakt.tv username (auto-populated after auth)." | |
| 1320 | msgstr "Nome de usuário Trakt.tv (preenchido automaticamente após autenticação)." | |
| 1321 | ||
| 1322 | msgctxt "#30856" | |
| 1323 | msgid "Disconnect Trakt.tv account from add-on." | |
| 1324 | msgstr "Desconectar conta Trakt.tv da extensão." | |
| 1325 | ||
| 1326 | msgctxt "#30857" | |
| 1327 | msgid "Enable Zilean integration for metadata fetching." | |
| 1328 | msgstr "Habilitar integração do Zilean para obtenção de metadados." | |
| 1329 | ||
| 1330 | msgctxt "#30858" | |
| 1331 | msgid "Your Client Key (required for metadata)" | |
| 1332 | msgstr "Sua chave da API TMDB (necessária para metadados)." | |
| 1333 | ||
| 1334 | msgctxt "#30859" | |
| 1335 | msgid "Enable Burst mode for accelerated searches." | |
| 1336 | msgstr "Habilitar modo Burst para buscas aceleradas." | |
| 1337 | ||
| 1338 | msgctxt "#30860" | |
| 1339 | msgid "Others" | |
| 1340 | msgstr "Outros" | |
| 1341 | ||
| 1342 | msgctxt "#30861" | |
| 1343 | msgid "Filters" | |
| 1344 | msgstr "Filters" | |
| 1345 | ||
| 1346 | msgctxt "#30862" | |
| 1347 | msgid "Torrent (P2P) Configuration" | |
| 1348 | msgstr "Torrent (P2P) Configuração" | |
| 1349 | ||
| 1350 | msgctxt "#30863" | |
| 1351 | msgid "Specify the torrent client" | |
| 1352 | msgstr "Especificar o cliente de torrent" | |
| 1353 | ||
| 1354 | msgctxt "#30864" | |
| 1355 | msgid "Sources" | |
| 1356 | msgstr "Pesquisar" | |
| 1357 | ||
| 1358 | msgctxt "#30865" | |
| 1359 | msgid "General Filters" | |
| 1360 | msgstr "Filtro e ordem dos resultados" | |
| 1361 | ||
| 1362 | msgctxt "#30866" | |
| 1363 | msgid "Sign in to your Stremio account to access add-ons and personalized features." | |
| 1364 | msgstr "Faça login na sua conta do Stremio para acessar complementos e recursos personalizados." | |
| 1365 | ||
| 1366 | msgctxt "#30867" | |
| 1367 | msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data." | |
| 1368 | msgstr "Faça logout da sua conta do Stremio. Isso desconectará sua conta, mas não excluirá seus dados." | |
| 1369 | ||
| 1370 | msgctxt "#30868" | |
| 1371 | msgid "Log Out of Stremio" | |
| 1372 | msgstr "Sair do Stremio" | |
| 1373 | ||
| 1374 | msgctxt "#30869" | |
| 1375 | msgid "Enable Stremio add-ons integration." | |
| 1376 | msgstr "Habilitar a integração de complementos do Stremio." | |
| 1377 | ||
| 1378 | msgctxt "#30870" | |
| 1379 | msgid "Select Stream Addons" | |
| 1380 | msgstr "Alternar complementos" | |
| 1381 | ||
| 1382 | msgctxt "#30871" | |
| 1383 | msgid "Log In to Stremio" | |
| 1384 | msgstr "Fazer login no Stremio" | |
| 1385 | ||
| 1386 | msgctxt "#30872" | |
| 1387 | msgid "Enable or disable Stremio add-ons." | |
| 1388 | msgstr "Habilitar ou desabilitar complementos do Stremio." | |
| 1389 | ||
| 1390 | msgctxt "#30873" | |
| 1391 | msgid "Select language for player subtitle selection" | |
| 1392 | msgstr "Priorizar resultados no idioma selecionado" | |
| 1393 | ||
| 1394 | msgctxt "#30874" | |
| 1395 | msgid "Update Addons" | |
| 1396 | msgstr "Atualizar Addons" | |
| 1397 | ||
| 1398 | msgctxt "#30875" | |
| 1399 | msgid "Select Catalogs Addons" | |
| 1400 | msgstr "Selecionar Catalógos e Addons" | |
| 1401 | ||
| 1402 | msgctxt "#30876" | |
| 1403 | msgid "Download directory" | |
| 1404 | msgstr "Download directory" | |
| 1405 | ||
| 1406 | msgctxt "#30877" | |
| 1407 | msgid "Enable Trakt.tv Scrobbling" | |
| 1408 | msgstr "Ativar Scrobbling do Trakt.tv" | |
| 1409 | ||
| 1410 | msgctxt "#30878" | |
| 1411 | msgid "Auto select subtitle when playing" | |
| 1412 | msgstr "Selecionar legenda automaticamente" | |
| 1413 | ||
| 1414 | msgctxt "#30879" | |
| 1415 | msgid "Auto-play quality" | |
| 1416 | msgstr "Qualidade de reprodução automática" | |
| 1417 | ||
| 1418 | msgctxt "#30880" | |
| 1419 | msgid "1080p" | |
| 1420 | msgstr "1080p" | |
| 1421 | ||
| 1422 | msgctxt "#30881" | |
| 1423 | msgid "720p" | |
| 1424 | msgstr "720p" | |
| 1425 | ||
| 1426 | msgctxt "#30882" | |
| 1427 | msgid "4k" | |
| 1428 | msgstr "4k" | |
| 1429 | ||
| 1430 | msgctxt "#30883" | |
| 1431 | msgid "480p" | |
| 1432 | msgstr "480p" | |
| 1433 | ||
| 1434 | msgctxt "#30884" | |
| 1435 | msgid "Subtitles Configuration" | |
| 1436 | msgstr "Configuração de legendas" | |
| 1437 | ||
| 1438 | msgctxt "#30885" | |
| 1439 | msgid "Addon Host" | |
| 1440 | msgstr "Host do addon" | |
| 1441 | ||
| 1442 | msgctxt "#30886" | |
| 1443 | msgid "Subtitles" | |
| 1444 | msgstr "Legendas" | |
| 1445 | ||
| 1446 | msgctxt "#30887" | |
| 1447 | msgid "Primary Language" | |
| 1448 | msgstr "Idioma principal" | |
| 1449 | ||
| 1450 | msgctxt "#30888" | |
| 1451 | msgid "Secondary Language" | |
| 1452 | msgstr "Idioma secundário" | |
| 1453 | ||
| 1454 | msgctxt "#30889" | |
| 1455 | msgid "Deepl Enabled" | |
| 1456 | msgstr "Deepl ativado" | |
| 1457 | ||
| 1458 | msgctxt "#30890" | |
| 1459 | msgid "Deepl Client Key" | |
| 1460 | msgstr "Chave API do Deepl" | |
| 1461 | ||
| 1462 | msgctxt "#30891" | |
| 1463 | msgid "Translator Configuration" | |
| 1464 | msgstr "Configuração do tradutor" | |
| 1465 | ||
| 1466 | msgctxt "#30892" | |
| 1467 | msgid "Deepl Target Language" | |
| 1468 | msgstr "Idioma de destino do Deepl" | |
| 1469 | ||
| 1470 | msgctxt "#30893" | |
| 1471 | msgid "Stremio Subtitle Configuration" | |
| 1472 | msgstr "Configuração de legendas do Stremio" | |
| 1473 | ||
| 1474 | msgctxt "#90001" | |
| 1475 | msgid "Add Custom Addon" | |
| 1476 | msgstr "Adicionar Addon Personalizado" | |
| 1477 | ||
| 1478 | msgctxt "#90002" | |
| 1479 | msgid "Add a custom Stremio addon by entering its URL" | |
| 1480 | msgstr "Adicionar um addon Stremio personalizado inserindo o seu URL" | |
| 1481 | ||
| 1482 | msgctxt "#90003" | |
| 1483 | msgid "Auto-select audio when playing" | |
| 1484 | msgstr "Selecionar áudio automaticamente ao reproduzir" | |
| 1485 | ||
| 1486 | msgctxt "#90004" | |
| 1487 | msgid "Audio Language" | |
| 1488 | msgstr "Idioma do Áudio" | |
| 1489 | ||
| 1490 | msgctxt "#90005" | |
| 1491 | msgid "Select audio language for playing the video" | |
| 1492 | msgstr "Selecionar o idioma do áudio para reproduzir o vídeo" | |
| 1493 | ||
| 1494 | msgctxt "#90006" | |
| 1495 | msgid "Search" | |
| 1496 | msgstr "Pesquisar" | |
| 1497 | ||
| 1498 | msgctxt "#90007" | |
| 1499 | msgid "TV Shows" | |
| 1500 | msgstr "Séries" | |
| 1501 | ||
| 1502 | msgctxt "#90008" | |
| 1503 | msgid "Movies" | |
| 1504 | msgstr "Filmes" | |
| 1505 | ||
| 1506 | msgctxt "#90009" | |
| 1507 | msgid "Anime" | |
| 1508 | msgstr "Anime" | |
| 1509 | ||
| 1510 | msgctxt "#90010" | |
| 1511 | msgid "Live TV" | |
| 1512 | msgstr "TV Direta" | |
| 1513 | ||
| 1514 | msgctxt "#90011" | |
| 1515 | msgid "Direct Search" | |
| 1516 | msgstr "Pesquisa Direta" | |
| 1517 | ||
| 1518 | msgctxt "#90012" | |
| 1519 | msgid "Torrents" | |
| 1520 | msgstr "Torrents" | |
| 1521 | ||
| 1522 | msgctxt "#90013" | |
| 1523 | msgid "Telegram" | |
| 1524 | msgstr "Telegram" | |
| 1525 | ||
| 1526 | msgctxt "#90014" | |
| 1527 | msgid "Cloud" | |
| 1528 | msgstr "Nuvem" | |
| 1529 | ||
| 1530 | msgctxt "#90015" | |
| 1531 | msgid "Downloads" | |
| 1532 | msgstr "Descarregamentos" | |
| 1533 | ||
| 1534 | msgctxt "#90016" | |
| 1535 | msgid "Settings" | |
| 1536 | msgstr "Definições" | |
| 1537 | ||
| 1538 | msgctxt "#90017" | |
| 1539 | msgid "History" | |
| 1540 | msgstr "Histórico" | |
| 1541 | ||
| 1542 | msgctxt "#90018" | |
| 1543 | msgid "Donate" | |
| 1544 | msgstr "Doar" | |
| 1545 | ||
| 1546 | msgctxt "#90019" | |
| 1547 | msgid "Recently Watched Files" | |
| 1548 | msgstr "Ficheiros vistos recentemente" | |
| 1549 | ||
| 1550 | msgctxt "#90020" | |
| 1551 | msgid "Recently Watched Titles" | |
| 1552 | msgstr "Títulos vistos recentemente" | |
| 1553 | ||
| 1554 | msgctxt "#90021" | |
| 1555 | msgid "TV Calendar" | |
| 1556 | msgstr "Calendário de TV" | |
| 1557 | ||
| 1558 | msgctxt "#90022" | |
| 1559 | msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:" | |
| 1560 | msgstr "Se gosta de usar o Jacktook e quer apoiar o seu desenvolvimento, pode pagar-me um café! O seu apoio ajuda a manter o projeto ativo e permite futuras melhorias:" | |
| 1561 | ||
| 1562 | msgctxt "#90023" | |
| 1563 | msgid "Support Jacktook" | |
| 1564 | msgstr "Apoiar o Jacktook" | |
| 1565 | ||
| 1566 | msgctxt "#90024" | |
| 1567 | msgid "Trending Recent" | |
| 1568 | msgstr "Tendências recentes" | |
| 1569 | ||
| 1570 | msgctxt "#90025" | |
| 1571 | msgid "Genres" | |
| 1572 | msgstr "Géneros" | |
| 1573 | ||
| 1574 | msgctxt "#90026" | |
| 1575 | msgid "Calendar" | |
| 1576 | msgstr "Calendário" | |
| 1577 | ||
| 1578 | msgctxt "#90027" | |
| 1579 | msgid "Years" | |
| 1580 | msgstr "Anos" | |
| 1581 | ||
| 1582 | msgctxt "#90028" | |
| 1583 | msgid "Trending" | |
| 1584 | msgstr "Tendências" | |
| 1585 | ||
| 1586 | msgctxt "#90029" | |
| 1587 | msgid "Most Watched" | |
| 1588 | msgstr "Mais vistos" | |
| 1589 | ||
| 1590 | msgctxt "#90030" | |
| 1591 | msgid "Most Favorited" | |
| 1592 | msgstr "Mais favoritados" | |
| 1593 | ||
| 1594 | msgctxt "#90031" | |
| 1595 | msgid "Popular Lists" | |
| 1596 | msgstr "Listas populares" | |
| 1597 | ||
| 1598 | msgctxt "#90032" | |
| 1599 | msgid "Trending Lists" | |
| 1600 | msgstr "Listas de tendências" | |
| 1601 | ||
| 1602 | msgctxt "#90033" | |
| 1603 | msgid "Recomendations" | |
| 1604 | msgstr "Recomendações" | |
| 1605 | ||
| 1606 | msgctxt "#90034" | |
| 1607 | msgid "Watched History" | |
| 1608 | msgstr "Histórico de visualização" | |
| 1609 | ||
| 1610 | msgctxt "#90035" | |
| 1611 | msgid "Watchlist" | |
| 1612 | msgstr "Lista de desejos" | |
| 1613 | ||
| 1614 | msgctxt "#90036" | |
| 1615 | msgid "Top 10 BoxOffice" | |
| 1616 | msgstr "Top 10 Bilheteira" | |
| 1617 | ||
| 1618 | msgctxt "#90037" | |
| 1619 | msgid "Popular" | |
| 1620 | msgstr "Popular" | |
| 1621 | ||
| 1622 | msgctxt "#90038" | |
| 1623 | msgid "Popular Recent" | |
| 1624 | msgstr "Populares recentes" | |
| 1625 | ||
| 1626 | msgctxt "#90039" | |
| 1627 | msgid "Airing" | |
| 1628 | msgstr "No ar" | |
| 1629 | ||
| 1630 | msgctxt "#90040" | |
| 1631 | msgid "Years" | |
| 1632 | msgstr "Anos" | |
| 1633 | ||
| 1634 | msgctxt "#90041" | |
| 1635 | msgid "Genres" | |
| 1636 | msgstr "Géneros" | |
| 1637 | ||
| 1638 | msgctxt "#90042" | |
| 1639 | msgid "Top Rated" | |
| 1640 | msgstr "Melhores avaliados" | |
| 1641 | ||
| 1642 | msgctxt "#90043" | |
| 1643 | msgid "Most Watched" | |
| 1644 | msgstr "Mais vistos" | |
| 1645 | ||
| 1646 | msgctxt "#90044" | |
| 1647 | msgid "Torrentio Configuration" | |
| 1648 | msgstr "Torrentio Configuração" | |
| 1649 | ||
| 1650 | msgctxt "#90045" | |
| 1651 | msgid "Enable Torrentio Settings" | |
| 1652 | msgstr "Ativar definições Torrentio" | |
| 1653 | ||
| 1654 | msgctxt "#90046" | |
| 1655 | msgid "Enable or disable the Torrentio Configuration" | |
| 1656 | msgstr "Ativar ou desativar the Torrentio Configuration" | |
| 1657 | ||
| 1658 | msgctxt "#90047" | |
| 1659 | msgid "Select Torrentio Providers" | |
| 1660 | msgstr "Selecionar provedores Torrentio" | |
| 1661 | ||
| 1662 | msgctxt "#90048" | |
| 1663 | msgid "Choose which providers to include in the stream request." | |
| 1664 | msgstr "Escolha quais os provedores a incluir no pedido de stream." | |
| 1665 | ||
| 1666 | msgctxt "#90049" | |
| 1667 | msgid "Scrape again" | |
| 1668 | msgstr "Pesquisar novamente" | |
| 1669 | ||
| 1670 | msgctxt "#90050" | |
| 1671 | msgid "Recommendations" | |
| 1672 | msgstr "Recomendações" | |
| 1673 | ||
| 1674 | msgctxt "#90051" | |
| 1675 | msgid "Similar" | |
| 1676 | msgstr "Semelhante" | |
| 1677 | ||
| 1678 | msgctxt "#90052" | |
| 1679 | msgid "Thread Number" | |
| 1680 | msgstr "Número de threads" | |
| 1681 | ||
| 1682 | msgctxt "#90053" | |
| 1683 | msgid "Select the number of threads (1-30)" | |
| 1684 | msgstr "Select the number of threads (1-30)" | |
| 1685 | ||
| 1686 | msgctxt "#90054" | |
| 1687 | msgid "Kodi logs" | |
| 1688 | msgstr "Logs do Kodi" | |
| 1689 | ||
| 1690 | msgctxt "#90055" | |
| 1691 | msgid "Kodi old logs" | |
| 1692 | msgstr "Logs antigos do Kodi" | |
| 1693 | ||
| 1694 | msgctxt "#90056" | |
| 1695 | msgid "Displays Kodi logs to help diagnose issues." | |
| 1696 | msgstr "Exibe os logs do Kodi para ajudar a diagnosticar problemas." | |
| 1697 | ||
| 1698 | msgctxt "#90057" | |
| 1699 | msgid "Logs" | |
| 1700 | msgstr "Logs" | |
| 1701 | ||
| 1702 | msgctxt "#90058" | |
| 1703 | msgid "Filter by Size" | |
| 1704 | msgstr "Filtrar por Tamanho" | |
| 1705 | ||
| 1706 | msgctxt "#90059" | |
| 1707 | msgid "Enable Size Filter" | |
| 1708 | msgstr "Ativar filtro de tamanho" | |
| 1709 | ||
| 1710 | msgctxt "#90060" | |
| 1711 | msgid "Minimum Size (MB)" | |
| 1712 | msgstr "Minimum Size (MB)" | |
| 1713 | ||
| 1714 | msgctxt "#90061" | |
| 1715 | msgid "Maximum Size (MB)" | |
| 1716 | msgstr "Maximum Size (MB)" | |
| 1717 | ||
| 1718 | msgctxt "#90062" | |
| 1719 | msgid "Enable Cached Search" | |
| 1720 | msgstr "Ativar pesquisa em cache" | |
| 1721 | ||
| 1722 | msgctxt "#90063" | |
| 1723 | msgid "Only show cached streams from external services" | |
| 1724 | msgstr "Mostrar apenas streams em cache de serviços externos" | |
| 1725 | ||
| 1726 | msgctxt "#90064" | |
| 1727 | msgid "Popular" | |
| 1728 | msgstr "Popular" | |
| 1729 | ||
| 1730 | msgctxt "#90065" | |
| 1731 | msgid "Languages" | |
| 1732 | msgstr "Idiomas" | |
| 1733 | ||
| 1734 | msgctxt "#90066" | |
| 1735 | msgid "Networks" | |
| 1736 | msgstr "Redes" | |
| 1737 | ||
| 1738 | msgctxt "#90067" | |
| 1739 | msgid "Collections" | |
| 1740 | msgstr "Coleções" | |
| 1741 | ||
| 1742 | msgctxt "#90068" | |
| 1743 | msgid "Search for collections:" | |
| 1744 | msgstr "Pesquisar coleções:" | |
| 1745 | ||
| 1746 | msgctxt "#90069" | |
| 1747 | msgid "Main Menu" | |
| 1748 | msgstr "Menu Principal" | |
| 1749 | ||
| 1750 | msgctxt "#90070" | |
| 1751 | msgid "Last Titles" | |
| 1752 | msgstr "Últimos Títulos" | |
| 1753 | ||
| 1754 | msgctxt "#90071" | |
| 1755 | msgid "Last Files" | |
| 1756 | msgstr "Últimos Ficheiros" | |
| 1757 | ||
| 1758 | msgctxt "#90072" | |
| 1759 | msgid "Trakt Lists" | |
| 1760 | msgstr "Listas Trakt" | |
| 1761 | ||
| 1762 | msgctxt "#90073" | |
| 1763 | msgid "Metadata" | |
| 1764 | msgstr "Metadados" | |
| 1765 | ||
| 1766 | msgctxt "#90074" | |
| 1767 | msgid "Top Lists" | |
| 1768 | msgstr "Listas Top" | |
| 1769 | ||
| 1770 | msgctxt "#90075" | |
| 1771 | msgid "User Lists" | |
| 1772 | msgstr "Listas do Utilizador" | |
| 1773 | ||
| 1774 | msgctxt "#90076" | |
| 1775 | msgid "Search Lists" | |
| 1776 | msgstr "Pesquisar Listas" | |
| 1777 | ||
| 1778 | msgctxt "#90077" | |
| 1779 | msgid "MDbList Configuration" | |
| 1780 | msgstr "MDbList Configuração" | |
| 1781 | ||
| 1782 | msgctxt "#90078" | |
| 1783 | msgid "People" | |
| 1784 | msgstr "Pessoas" | |
| 1785 | ||
| 1786 | msgctxt "#90079" | |
| 1787 | msgid "Popular People" | |
| 1788 | msgstr "Pessoas Populares" | |
| 1789 | ||
| 1790 | msgctxt "#90080" | |
| 1791 | msgid "Latest People" | |
| 1792 | msgstr "Pessoas Recentes" | |
| 1793 | ||
| 1794 | msgctxt "#90081" | |
| 1795 | msgid "Search People" | |
| 1796 | msgstr "Pesquisar Pessoas" | |
| 1797 | ||
| 1798 | msgctxt "#90082" | |
| 1799 | msgid "Download subtitles" | |
| 1800 | msgstr "Descarregar legendas" | |
| 1801 | ||
| 1802 | msgctxt "#90083" | |
| 1803 | msgid "Download video" | |
| 1804 | msgstr "Descarregar vídeo" | |
| 1805 | ||
| 1806 | msgctxt "#90084" | |
| 1807 | msgid "Browse into" | |
| 1808 | msgstr "Explorar" | |
| 1809 | ||
| 1810 | msgctxt "#90085" | |
| 1811 | msgid "Include TV show Specials" | |
| 1812 | msgstr "Incluir Especiais de Séries de TV" | |
| 1813 | ||
| 1814 | msgctxt "#90086" | |
| 1815 | msgid "Airing Today" | |
| 1816 | msgstr "Hoje no Ar" | |
| 1817 | ||
| 1818 | msgctxt "#90099" | |
| 1819 | msgid "Filter by Source Type" | |
| 1820 | msgstr "Filtrar por Tipo de Fonte" | |
| 1821 | ||
| 1822 | msgctxt "#90100" | |
| 1823 | msgid "Include BluRay/UHD sources" | |
| 1824 | msgstr "Incluir fontes BluRay/UHD" | |
| 1825 | ||
| 1826 | msgctxt "#90105" | |
| 1827 | msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1828 | msgstr "Inclui lançamentos como: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1829 | ||
| 1830 | msgctxt "#90101" | |
| 1831 | msgid "Include WEB/HD sources" | |
| 1832 | msgstr "Incluir fontes WEB/HD" | |
| 1833 | ||
| 1834 | msgctxt "#90106" | |
| 1835 | msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1836 | msgstr "Inclui lançamentos como: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1837 | ||
| 1838 | msgctxt "#90102" | |
| 1839 | msgid "Include DVD/TV/SAT sources" | |
| 1840 | msgstr "Incluir fontes DVD/TV/SAT" | |
| 1841 | ||
| 1842 | msgctxt "#90107" | |
| 1843 | msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1844 | msgstr "Inclui lançamentos como: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1845 | ||
| 1846 | msgctxt "#90103" | |
| 1847 | msgid "Include CAM/Screener sources" | |
| 1848 | msgstr "Incluir fontes CAM/Screener" | |
| 1849 | ||
| 1850 | msgctxt "#90108" | |
| 1851 | msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR" | |
| 1852 | msgstr "Inclui lançamentos como: CAM, TeleSync, TeleCine, SCR" | |
| 1853 | ||
| 1854 | msgctxt "#90104" | |
| 1855 | msgid "Include Unknown sources" | |
| 1856 | msgstr "Incluir fontes desconhecidas" | |
| 1857 | ||
| 1858 | msgctxt "#90109" | |
| 1859 | msgid "Includes uncategorized or unidentified sources" | |
| 1860 | msgstr "Inclui fontes não categorizadas ou não identificadas" | |
| 1861 | ||
| 1862 | msgctxt "#90110" | |
| 1863 | msgid "Do you want to clear your watched history?." | |
| 1864 | msgstr "Deseja limpar o seu histórido de watched history.?" | |
| 1865 | ||
| 1866 | msgctxt "#90111" | |
| 1867 | msgid "Do you want to clear your history?." | |
| 1868 | msgstr "Deseja limpar o seu histórido de history.?" | |
| 1869 | ||
| 1870 | msgctxt "#90112" | |
| 1871 | msgid "Clear History" | |
| 1872 | msgstr "Limpar Histórico" | |
| 1873 | ||
| 1874 | msgctxt "#90113" | |
| 1875 | msgid "Click to delete all watched history immediately." | |
| 1876 | msgstr "Clique para apagar todo o histórico de visualização imediatamente." | |
| 1877 | ||
| 1878 | msgctxt "#90114" | |
| 1879 | msgid "History cleared" | |
| 1880 | msgstr "Histórico apagado" | |
| 1881 | ||
| 1882 | msgctxt "#90115" | |
| 1883 | msgid "Source Select" | |
| 1884 | msgstr "Seleção de Fonte" | |
| 1885 | ||
| 1886 | msgctxt "#90116" | |
| 1887 | msgid "Open Settings" | |
| 1888 | msgstr "Abrir Definições" | |
| 1889 | ||
| 1890 | msgctxt "#90117" | |
| 1891 | msgid "Select Torrentio Providers" | |
| 1892 | msgstr "Selecionar provedores Torrentio" | |
| 1893 | ||
| 1894 | msgctxt "#90118" | |
| 1895 | msgid "Port" | |
| 1896 | msgstr "Porta" | |
| 1897 | ||
| 1898 | msgctxt "#90119" | |
| 1899 | msgid "Jackett port (default:9117)" | |
| 1900 | msgstr "Jackett port (default:9117)" | |
| 1901 | ||
| 1902 | msgctxt "#90120" | |
| 1903 | msgid "Prowlarr port (default:9696)" | |
| 1904 | msgstr "Prowlarr port (default:9696)" | |
| 1905 | ||
| 1906 | msgctxt "#90121" | |
| 1907 | msgid "All Debrid Configuration" | |
| 1908 | msgstr "All Debrid Configuração" | |
| 1909 | ||
| 1910 | msgctxt "#90122" | |
| 1911 | msgid "Auto download subtitles" | |
| 1912 | msgstr "Descarregar legendas automaticamente" | |
| 1913 | ||
| 1914 | msgctxt "#90123" | |
| 1915 | msgid "Stremio Timeout" | |
| 1916 | msgstr "Stremio Tempo limite" | |
| 1917 | ||
| 1918 | msgctxt "#90124" | |
| 1919 | msgid "Webdav Client Configuration" | |
| 1920 | msgstr "Webdav Client Configuração" | |
| 1921 | ||
| 1922 | msgctxt "#90126" | |
| 1923 | msgid "Host" | |
| 1924 | msgstr "Host" | |
| 1925 | ||
| 1926 | msgctxt "#90127" | |
| 1927 | msgid "Username" | |
| 1928 | msgstr "Nome de utilizador" | |
| 1929 | ||
| 1930 | msgctxt "#90128" | |
| 1931 | msgid "Password" | |
| 1932 | msgstr "Palavra-passe" | |
| 1933 | ||
| 1934 | msgctxt "#90129" | |
| 1935 | msgid "Test Connection" | |
| 1936 | msgstr "" | |
| 1937 | ||
| 1938 | msgctxt "#90131" | |
| 1939 | msgid "Webdav" | |
| 1940 | msgstr "" | |
| 1941 | ||
| 1942 | msgctxt "#90132" | |
| 1943 | msgid "Remote Path" | |
| 1944 | msgstr "" | |
| 1945 | ||
| 1946 | msgctxt "#90133" | |
| 1947 | msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)." | |
| 1948 | msgstr "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)" | |
| 1949 | ||
| 1950 | msgctxt "#90134" | |
| 1951 | msgid "The username required for authenticating with your WebDAV server" | |
| 1952 | msgstr "" | |
| 1953 | ||
| 1954 | msgctxt "#90135" | |
| 1955 | msgid "The password for the specified username to authenticate with your WebDAV server" | |
| 1956 | msgstr "" | |
| 1957 | ||
| 1958 | msgctxt "#90136" | |
| 1959 | msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)." | |
| 1960 | msgstr "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)" | |
| 1961 | ||
| 1962 | msgctxt "#90137" | |
| 1963 | msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials." | |
| 1964 | msgstr "" | |
| 1965 | ||
| 1966 | msgctxt "#90138" | |
| 1967 | msgid "Remove Custom Addon" | |
| 1968 | msgstr "" | |
| 1969 | ||
| 1970 | msgctxt "#90139" | |
| 1971 | msgid "Playback" | |
| 1972 | msgstr "Reprodução" | |
| 1973 | ||
| 1974 | msgctxt "#90140" | |
| 1975 | msgid "Super quick play" | |
| 1976 | msgstr "" | |
| 1977 | ||
| 1978 | msgctxt "#90141" | |
| 1979 | msgid "Re-ask to play last played video" | |
| 1980 | msgstr "" | |
| 1981 | ||
| 1982 | msgctxt "#90142" | |
| 1983 | msgid "Replay Dialog" | |
| 1984 | msgstr "" | |
| 1985 | ||
| 1986 | msgctxt "#90143" | |
| 1987 | msgid "Do you want to play last watched?" | |
| 1988 | msgstr "" | |
| 1989 | ||
| 1990 | msgctxt "#90144" | |
| 1991 | msgid "Could not resolve playback URL" | |
| 1992 | msgstr "" | |
| 1993 | ||
| 1994 | msgctxt "#90145" | |
| 1995 | msgid "Precaching Enabled" | |
| 1996 | msgstr "Pré-carregamento Ativado" | |
| 1997 | ||
| 1998 | msgctxt "#90146" | |
| 1999 | msgid "Enable or disable precaching of next episodes." | |
| 2000 | msgstr "Ativar ou desativar o pré-carregamento dos próximos episódios." | |
| 2001 | ||
| 2002 | msgctxt "#90147" | |
| 2003 | msgid "Precaching Episode Count" | |
| 2004 | msgstr "Contagem de Episódios para Pré-carregar" | |
| 2005 | ||
| 2006 | msgctxt "#90148" | |
| 2007 | msgid "Number of next episodes to precache." | |
| 2008 | msgstr "Número de próximos episódios para pré-carregar." | |
| 2009 | ||
| 2010 | msgctxt "#30901" | |
| 2011 | msgid "Show Only Stremio Catalogs" | |
| 2012 | msgstr "" | |
| 2013 | ||
| 2014 | msgctxt "#30902" | |
| 2015 | msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus." | |
| 2016 | msgstr "Oculta itens incorporados (TMDb, Trakt, etc.) e mostra apenas catálogos do Stremio nos menus." | |
| 2017 | ||
| 2018 | msgctxt "#30903" | |
| 2019 | msgid "Select Live TV Addons" | |
| 2020 | msgstr "Selecionar extras de TV Direta" | |
| 2021 | ||
| 2022 | msgctxt "#30904" | |
| 2023 | msgid "Choose specific Stremio addons for Live TV content" | |
| 2024 | msgstr "Escolher extras específicos do Stremio para conteúdo de TV Direta" | |
| 2025 | ||
| 2026 | msgctxt "#30920" | |
| 2027 | msgid "Clear Trakt Cache" | |
| 2028 | msgstr "Limpar cache do Trakt" | |
| 2029 | ||
| 2030 | msgctxt "#30921" | |
| 2031 | msgid "Clear TMDB Cache" | |
| 2032 | msgstr "Limpar cache do TMDB" | |
| 2033 | ||
| 2034 | msgctxt "#30922" | |
| 2035 | msgid "Clear Providers Cache" | |
| 2036 | msgstr "Limpar cache de Provedores" | |
| 2037 | ||
| 2038 | msgctxt "#30923" | |
| 2039 | msgid "Clear Settings Cache" | |
| 2040 | msgstr "Limpar cache de Definições" | |
| 2041 | ||
| 2042 | msgctxt "#90149" | |
| 2043 | msgid "Maintenance" | |
| 2044 | msgstr "Manutenção" | |
| 2045 | ||
| 2046 | msgctxt "#90150" | |
| 2047 | msgid "Stremio: General" | |
| 2048 | msgstr "Stremio: Geral" | |
| 2049 | ||
| 2050 | msgctxt "#90151" | |
| 2051 | msgid "Stremio: Account" | |
| 2052 | msgstr "Stremio: Conta" | |
| 2053 | ||
| 2054 | msgctxt "#90152" | |
| 2055 | msgid "Stremio: Addons & Catalogs" | |
| 2056 | msgstr "Stremio: Addons e catálogos" | |
| 2057 | ||
| 2058 | msgctxt "#90153" | |
| 2059 | msgid "Stremio: Torrentio" | |
| 2060 | msgstr "Stremio: Torrentio" | |
| 2061 | ||
| 2062 | msgctxt "#90154" | |
| 2063 | msgid "Trending Recent" | |
| 2064 | msgstr "Tendências recentes" | |
| 2065 | ||
| 2066 | msgctxt "#90155" | |
| 2067 | msgid "Stremio: Filters" | |
| 2068 | msgstr "Stremio: Filters" | |
| 2069 | ||
| 2070 | msgctxt "#32300" | |
| 2071 | msgid "Filter by Quality" | |
| 2072 | msgstr "Filtrar por Qualidade" | |
| 2073 | ||
| 2074 | msgctxt "#32301" | |
| 2075 | msgid "Filter by Provider" | |
| 2076 | msgstr "" | |
| 2077 | ||
| 2078 | msgctxt "#32302" | |
| 2079 | msgid "Filter by Source" | |
| 2080 | msgstr "" | |
| 2081 | ||
| 2082 | msgctxt "#32303" | |
| 2083 | msgid "Filter by Language" | |
| 2084 | msgstr "" | |
| 2085 | ||
| 2086 | msgctxt "#32304" | |
| 2087 | msgid "Filter by Torrents" | |
| 2088 | msgstr "" | |
| 2089 | ||
| 2090 | msgctxt "#32305" | |
| 2091 | msgid "Filter by Debrid" | |
| 2092 | msgstr "Filtrar por Debrid" | |
| 2093 | ||
| 2094 | msgctxt "#32306" | |
| 2095 | msgid "Reset Filter" | |
| 2096 | msgstr "" | |
| 2097 | ||
| 2098 | msgctxt "#30930" | |
| 2099 | msgid "Ping Addons" | |
| 2100 | msgstr "Testar ligação aos extras" | |
| 2101 | ||
| 2102 | msgctxt "#30931" | |
| 2103 | msgid "Check availability of addons and show only reachable ones" | |
| 2104 | msgstr "Verificar a disponibilidade dos extras e mostrar apenas os alcançáveis." | |
| 2105 | ||
| 2106 | msgctxt "#90164" | |
| 2107 | msgid "Skip Intro / Recap" | |
| 2108 | msgstr "Saltar Intro / Resumo" | |
| 2109 | ||
| 2110 | msgctxt "#90165" | |
| 2111 | msgid "Enable Skip Intro" | |
| 2112 | msgstr "Ativar Saltar Intro" | |
| 2113 | ||
| 2114 | msgctxt "#90166" | |
| 2115 | msgid "Detect intros and recaps using IntroDB and offer to skip them during playback" | |
| 2116 | msgstr "Detetar intros e resumos usando IntroDB e oferecer para saltar durante a reprodução" | |
| 2117 | ||
| 2118 | msgctxt "#90167" | |
| 2119 | msgid "Auto-skip" | |
| 2120 | msgstr "Salto automático" | |
| 2121 | ||
| 2122 | msgctxt "#90168" | |
| 2123 | msgid "Automatically skip intros and recaps without showing the button" | |
| 2124 | msgstr "Saltar automaticamente intros e resumos sem mostrar o botão" | |
| 2125 | ||
| 2126 | msgctxt "#90160" | |
| 2127 | msgid "Skip Intro" | |
| 2128 | msgstr "Saltar Intro" | |
| 2129 | ||
| 2130 | msgctxt "#90161" | |
| 2131 | msgid "Skip Recap" | |
| 2132 | msgstr "Saltar Resumo" | |
| 2133 | ||
| 2134 | msgctxt "#90162" | |
| 2135 | msgid "Manage from Phone" | |
| 2136 | msgstr "Gerir do telemóvel" | |
| 2137 | ||
| 2138 | msgctxt "#90163" | |
| 2139 | msgid "Start a local web server and show a QR code to manage Stremio sources from your phone" | |
| 2140 | msgstr "Iniciar um servidor web local e mostrar um código QR para gerir as fontes do Stremio a partir do seu telemóvel" | |
| 2141 | ||
| 2142 | msgctxt "#90200" | |
| 2143 | msgid "Continue Watching" | |
| 2144 | msgstr "Continuar a Ver" | |
| 2145 | ||
| 2146 | msgctxt "#90201" | |
| 2147 | msgid "Library" | |
| 2148 | msgstr "Biblioteca" | |
| 2149 | ||
| 2150 | msgctxt "#90202" | |
| 2151 | msgid "My Shows" | |
| 2152 | msgstr "Minhas Séries" | |
| 2153 | ||
| 2154 | msgctxt "#90203" | |
| 2155 | msgid "My Movies" | |
| 2156 | msgstr "Meus Filmes" | |
| 2157 | ||
| 2158 | msgctxt "#90204" | |
| 2159 | msgid "Remove from Library" | |
| 2160 | msgstr "Remover da Biblioteca" | |
| 2161 | ||
| 2162 | msgctxt "#90205" | |
| 2163 | msgid "Add to Library" | |
| 2164 | msgstr "Adicionar à Biblioteca" | |
| 2165 | ||
| 2166 | msgctxt "#90206" | |
| 2167 | msgid "Remove" | |
| 2168 | msgstr "Remover" | |
| 2169 | ||
| 2170 | msgctxt "#90207" | |
| 2171 | msgid "Search Movies & TV" | |
| 2172 | msgstr "Pesquisar Filmes e Séries" | |
| 2173 | ||
| 2174 | msgctxt "#90208" | |
| 2175 | msgid "Recent Searches" | |
| 2176 | msgstr "Pesquisas Recentes" | |
| 2177 | ||
| 2178 | msgctxt "#90209" | |
| 2179 | msgid "Recent Direct Searches" | |
| 2180 | msgstr "Pesquisas Diretas Recentes" | |
| 2181 | ||
| 2182 | msgctxt "#90210" | |
| 2183 | msgid "Clear All Search History" | |
| 2184 | msgstr "Limpar todo o histórico de pesquisa" | |
| 2185 | ||
| 2186 | msgctxt "#90211" | |
| 2187 | msgid "Bypass Stremio Addons Native Sorting" | |
| 2188 | msgstr "Ignorar a ordenação nativa dos extras do Stremio" | |
| 2189 | ||
| 2190 | msgctxt "#90212" | |
| 2191 | msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds." | |
| 2192 | msgstr "" | |
| 2193 | ||
| 2194 | msgctxt "#90213" | |
| 2195 | msgid "Select Bypassed Addons" | |
| 2196 | msgstr "Selecionar extras a ignorar" | |
| 2197 | ||
| 2198 | msgctxt "#90214" | |
| 2199 | msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering." | |
| 2200 | msgstr "" | |
| 2201 | ||
| 2202 | msgctxt "#90215" | |
| 2203 | msgid "Downgrade" | |
| 2204 | msgstr "Reverter" | |
| 2205 | ||
| 2206 | msgctxt "#90671" | |
| 2207 | msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails." | |
| 2208 | msgstr "Instala uma versao publicada anterior do Jacktook. A versao atual e salva e restaurada automaticamente se o downgrade falhar." | |
| 2209 | ||
| 2210 | msgctxt "#30932" | |
| 2211 | msgid "Update Action" | |
| 2212 | msgstr "" | |
| 2213 | ||
| 2214 | msgctxt "#30933" | |
| 2215 | msgid "Choose how to handle background update checks." | |
| 2216 | msgstr "" | |
| 2217 | ||
| 2218 | msgctxt "#30934" | |
| 2219 | msgid "Ask" | |
| 2220 | msgstr "" | |
| 2221 | ||
| 2222 | msgctxt "#30935" | |
| 2223 | msgid "Notify" | |
| 2224 | msgstr "" | |
| 2225 | ||
| 2226 | msgctxt "#90216" | |
| 2227 | msgid "Volume Check Enabled" | |
| 2228 | msgstr "" | |
| 2229 | ||
| 2230 | msgctxt "#90217" | |
| 2231 | msgid "Automatically limits volume if it is above the threshold before playback." | |
| 2232 | msgstr "" | |
| 2233 | ||
| 2234 | msgctxt "#90218" | |
| 2235 | msgid "Volume Threshold" | |
| 2236 | msgstr "Limite de volume" | |
| 2237 | ||
| 2238 | msgctxt "#90219" | |
| 2239 | msgid "The volume percentage above which the warning triggers." | |
| 2240 | msgstr "" | |
| 2241 | ||
| 2242 | msgctxt "#90220" | |
| 2243 | msgid "High Volume Warning" | |
| 2244 | msgstr "Easynews" | |
| 2245 | ||
| 2246 | msgctxt "#90221" | |
| 2247 | msgid "High volume detected. Adjusting to %s%% for safety." | |
| 2248 | msgstr "Habilitar o EasyNews como fornecedor de stream direto." | |
| 2249 | ||
| 2250 | msgctxt "#90222" | |
| 2251 | msgid "Fanart.tv Configuration" | |
| 2252 | msgstr "Nome de utilizador do EasyNews." | |
| 2253 | ||
| 2254 | msgctxt "#90223" | |
| 2255 | msgid "Client Key" | |
| 2256 | msgstr "Palavra-passe do EasyNews." | |
| 2257 | ||
| 2258 | msgctxt "#90224" | |
| 2259 | msgid "Your personal Fanart.tv client key for higher quality assets." | |
| 2260 | msgstr "Tempo limite do pedido em segundos." | |
| 2261 | ||
| 2262 | msgctxt "#90225" | |
| 2263 | msgid "Debrid Notifications" | |
| 2264 | msgstr "" | |
| 2265 | ||
| 2266 | msgctxt "#90226" | |
| 2267 | msgid "Debrid Expiration Notification" | |
| 2268 | msgstr "" | |
| 2269 | ||
| 2270 | msgctxt "#90227" | |
| 2271 | msgid "Enable or disable notifications when your Debrid subscription is about to expire." | |
| 2272 | msgstr "Ativar ou desativar notifications when your Debrid subscription is about to expire." | |
| 2273 | ||
| 2274 | msgctxt "#90228" | |
| 2275 | msgid "Expiration Threshold (Days)" | |
| 2276 | msgstr "Expiration Threshold (Days)" | |
| 2277 | ||
| 2278 | msgctxt "#90229" | |
| 2279 | msgid "How many days before expiration to start notifying." | |
| 2280 | msgstr "" | |
| 2281 | ||
| 2282 | msgctxt "#90230" | |
| 2283 | msgid "%s expires in %d days." | |
| 2284 | msgstr "" | |
| 2285 | ||
| 2286 | msgctxt "#90231" | |
| 2287 | msgid "%s expires today!" | |
| 2288 | msgstr "" | |
| 2289 | ||
| 2290 | msgctxt "#90232" | |
| 2291 | msgid "Easynews" | |
| 2292 | msgstr "Easynews" | |
| 2293 | ||
| 2294 | msgctxt "#90233" | |
| 2295 | msgid "Enable EasyNews as a direct stream provider." | |
| 2296 | msgstr "" | |
| 2297 | ||
| 2298 | msgctxt "#90234" | |
| 2299 | msgid "Your EasyNews username." | |
| 2300 | msgstr "" | |
| 2301 | ||
| 2302 | msgctxt "#90235" | |
| 2303 | msgid "Your EasyNews password." | |
| 2304 | msgstr "" | |
| 2305 | ||
| 2306 | msgctxt "#90236" | |
| 2307 | msgid "Request timeout in seconds." | |
| 2308 | msgstr "" | |
| 2309 | ||
| 2310 | msgctxt "#90237" | |
| 2311 | msgid "Account Information" | |
| 2312 | msgstr "Informações da conta" | |
| 2313 | ||
| 2314 | msgctxt "#90238" | |
| 2315 | msgid "Search Dialog Style" | |
| 2316 | msgstr "" | |
| 2317 | ||
| 2318 | msgctxt "#90239" | |
| 2319 | msgid "Background" | |
| 2320 | msgstr "" | |
| 2321 | ||
| 2322 | msgctxt "#90240" | |
| 2323 | msgid "Detailed Status" | |
| 2324 | msgstr "" | |
| 2325 | ||
| 2326 | msgctxt "#90241" | |
| 2327 | msgid "Search Status" | |
| 2328 | msgstr "Status da pesquisa" | |
| 2329 | ||
| 2330 | msgctxt "#90242" | |
| 2331 | msgid "Cancel" | |
| 2332 | msgstr "Cancelar" | |
| 2333 | ||
| 2334 | msgctxt "#90243" | |
| 2335 | msgid "Results found: " | |
| 2336 | msgstr "Resultados encontrados: " | |
| 2337 | ||
| 2338 | msgctxt "#90244" | |
| 2339 | msgid "Pending" | |
| 2340 | msgstr "Pendente" | |
| 2341 | ||
| 2342 | msgctxt "#90245" | |
| 2343 | msgid "In Progress" | |
| 2344 | msgstr "Em curso" | |
| 2345 | ||
| 2346 | msgctxt "#90246" | |
| 2347 | msgid "Completed" | |
| 2348 | msgstr "Concluído" | |
| 2349 | ||
| 2350 | msgctxt "#90247" | |
| 2351 | msgid "Failed" | |
| 2352 | msgstr "Falhou" | |
| 2353 | ||
| 2354 | msgctxt "#90248" | |
| 2355 | msgid "Cancelled" | |
| 2356 | msgstr "Cancelado" | |
| 2357 | ||
| 2358 | msgctxt "#90249" | |
| 2359 | msgid "Your personal Jackgram API token for authentication." | |
| 2360 | msgstr "" | |
| 2361 | ||
| 2362 | msgctxt "#90250" | |
| 2363 | msgid "Subtitles Found" | |
| 2364 | msgstr "Legendas Encontradas" | |
| 2365 | ||
| 2366 | msgctxt "#90251" | |
| 2367 | msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?" | |
| 2368 | msgstr "Ja existem legendas descarregadas para este video.\nDeseja usar as legendas existentes?" | |
| 2369 | ||
| 2370 | msgctxt "#90252" | |
| 2371 | msgid "No subtitles found" | |
| 2372 | msgstr "Nenhuma legenda encontrada" | |
| 2373 | ||
| 2374 | msgctxt "#90253" | |
| 2375 | msgid "No subtitles selected" | |
| 2376 | msgstr "Nenhuma legenda selecionada" | |
| 2377 | ||
| 2378 | msgctxt "#90254" | |
| 2379 | msgid "Translate Subtitles" | |
| 2380 | msgstr "Traduzir Legendas" | |
| 2381 | ||
| 2382 | msgctxt "#90255" | |
| 2383 | msgid "Do you want to also translate the selected subtitles?" | |
| 2384 | msgstr "Deseja tambem traduzir as legendas selecionadas?" | |
| 2385 | ||
| 2386 | msgctxt "#90256" | |
| 2387 | msgid "Select Subtitle to Download" | |
| 2388 | msgstr "Selecionar Legenda para Descarregar" | |
| 2389 | ||
| 2390 | msgctxt "#90257" | |
| 2391 | msgid "Subtitles Loaded" | |
| 2392 | msgstr "Legendas Carregadas" | |
| 2393 | ||
| 2394 | msgctxt "#90258" | |
| 2395 | msgid "Backup" | |
| 2396 | msgstr "Copia de Seguranca das Definicoes" | |
| 2397 | ||
| 2398 | msgctxt "#90259" | |
| 2399 | msgid "Export Backup" | |
| 2400 | msgstr "Exportar Copia de Seguranca" | |
| 2401 | ||
| 2402 | msgctxt "#90260" | |
| 2403 | msgid "Export addon settings and custom Stremio addons to a JSON backup file." | |
| 2404 | msgstr "Exporta as definicoes do addon e os addons personalizados do Stremio para um ficheiro JSON de copia de seguranca." | |
| 2405 | ||
| 2406 | msgctxt "#90261" | |
| 2407 | msgid "Restore Backup" | |
| 2408 | msgstr "Restaurar Copia de Seguranca" | |
| 2409 | ||
| 2410 | msgctxt "#90262" | |
| 2411 | msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file." | |
| 2412 | msgstr "Restaura as definicoes do addon e substitui os addons personalizados do Stremio a partir de um ficheiro JSON de copia de seguranca." | |
| 2413 | ||
| 2414 | msgctxt "#90263" | |
| 2415 | msgid "Remove Sensitive Data" | |
| 2416 | msgstr "Remover Dados Sensiveis" | |
| 2417 | ||
| 2418 | msgctxt "#90264" | |
| 2419 | msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups." | |
| 2420 | msgstr "Remove chaves API, palavras-passe, nomes de utilizador, emails, estado de autenticacao e URLs de addons personalizados do Stremio das copias exportadas." | |
| 2421 | ||
| 2422 | msgctxt "#90265" | |
| 2423 | msgid "Settings backup exported" | |
| 2424 | msgstr "Copia de seguranca exportada" | |
| 2425 | ||
| 2426 | msgctxt "#90266" | |
| 2427 | msgid "Settings backup restored" | |
| 2428 | msgstr "Copia de seguranca restaurada" | |
| 2429 | ||
| 2430 | msgctxt "#90267" | |
| 2431 | msgid "Settings Backup Failed" | |
| 2432 | msgstr "Falha na Copia de Seguranca" | |
| 2433 | ||
| 2434 | msgctxt "#90268" | |
| 2435 | msgid "Select export folder" | |
| 2436 | msgstr "Selecionar pasta de exportacao" | |
| 2437 | ||
| 2438 | msgctxt "#90269" | |
| 2439 | msgid "Select backup file" | |
| 2440 | msgstr "Selecionar ficheiro de copia" | |
| 2441 | ||
| 2442 | msgctxt "#90270" | |
| 2443 | msgid "Backup file is invalid." | |
| 2444 | msgstr "O ficheiro de copia e invalido." | |
| 2445 | ||
| 2446 | msgctxt "#90271" | |
| 2447 | msgid "No export folder selected." | |
| 2448 | msgstr "Nenhuma pasta de exportacao selecionada." | |
| 2449 | ||
| 2450 | msgctxt "#90272" | |
| 2451 | msgid "No backup file selected." | |
| 2452 | msgstr "Nenhum ficheiro de copia selecionado." | |
| 2453 | ||
| 2454 | msgctxt "#90273" | |
| 2455 | msgid "Unable to export settings backup." | |
| 2456 | msgstr "Nao foi possivel exportar a copia de seguranca." | |
| 2457 | ||
| 2458 | msgctxt "#90274" | |
| 2459 | msgid "Unable to restore settings backup." | |
| 2460 | msgstr "Nao foi possivel restaurar a copia de seguranca." | |
| 2461 | ||
| 2462 | msgctxt "#90275" | |
| 2463 | msgid "Restore Backup" | |
| 2464 | msgstr "Restaurar Copia de Seguranca" | |
| 2465 | ||
| 2466 | msgctxt "#90276" | |
| 2467 | msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?" | |
| 2468 | msgstr "Isto vai substituir as definicoes atuais do addon e trocar os addons personalizados do Stremio pelos da copia de seguranca. Deseja continuar?" | |
| 2469 | ||
| 2470 | msgctxt "#90277" | |
| 2471 | msgid "Restore cancelled" | |
| 2472 | msgstr "Restauro cancelado" | |
| 2473 | ||
| 2474 | msgctxt "#90278" | |
| 2475 | msgid "Reset All Settings" | |
| 2476 | msgstr "Repor Todas as Definicoes" | |
| 2477 | ||
| 2478 | msgctxt "#90279" | |
| 2479 | msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?" | |
| 2480 | msgstr "Isto vai repor todas as definicoes do addon para os valores predefinidos e remover os seus addons personalizados do Stremio. Deseja continuar?" | |
| 2481 | ||
| 2482 | msgctxt "#90280" | |
| 2483 | msgid "Reset cancelled" | |
| 2484 | msgstr "Reposicao cancelada" | |
| 2485 | ||
| 2486 | msgctxt "#90281" | |
| 2487 | msgid "All addon settings reset" | |
| 2488 | msgstr "Todas as definicoes do addon foram repostas" | |
| 2489 | ||
| 2490 | msgctxt "#90282" | |
| 2491 | msgid "Reset Failed" | |
| 2492 | msgstr "Falha ao Repor" | |
| 2493 | ||
| 2494 | msgctxt "#90283" | |
| 2495 | msgid "Unable to reset addon settings." | |
| 2496 | msgstr "Nao foi possivel repor as definicoes do addon." | |
| 2497 | ||
| 2498 | msgctxt "#90284" | |
| 2499 | msgid "Reset every addon setting back to its default value and clear custom Stremio addons." | |
| 2500 | msgstr "Repoe cada definicao do addon para o valor predefinido e limpa os addons personalizados do Stremio." | |
| 2501 | ||
| 2502 | msgctxt "#90285" | |
| 2503 | msgid "Factory Reset" | |
| 2504 | msgstr "Reposicao de Fabrica" | |
| 2505 | ||
| 2506 | msgctxt "#90286" | |
| 2507 | msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?" | |
| 2508 | msgstr "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 | ||
| 2510 | msgctxt "#90287" | |
| 2511 | msgid "Factory reset cancelled" | |
| 2512 | msgstr "Reposicao de fabrica cancelada" | |
| 2513 | ||
| 2514 | msgctxt "#90288" | |
| 2515 | msgid "Factory reset completed" | |
| 2516 | msgstr "Reposicao de fabrica concluida" | |
| 2517 | ||
| 2518 | msgctxt "#90289" | |
| 2519 | msgid "Factory Reset Failed" | |
| 2520 | msgstr "Falha na Reposicao de Fabrica" | |
| 2521 | ||
| 2522 | msgctxt "#90290" | |
| 2523 | msgid "Unable to complete factory reset." | |
| 2524 | msgstr "Nao foi possivel concluir a reposicao de fabrica." | |
| 2525 | ||
| 2526 | msgctxt "#90291" | |
| 2527 | msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data." | |
| 2528 | msgstr "Repoe as definicoes para os valores predefinidos, limpa as caches e apaga o historico e os dados locais do addon." | |
| 2529 | ||
| 2530 | msgctxt "#90292" | |
| 2531 | msgid "Trakt Library" | |
| 2532 | msgstr "Biblioteca do Trakt" | |
| 2533 | ||
| 2534 | msgctxt "#90293" | |
| 2535 | msgid "Trakt Discovery" | |
| 2536 | msgstr "Descoberta do Trakt" | |
| 2537 | ||
| 2538 | msgctxt "#90294" | |
| 2539 | msgid "Collection" | |
| 2540 | msgstr "Colecao" | |
| 2541 | ||
| 2542 | msgctxt "#90295" | |
| 2543 | msgid "Favorites" | |
| 2544 | msgstr "Favoritos" | |
| 2545 | ||
| 2546 | msgctxt "#90296" | |
| 2547 | msgid "Up Next" | |
| 2548 | msgstr "A Seguir" | |
| 2549 | ||
| 2550 | msgctxt "#90297" | |
| 2551 | msgid "My Lists" | |
| 2552 | msgstr "As Minhas Listas" | |
| 2553 | ||
| 2554 | msgctxt "#90298" | |
| 2555 | msgid "Liked Lists" | |
| 2556 | msgstr "Listas com Gostos" | |
| 2557 | ||
| 2558 | msgctxt "#90331" | |
| 2559 | msgid "Stremio Addons" | |
| 2560 | msgstr "Addons do Stremio" | |
| 2561 | ||
| 2562 | msgctxt "#90332" | |
| 2563 | msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first." | |
| 2564 | msgstr "Ainda nao ha addons disponiveis. Use Iniciar sessao no Stremio ou Adicionar Addon Personalizado primeiro." | |
| 2565 | ||
| 2566 | msgctxt "#90333" | |
| 2567 | msgid "Stremio Stream Addons" | |
| 2568 | msgstr "Addons de streams do Stremio" | |
| 2569 | ||
| 2570 | msgctxt "#90334" | |
| 2571 | msgid "Stremio Catalog Addons" | |
| 2572 | msgstr "Addons de catalogo do Stremio" | |
| 2573 | ||
| 2574 | msgctxt "#90335" | |
| 2575 | msgid "Stremio Live TV Addons" | |
| 2576 | msgstr "Addons de TV ao vivo do Stremio" | |
| 2577 | ||
| 2578 | msgctxt "#90336" | |
| 2579 | msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged." | |
| 2580 | msgstr "Atualiza os addons importados da sua conta Stremio. Os addons personalizados permanecem inalterados." | |
| 2581 | ||
| 2582 | msgctxt "#90337" | |
| 2583 | msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first." | |
| 2584 | msgstr "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 | ||
| 2586 | msgctxt "#90338" | |
| 2587 | msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first." | |
| 2588 | msgstr "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 | ||
| 2590 | msgctxt "#90339" | |
| 2591 | msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first." | |
| 2592 | msgstr "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 | ||
| 2594 | msgctxt "#90340" | |
| 2595 | msgid "Remove custom Stremio addons you previously added manually." | |
| 2596 | msgstr "Remova os addons personalizados do Stremio que adicionou manualmente." | |
| 2597 | ||
| 2598 | msgctxt "#90341" | |
| 2599 | msgid "Choose Torrent Client" | |
| 2600 | msgstr "Escolher cliente de torrent" | |
| 2601 | ||
| 2602 | msgctxt "#90342" | |
| 2603 | msgid "Clear Database Cache" | |
| 2604 | msgstr "Limpar cache da base de dados" | |
| 2605 | ||
| 2606 | msgctxt "#90343" | |
| 2607 | msgid "Clear Stremio Cache" | |
| 2608 | msgstr "Limpar cache do Stremio" | |
| 2609 | ||
| 2610 | msgctxt "#90344" | |
| 2611 | msgid "Clear Debrid Cache" | |
| 2612 | msgstr "Limpar cache de Debrid" | |
| 2613 | ||
| 2614 | msgctxt "#90345" | |
| 2615 | msgid "Clear MDBList Cache" | |
| 2616 | msgstr "Limpar cache do MDBList" | |
| 2617 | ||
| 2618 | msgctxt "#90346" | |
| 2619 | msgid "Restore backup from" | |
| 2620 | msgstr "Restaurar copia de seguranca de" | |
| 2621 | ||
| 2622 | msgctxt "#90347" | |
| 2623 | msgid "File" | |
| 2624 | msgstr "Arquivo" | |
| 2625 | ||
| 2626 | msgctxt "#90348" | |
| 2627 | msgid "URL" | |
| 2628 | msgstr "URL" | |
| 2629 | ||
| 2630 | msgctxt "#90349" | |
| 2631 | msgid "Enter backup URL" | |
| 2632 | msgstr "Introduza o URL da copia de seguranca" | |
| 2633 | ||
| 2634 | msgctxt "#90350" | |
| 2635 | msgid "No backup URL provided." | |
| 2636 | msgstr "Nao foi fornecido nenhum URL de copia de seguranca." | |
| 2637 | ||
| 2638 | msgctxt "#90351" | |
| 2639 | msgid "Unable to download settings backup." | |
| 2640 | msgstr "Nao foi possivel transferir a copia de seguranca." | |
| 2641 | ||
| 2642 | msgctxt "#90352" | |
| 2643 | msgid "Source Select Style" | |
| 2644 | msgstr "" | |
| 2645 | ||
| 2646 | msgctxt "#90353" | |
| 2647 | msgid "Modern" | |
| 2648 | msgstr "" | |
| 2649 | ||
| 2650 | msgctxt "#90354" | |
| 2651 | msgid "OLED" | |
| 2652 | msgstr "" | |
| 2653 | ||
| 2654 | msgctxt "#90355" | |
| 2655 | msgid "Cyberpunk" | |
| 2656 | msgstr "" | |
| 2657 | ||
| 2658 | msgctxt "#90356" | |
| 2659 | msgid "Forest" | |
| 2660 | msgstr "" | |
| 2661 | ||
| 2662 | msgctxt "#90357" | |
| 2663 | msgid "Gold" | |
| 2664 | msgstr "" | |
| 2665 | ||
| 2666 | msgctxt "#90358" | |
| 2667 | msgid "No playable results found" | |
| 2668 | msgstr "Nao foram encontrados resultados reproduziveis" | |
| 2669 | ||
| 2670 | msgctxt "#90359" | |
| 2671 | msgid "Add to TorrServer" | |
| 2672 | msgstr "Adicionar ao TorrServer" | |
| 2673 | ||
| 2674 | msgctxt "#90360" | |
| 2675 | msgid "Added to TorrServer" | |
| 2676 | msgstr "Adicionado ao TorrServer" | |
| 2677 | ||
| 2678 | msgctxt "#90361" | |
| 2679 | msgid "Could not resolve torrent details" | |
| 2680 | msgstr "Nao foi possivel resolver os detalhes do torrent" | |
| 2681 | ||
| 2682 | msgctxt "#90362" | |
| 2683 | msgid "Added to %s cloud" | |
| 2684 | msgstr "Adicionado a nuvem do %s" | |
| 2685 | ||
| 2686 | msgctxt "#90363" | |
| 2687 | msgid "Choose Debrid Service" | |
| 2688 | msgstr "Escolher servico Debrid" | |
| 2689 | ||
| 2690 | msgctxt "#90364" | |
| 2691 | msgid "No supported debrid service is enabled" | |
| 2692 | msgstr "Nenhum servico Debrid compativel esta habilitado" | |
| 2693 | ||
| 2694 | msgctxt "#90365" | |
| 2695 | msgid "Download to Debrid" | |
| 2696 | msgstr "Transferir para Debrid" | |
| 2697 | ||
| 2698 | msgctxt "#90366" | |
| 2699 | msgid "Include Unknown quality" | |
| 2700 | msgstr "Incluir qualidade desconhecida" | |
| 2701 | ||
| 2702 | msgctxt "#90367" | |
| 2703 | msgid "Includes releases where video quality could not be identified" | |
| 2704 | msgstr "Inclui releases onde a qualidade do video nao pode ser identificada" | |
| 2705 | ||
| 2706 | msgctxt "#90368" | |
| 2707 | msgid "Search by Keyword" | |
| 2708 | msgstr "Buscar por palavra-chave" | |
| 2709 | ||
| 2710 | msgctxt "#90369" | |
| 2711 | msgid "Latest Movies" | |
| 2712 | msgstr "Últimos filmes" | |
| 2713 | ||
| 2714 | msgctxt "#90370" | |
| 2715 | msgid "Latest Series" | |
| 2716 | msgstr "Últimas séries" | |
| 2717 | ||
| 2718 | msgctxt "#90371" | |
| 2719 | msgid "Raw Files" | |
| 2720 | msgstr "Arquivos brutos" | |
| 2721 | ||
| 2722 | msgctxt "#90372" | |
| 2723 | msgid "MDblist" | |
| 2724 | msgstr "MDblist" | |
| 2725 | ||
| 2726 | msgctxt "#90373" | |
| 2727 | msgid "Active Downloads" | |
| 2728 | msgstr "Downloads ativos" | |
| 2729 | ||
| 2730 | msgctxt "#90374" | |
| 2731 | msgid "Cancel Download" | |
| 2732 | msgstr "Cancelar download" | |
| 2733 | ||
| 2734 | msgctxt "#90375" | |
| 2735 | msgid "Delete File" | |
| 2736 | msgstr "Excluir arquivo" | |
| 2737 | ||
| 2738 | msgctxt "#90376" | |
| 2739 | msgid "Search Collections" | |
| 2740 | msgstr "Buscar coleções" | |
| 2741 | ||
| 2742 | msgctxt "#90377" | |
| 2743 | msgid "Popular Collections" | |
| 2744 | msgstr "Coleções populares" | |
| 2745 | ||
| 2746 | msgctxt "#90378" | |
| 2747 | msgid "Top Rated Collections" | |
| 2748 | msgstr "Coleções mais bem avaliadas" | |
| 2749 | ||
| 2750 | msgctxt "#90379" | |
| 2751 | msgid "Recommendations" | |
| 2752 | msgstr "Recomendações" | |
| 2753 | ||
| 2754 | msgctxt "#90380" | |
| 2755 | msgid "Similar" | |
| 2756 | msgstr "Semelhantes" | |
| 2757 | ||
| 2758 | msgctxt "#90381" | |
| 2759 | msgid "Keyword: %s" | |
| 2760 | msgstr "Palavra-chave: %s" | |
| 2761 | ||
| 2762 | msgctxt "#90410" | |
| 2763 | msgid "Error modifying Trakt Watchlist" | |
| 2764 | msgstr "Erro ao modificar a Watchlist do Trakt" | |
| 2765 | ||
| 2766 | msgctxt "#90411" | |
| 2767 | msgid "Marked as Watched on Trakt" | |
| 2768 | msgstr "Marcado como Visto no Trakt" | |
| 2769 | ||
| 2770 | msgctxt "#90412" | |
| 2771 | msgid "Error modifying Trakt Watched status" | |
| 2772 | msgstr "Erro ao modificar o estado de Visto no Trakt" | |
| 2773 | ||
| 2774 | msgctxt "#90413" | |
| 2775 | msgid "Play" | |
| 2776 | msgstr "Reproduzir" | |
| 2777 | ||
| 2778 | msgctxt "#90414" | |
| 2779 | msgid "Trailer" | |
| 2780 | msgstr "Trailer" | |
| 2781 | ||
| 2782 | msgctxt "#90415" | |
| 2783 | msgid "Watched" | |
| 2784 | msgstr "Visto" | |
| 2785 | ||
| 2786 | msgctxt "#90416" | |
| 2787 | msgid "Refresh" | |
| 2788 | msgstr "Atualizar" | |
| 2789 | ||
| 2790 | msgctxt "#90418" | |
| 2791 | msgid "No credits found for this ID" | |
| 2792 | msgstr "Nenhum crédito encontrado para este ID" | |
| 2793 | ||
| 2794 | msgctxt "#90419" | |
| 2795 | msgid "Credited People" | |
| 2796 | msgstr "Pessoas Creditadas" | |
| 2797 | ||
| 2798 | msgctxt "#90420" | |
| 2799 | msgid "Person Details" | |
| 2800 | msgstr "Detalhes da Pessoa" | |
| 2801 | ||
| 2802 | msgctxt "#90421" | |
| 2803 | msgid "Person not found" | |
| 2804 | msgstr "Pessoa não encontrada" | |
| 2805 | ||
| 2806 | msgctxt "#90422" | |
| 2807 | msgid "Jackgram not activated" | |
| 2808 | msgstr "Jackgram não ativado." | |
| 2809 | ||
| 2810 | msgctxt "#90423" | |
| 2811 | msgid "Not yet implemented" | |
| 2812 | msgstr "Ainda não implementado" | |
| 2813 | ||
| 2814 | msgctxt "#90424" | |
| 2815 | msgid "Unsupported debrid type" | |
| 2816 | msgstr "Tipo de debrid não suportado" | |
| 2817 | ||
| 2818 | msgctxt "#90425" | |
| 2819 | msgid "Easynews credentials required" | |
| 2820 | msgstr "Credenciais do Easynews obrigatórias" | |
| 2821 | ||
| 2822 | msgctxt "#90426" | |
| 2823 | msgid "No Corrupt or Missing Databases" | |
| 2824 | msgstr "Sem bases de dados corrompidas ou em falta" | |
| 2825 | ||
| 2826 | msgctxt "#90427" | |
| 2827 | msgid "Otaku (script.otaku.mappings) not found" | |
| 2828 | msgstr "Otaku (script.otaku.mappings) não encontrado" | |
| 2829 | ||
| 2830 | msgctxt "#90431" | |
| 2831 | msgid "No items found in this list" | |
| 2832 | msgstr "Nenhum item encontrado nesta lista" | |
| 2833 | ||
| 2834 | msgctxt "#90432" | |
| 2835 | msgid "Databases Rebuilt" | |
| 2836 | msgstr "Bases de dados reconstruídas" | |
| 2837 | ||
| 2838 | msgctxt "#90433" | |
| 2839 | msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s" | |
| 2840 | msgstr "[B]As seguintes bases de dados foram reconstruídas:[/B][CR][CR]%s" | |
| 2841 | ||
| 2842 | msgctxt "#90434" | |
| 2843 | msgid "Added to Trakt watchlist" | |
| 2844 | msgstr "Adicionado à watchlist do Trakt" | |
| 2845 | ||
| 2846 | msgctxt "#90435" | |
| 2847 | msgid "Failed to add to Trakt watchlist" | |
| 2848 | msgstr "Falha ao adicionar à watchlist do Trakt" | |
| 2849 | ||
| 2850 | msgctxt "#90436" | |
| 2851 | msgid "Removed from Trakt watchlist" | |
| 2852 | msgstr "Removido da watchlist do Trakt" | |
| 2853 | ||
| 2854 | msgctxt "#90437" | |
| 2855 | msgid "Failed to remove from Trakt watchlist" | |
| 2856 | msgstr "Falha ao remover da watchlist do Trakt" | |
| 2857 | ||
| 2858 | msgctxt "#90438" | |
| 2859 | msgid "Failed to mark as watched on Trakt" | |
| 2860 | msgstr "Falha ao marcar como visto no Trakt" | |
| 2861 | ||
| 2862 | msgctxt "#90439" | |
| 2863 | msgid "Marked as unwatched on Trakt" | |
| 2864 | msgstr "Marcado como não visto no Trakt" | |
| 2865 | ||
| 2866 | msgctxt "#90440" | |
| 2867 | msgid "Failed to mark as unwatched on Trakt" | |
| 2868 | msgstr "Falha ao marcar como não visto no Trakt" | |
| 2869 | ||
| 2870 | msgctxt "#90441" | |
| 2871 | msgid "Added to Trakt collection" | |
| 2872 | msgstr "Adicionado à coleção do Trakt" | |
| 2873 | ||
| 2874 | msgctxt "#90442" | |
| 2875 | msgid "Removed from Trakt collection" | |
| 2876 | msgstr "Removido da coleção do Trakt" | |
| 2877 | ||
| 2878 | msgctxt "#90443" | |
| 2879 | msgid "Failed to add to Trakt collection" | |
| 2880 | msgstr "Falha ao adicionar à coleção do Trakt" | |
| 2881 | ||
| 2882 | msgctxt "#90444" | |
| 2883 | msgid "Failed to remove from Trakt collection" | |
| 2884 | msgstr "Falha ao remover da coleção do Trakt" | |
| 2885 | ||
| 2886 | msgctxt "#90445" | |
| 2887 | msgid "Added to Trakt favorites" | |
| 2888 | msgstr "Adicionado aos favoritos do Trakt" | |
| 2889 | ||
| 2890 | msgctxt "#90446" | |
| 2891 | msgid "Removed from Trakt favorites" | |
| 2892 | msgstr "Removido dos favoritos do Trakt" | |
| 2893 | ||
| 2894 | msgctxt "#90447" | |
| 2895 | msgid "Failed to add to Trakt favorites" | |
| 2896 | msgstr "Falha ao adicionar aos favoritos do Trakt" | |
| 2897 | ||
| 2898 | msgctxt "#90448" | |
| 2899 | msgid "Failed to remove from Trakt favorites" | |
| 2900 | msgstr "Falha ao remover dos favoritos do Trakt" | |
| 2901 | ||
| 2902 | msgctxt "#90449" | |
| 2903 | msgid "Created Trakt list" | |
| 2904 | msgstr "Lista do Trakt criada" | |
| 2905 | ||
| 2906 | msgctxt "#90450" | |
| 2907 | msgid "Failed to create Trakt list" | |
| 2908 | msgstr "Falha ao criar lista do Trakt" | |
| 2909 | ||
| 2910 | msgctxt "#90451" | |
| 2911 | msgid "Deleted Trakt list" | |
| 2912 | msgstr "Lista do Trakt eliminada" | |
| 2913 | ||
| 2914 | msgctxt "#90452" | |
| 2915 | msgid "Failed to delete Trakt list" | |
| 2916 | msgstr "Falha ao eliminar lista do Trakt" | |
| 2917 | ||
| 2918 | msgctxt "#90453" | |
| 2919 | msgid "Liked Trakt list" | |
| 2920 | msgstr "Gosto na lista do Trakt" | |
| 2921 | ||
| 2922 | msgctxt "#90454" | |
| 2923 | msgid "Failed to like Trakt list" | |
| 2924 | msgstr "Falha ao colocar gosto na lista do Trakt" | |
| 2925 | ||
| 2926 | msgctxt "#90455" | |
| 2927 | msgid "Unliked Trakt list" | |
| 2928 | msgstr "Gosto removido da lista do Trakt" | |
| 2929 | ||
| 2930 | msgctxt "#90456" | |
| 2931 | msgid "Failed to unlike Trakt list" | |
| 2932 | msgstr "Falha ao remover gosto da lista do Trakt" | |
| 2933 | ||
| 2934 | msgctxt "#90457" | |
| 2935 | msgid "Added to Trakt list" | |
| 2936 | msgstr "Adicionado à lista do Trakt" | |
| 2937 | ||
| 2938 | msgctxt "#90459" | |
| 2939 | msgid "Failed to add to Trakt list" | |
| 2940 | msgstr "Falha ao adicionar à lista do Trakt" | |
| 2941 | ||
| 2942 | msgctxt "#90461" | |
| 2943 | msgid "No Trakt lists found" | |
| 2944 | msgstr "Nenhuma lista do Trakt encontrada" | |
| 2945 | ||
| 2946 | msgctxt "#90462" | |
| 2947 | msgid "Item not found in Trakt collection" | |
| 2948 | msgstr "Item não encontrado na coleção do Trakt" | |
| 2949 | ||
| 2950 | msgctxt "#90463" | |
| 2951 | msgid "Trakt could not match this item for collection" | |
| 2952 | msgstr "O Trakt não conseguiu corresponder este item à coleção" | |
| 2953 | ||
| 2954 | msgctxt "#90464" | |
| 2955 | msgid "No Trakt collection changes were applied" | |
| 2956 | msgstr "Nenhuma alteração à coleção do Trakt foi aplicada" | |
| 2957 | ||
| 2958 | msgctxt "#90465" | |
| 2959 | msgid "Could not build Trakt collection payload for show" | |
| 2960 | msgstr "Não foi possível criar a payload da coleção do Trakt para a série" | |
| 2961 | ||
| 2962 | msgctxt "#90466" | |
| 2963 | msgid "Delete this Trakt list?" | |
| 2964 | msgstr "Eliminar esta lista do Trakt?" | |
| 2965 | ||
| 2966 | ||
| 2967 | msgctxt "#90510" | |
| 2968 | msgid "No meta available" | |
| 2969 | msgstr "Sem metadados" | |
| 2970 | ||
| 2971 | msgctxt "#90511" | |
| 2972 | msgid "No content available for" | |
| 2973 | msgstr "Sem conteúdo para" | |
| 2974 | ||
| 2975 | msgctxt "#90512" | |
| 2976 | msgid "Season" | |
| 2977 | msgstr "Temporada" | |
| 2978 | ||
| 2979 | msgctxt "#90513" | |
| 2980 | msgid "No episodes available" | |
| 2981 | msgstr "Sem episódios" | |
| 2982 | ||
| 2983 | msgctxt "#90514" | |
| 2984 | msgid "No videos available" | |
| 2985 | msgstr "Sem vídeos" | |
| 2986 | ||
| 2987 | msgctxt "#90515" | |
| 2988 | msgid "Next Page" | |
| 2989 | msgstr "Próxima página" | |
| 2990 | ||
| 2991 | msgctxt "#90516" | |
| 2992 | msgid "No metas available" | |
| 2993 | msgstr "Sem metadados disponíveis" | |
| 2994 | ||
| 2995 | msgctxt "#90517" | |
| 2996 | msgid "No seasons available" | |
| 2997 | msgstr "Sem temporadas disponíveis" | |
| 2998 | ||
| 2999 | msgctxt "#90518" | |
| 3000 | msgid "Invalid query" | |
| 3001 | msgstr "Consulta inválida" | |
| 3002 | ||
| 3003 | msgctxt "#90519" | |
| 3004 | msgid "No lists found" | |
| 3005 | msgstr "Nenhuma lista encontrada" | |
| 3006 | ||
| 3007 | msgctxt "#90520" | |
| 3008 | msgid "No user lists found" | |
| 3009 | msgstr "Nenhuma lista de utilizador encontrada" | |
| 3010 | ||
| 3011 | msgctxt "#90521" | |
| 3012 | msgid "No top lists found" | |
| 3013 | msgstr "Nenhuma lista principal encontrada" | |
| 3014 | ||
| 3015 | msgctxt "#90522" | |
| 3016 | msgid "Custom Addon" | |
| 3017 | msgstr "Addon personalizado" | |
| 3018 | ||
| 3019 | msgctxt "#90523" | |
| 3020 | msgid "Enter the custom Stremio addon URL" | |
| 3021 | msgstr "Introduza o URL do addon personalizado do Stremio" | |
| 3022 | ||
| 3023 | msgctxt "#90524" | |
| 3024 | msgid "No URL provided." | |
| 3025 | msgstr "Nenhum URL fornecido." | |
| 3026 | ||
| 3027 | msgctxt "#90525" | |
| 3028 | msgid "Failed to fetch manifest: %s" | |
| 3029 | msgstr "Falha ao obter o manifesto: %s" | |
| 3030 | ||
| 3031 | msgctxt "#90526" | |
| 3032 | msgid "Manifest missing 'id' or 'name'." | |
| 3033 | msgstr "Falta 'id' ou 'name' no manifesto." | |
| 3034 | ||
| 3035 | msgctxt "#90527" | |
| 3036 | msgid "Custom Stremio addon added successfully!" | |
| 3037 | msgstr "Addon personalizado do Stremio adicionado com sucesso!" | |
| 3038 | ||
| 3039 | msgctxt "#90528" | |
| 3040 | msgid "Addon does not provide 'stream' or 'catalog' resources." | |
| 3041 | msgstr "O addon nao fornece recursos 'stream' ou 'catalog'." | |
| 3042 | ||
| 3043 | msgctxt "#90529" | |
| 3044 | msgid "This addon is already added to your list." | |
| 3045 | msgstr "Este addon ja foi adicionado a sua lista." | |
| 3046 | ||
| 3047 | msgctxt "#90530" | |
| 3048 | msgid "Failed to add custom addon: %s" | |
| 3049 | msgstr "Falha ao adicionar addon personalizado: %s" | |
| 3050 | ||
| 3051 | msgctxt "#90531" | |
| 3052 | msgid "Remove Addon" | |
| 3053 | msgstr "Remover addon" | |
| 3054 | ||
| 3055 | msgctxt "#90532" | |
| 3056 | msgid "No custom Stremio addons to remove." | |
| 3057 | msgstr "Nao ha addons personalizados do Stremio para remover." | |
| 3058 | ||
| 3059 | msgctxt "#90533" | |
| 3060 | msgid "Remove Stremio Addons" | |
| 3061 | msgstr "Remover addons do Stremio" | |
| 3062 | ||
| 3063 | msgctxt "#90534" | |
| 3064 | msgid "Selected addon(s) removed." | |
| 3065 | msgstr "Addon(s) selecionado(s) removido(s)." | |
| 3066 | ||
| 3067 | msgctxt "#90535" | |
| 3068 | msgid "Untitled List" | |
| 3069 | msgstr "Lista sem titulo" | |
| 3070 | ||
| 3071 | msgctxt "#90536" | |
| 3072 | msgid "Removed from Trakt list" | |
| 3073 | msgstr "Removido da lista do Trakt" | |
| 3074 | ||
| 3075 | msgctxt "#90537" | |
| 3076 | msgid "Failed to remove from Trakt list" | |
| 3077 | msgstr "Falha ao remover da lista do Trakt" | |
| 3078 | ||
| 3079 | msgctxt "#90538" | |
| 3080 | msgid "Resume from %.1f%%" | |
| 3081 | msgstr "Retomar a partir de %.1f%%" | |
| 3082 | ||
| 3083 | msgctxt "#90539" | |
| 3084 | msgid "Start from Beginning" | |
| 3085 | msgstr "Iniciar do principio" | |
| 3086 | ||
| 3087 | msgctxt "#90540" | |
| 3088 | msgid "Authorize Debrid Services" | |
| 3089 | msgstr "Autorizar servicos Debrid" | |
| 3090 | ||
| 3091 | msgctxt "#90541" | |
| 3092 | msgid "Navigate to: [B]%s[/B]" | |
| 3093 | msgstr "Navegue para: [B]%s[/B]" | |
| 3094 | ||
| 3095 | msgctxt "#90542" | |
| 3096 | msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3097 | msgstr "Introduza o seguinte codigo: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3098 | ||
| 3099 | msgctxt "#90543" | |
| 3100 | msgid "Premiumize Auth" | |
| 3101 | msgstr "Autenticacao Premiumize" | |
| 3102 | ||
| 3103 | msgctxt "#90544" | |
| 3104 | msgid "Success" | |
| 3105 | msgstr "Sucesso" | |
| 3106 | ||
| 3107 | msgctxt "#90545" | |
| 3108 | msgid "Authentication completed." | |
| 3109 | msgstr "Autenticacao concluida." | |
| 3110 | ||
| 3111 | msgctxt "#90546" | |
| 3112 | msgid "Error:" | |
| 3113 | msgstr "Erro:" | |
| 3114 | ||
| 3115 | msgctxt "#90547" | |
| 3116 | msgid "Error: %s" | |
| 3117 | msgstr "Erro: %s" | |
| 3118 | ||
| 3119 | msgctxt "#90548" | |
| 3120 | msgid "Auth Error" | |
| 3121 | msgstr "Erro de autenticacao" | |
| 3122 | ||
| 3123 | msgctxt "#90549" | |
| 3124 | msgid "Torbox authentication successful." | |
| 3125 | msgstr "Autenticacao do Torbox concluida com sucesso." | |
| 3126 | ||
| 3127 | msgctxt "#90554" | |
| 3128 | msgid "Please wait..." | |
| 3129 | msgstr "Por favor, aguarde..." | |
| 3130 | ||
| 3131 | msgctxt "#90555" | |
| 3132 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3133 | msgstr "Vá para:\n[COLOR cyan]%s[/COLOR]\nIntroduza o codigo: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3134 | ||
| 3135 | msgctxt "#90556" | |
| 3136 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3137 | msgstr "Vá para:\n[COLOR cyan]%s[/COLOR]\n\nIntroduza o codigo: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3138 | ||
| 3139 | msgctxt "#90557" | |
| 3140 | msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]" | |
| 3141 | msgstr "Ligacao Pastebin:\n[COLOR cyan]%s[/COLOR]" | |
| 3142 | ||
| 3143 | msgctxt "#90670" | |
| 3144 | msgid "Local URL:\n[COLOR cyan]%s[/COLOR]" | |
| 3145 | msgstr "URL local:\n[COLOR cyan]%s[/COLOR]" | |
| 3146 | ||
| 3147 | msgctxt "#90558" | |
| 3148 | msgid "QR Code Authentication" | |
| 3149 | msgstr "Autenticacao por codigo QR" | |
| 3150 | ||
| 3151 | msgctxt "#90559" | |
| 3152 | msgid "Kodi 20 or above required. Please update Kodi to use this addon." | |
| 3153 | msgstr "E necessario Kodi 20 ou superior. Atualize o Kodi para usar este addon." | |
| 3154 | ||
| 3155 | msgctxt "#90560" | |
| 3156 | msgid "Failed to start web server. Port may be in use." | |
| 3157 | msgstr "Falha ao iniciar o servidor web. A porta pode estar em uso." | |
| 3158 | ||
| 3159 | msgctxt "#90561" | |
| 3160 | msgid "Authentification Removed." | |
| 3161 | msgstr "Autenticacao removida." | |
| 3162 | ||
| 3163 | msgctxt "#90562" | |
| 3164 | msgid "WebDAV Error" | |
| 3165 | msgstr "Erro WebDAV" | |
| 3166 | ||
| 3167 | msgctxt "#90563" | |
| 3168 | msgid "Trakt Authorization" | |
| 3169 | msgstr "Autorizacao Trakt" | |
| 3170 | ||
| 3171 | msgctxt "#90564" | |
| 3172 | msgid "Do you want to continue transfer in background?" | |
| 3173 | msgstr "Pretende continuar a transferencia em segundo plano?" | |
| 3174 | ||
| 3175 | msgctxt "#90565" | |
| 3176 | msgid "Cloud Transfer" | |
| 3177 | msgstr "Transferencia para a nuvem" | |
| 3178 | ||
| 3179 | msgctxt "#90566" | |
| 3180 | msgid "Kodi Logs" | |
| 3181 | msgstr "Registos do Kodi" | |
| 3182 | ||
| 3183 | msgctxt "#90567" | |
| 3184 | msgid "Show Logs" | |
| 3185 | msgstr "Mostrar registos" | |
| 3186 | ||
| 3187 | msgctxt "#90568" | |
| 3188 | msgid "Export to paste.kodi.tv" | |
| 3189 | msgstr "Exportar para paste.kodi.tv" | |
| 3190 | ||
| 3191 | msgctxt "#90569" | |
| 3192 | msgid "Kodi Logs Exported" | |
| 3193 | msgstr "Registos do Kodi exportados" | |
| 3194 | ||
| 3195 | msgctxt "#90570" | |
| 3196 | msgid "Providers Selection Dialog" | |
| 3197 | msgstr "Dialogo de selecao de fornecedores" | |
| 3198 | ||
| 3199 | msgctxt "#90571" | |
| 3200 | msgid "Your current Providers are: \n%s\n\nDo you want to change?" | |
| 3201 | msgstr "Os seus fornecedores atuais sao: \n%s\n\nPretende alterar?" | |
| 3202 | ||
| 3203 | msgctxt "#90572" | |
| 3204 | msgid "Select Providers" | |
| 3205 | msgstr "Selecionar fornecedores" | |
| 3206 | ||
| 3207 | msgctxt "#90573" | |
| 3208 | msgid "Selection Dialog" | |
| 3209 | msgstr "Dialogo de selecao" | |
| 3210 | ||
| 3211 | msgctxt "#90574" | |
| 3212 | msgid "Successfully selected: %s" | |
| 3213 | msgstr "Selecionado com sucesso: %s" | |
| 3214 | ||
| 3215 | msgctxt "#90575" | |
| 3216 | msgid "Selection" | |
| 3217 | msgstr "Selecao" | |
| 3218 | ||
| 3219 | msgctxt "#90576" | |
| 3220 | msgid "No providers selected" | |
| 3221 | msgstr "Nenhum fornecedor selecionado" | |
| 3222 | ||
| 3223 | msgctxt "#90577" | |
| 3224 | msgid "Changelog" | |
| 3225 | msgstr "Registo de alteracoes" | |
| 3226 | ||
| 3227 | msgctxt "#90578" | |
| 3228 | msgid "[B]Unable to check for updates[/B]" | |
| 3229 | msgstr "[B]Nao foi possivel verificar atualizacoes[/B]" | |
| 3230 | ||
| 3231 | msgctxt "#90579" | |
| 3232 | msgid "[B]No update available[/B]" | |
| 3233 | msgstr "[B]Nenhuma atualizacao disponivel[/B]" | |
| 3234 | ||
| 3235 | msgctxt "#90580" | |
| 3236 | msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3237 | msgstr "Instalado: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3238 | ||
| 3239 | msgctxt "#90581" | |
| 3240 | msgid "[B]Update available. Do you want to update?[/B]" | |
| 3241 | msgstr "[B]Atualizacao disponivel. Pretende atualizar?[/B]" | |
| 3242 | ||
| 3243 | msgctxt "#90582" | |
| 3244 | msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]" | |
| 3245 | msgstr "[B]Atualizacao disponivel (v%s).[/B] [I]Verifique as definicoes para atualizar.[/I]" | |
| 3246 | ||
| 3247 | msgctxt "#90583" | |
| 3248 | msgid "Error fetching versions: %s" | |
| 3249 | msgstr "Erro ao obter versoes: %s" | |
| 3250 | ||
| 3251 | msgctxt "#90584" | |
| 3252 | msgid "No older/different versions found." | |
| 3253 | msgstr "Nao foram encontradas versoes mais antigas ou diferentes." | |
| 3254 | ||
| 3255 | msgctxt "#90585" | |
| 3256 | msgid "Select Version to Downgrade" | |
| 3257 | msgstr "Selecione a versao para downgrade" | |
| 3258 | ||
| 3259 | msgctxt "#90586" | |
| 3260 | msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?" | |
| 3261 | msgstr "Tem a certeza de que pretende fazer downgrade ou reinstalar a versao [B]%s[/B]?" | |
| 3262 | ||
| 3263 | msgctxt "#90587" | |
| 3264 | msgid "[B]Downloading update...[/B]" | |
| 3265 | msgstr "[B]A transferir atualizacao...[/B]" | |
| 3266 | ||
| 3267 | msgctxt "#90588" | |
| 3268 | msgid "Error: Unable to download update." | |
| 3269 | msgstr "Erro: nao foi possivel transferir a atualizacao." | |
| 3270 | ||
| 3271 | msgctxt "#90589" | |
| 3272 | msgid "Error saving update file: %s" | |
| 3273 | msgstr "Erro ao guardar o ficheiro de atualizacao: %s" | |
| 3274 | ||
| 3275 | msgctxt "#90590" | |
| 3276 | msgid "Error extracting update. Please install manually." | |
| 3277 | msgstr "Erro ao extrair a atualizacao. Instale manualmente." | |
| 3278 | ||
| 3279 | msgctxt "#90591" | |
| 3280 | msgid "Do you want to view the changelog for the new version?" | |
| 3281 | msgstr "Pretende ver o registo de alteracoes da nova versao?" | |
| 3282 | ||
| 3283 | msgctxt "#90592" | |
| 3284 | msgid "New Release v%s - Changelog" | |
| 3285 | msgstr "Nova versao v%s - Registo de alteracoes" | |
| 3286 | ||
| 3287 | msgctxt "#90593" | |
| 3288 | msgid "[B]Update complete.[/B]" | |
| 3289 | msgstr "[B]Atualizacao concluida.[/B]" | |
| 3290 | ||
| 3291 | msgctxt "#90594" | |
| 3292 | msgid "Real Debrid Auth" | |
| 3293 | msgstr "Autenticacao Real Debrid" | |
| 3294 | ||
| 3295 | msgctxt "#90595" | |
| 3296 | msgid "Torbox Auth" | |
| 3297 | msgstr "Autenticacao Torbox" | |
| 3298 | ||
| 3299 | msgctxt "#90596" | |
| 3300 | msgid "Debrider Auth" | |
| 3301 | msgstr "Autenticacao Debrider" | |
| 3302 | ||
| 3303 | msgctxt "#90597" | |
| 3304 | msgid "Download Error" | |
| 3305 | msgstr "Erro de transferencia" | |
| 3306 | ||
| 3307 | msgctxt "#90598" | |
| 3308 | msgid "Failed to download text: %s" | |
| 3309 | msgstr "Falha ao transferir o texto: %s" | |
| 3310 | ||
| 3311 | msgctxt "#90599" | |
| 3312 | msgid "General Error" | |
| 3313 | msgstr "Erro geral" | |
| 3314 | ||
| 3315 | msgctxt "#90600" | |
| 3316 | msgid "Could not display text file." | |
| 3317 | msgstr "Nao foi possivel apresentar o ficheiro de texto." | |
| 3318 | ||
| 3319 | msgctxt "#90601" | |
| 3320 | msgid "Failed to export logs." | |
| 3321 | msgstr "Falha ao exportar os registos." | |
| 3322 | ||
| 3323 | msgctxt "#90602" | |
| 3324 | msgid "Error reading log: %s" | |
| 3325 | msgstr "Erro ao ler o registo: %s" | |
| 3326 | ||
| 3327 | msgctxt "#90603" | |
| 3328 | msgid "Kodi log file not found." | |
| 3329 | msgstr "Ficheiro de registo do Kodi nao encontrado." | |
| 3330 | ||
| 3331 | msgctxt "#90604" | |
| 3332 | msgid "%s Auth" | |
| 3333 | msgstr "Autenticacao de %s" | |
| 3334 | ||
| 3335 | msgctxt "#90605" | |
| 3336 | msgid "Ok" | |
| 3337 | msgstr "Ok" | |
| 3338 | ||
| 3339 | msgctxt "#90606" | |
| 3340 | msgid "No" | |
| 3341 | msgstr "Nao" | |
| 3342 | ||
| 3343 | msgctxt "#90607" | |
| 3344 | msgid "Pinging addons for availability..." | |
| 3345 | msgstr "A verificar disponibilidade dos addons..." | |
| 3346 | ||
| 3347 | msgctxt "#90608" | |
| 3348 | msgid "Pinging addons... %d/%d" | |
| 3349 | msgstr "A verificar addons... %d/%d" | |
| 3350 | ||
| 3351 | msgctxt "#90609" | |
| 3352 | msgid "Ping Results" | |
| 3353 | msgstr "Resultados do ping" | |
| 3354 | ||
| 3355 | msgctxt "#90610" | |
| 3356 | msgid "Reachable: %d\nUnreachable: %d" | |
| 3357 | msgstr "Acessiveis: %d\nInacessiveis: %d" | |
| 3358 | ||
| 3359 | msgctxt "#90611" | |
| 3360 | msgid "Select Category to Filter" | |
| 3361 | msgstr "Selecione a categoria a filtrar" | |
| 3362 | ||
| 3363 | msgctxt "#90612" | |
| 3364 | msgid "Stremio Add-ons Import" | |
| 3365 | msgstr "Importacao de addons do Stremio" | |
| 3366 | ||
| 3367 | msgctxt "#90613" | |
| 3368 | msgid "To import your add-ons, please log in with your Stremio email and password." | |
| 3369 | msgstr "Para importar os seus addons, inicie sessao com o email e a palavra-passe do Stremio." | |
| 3370 | ||
| 3371 | msgctxt "#90614" | |
| 3372 | msgid "Enter your Email" | |
| 3373 | msgstr "Introduza o seu email" | |
| 3374 | ||
| 3375 | msgctxt "#90615" | |
| 3376 | msgid "Enter your Password" | |
| 3377 | msgstr "Introduza a sua palavra-passe" | |
| 3378 | ||
| 3379 | msgctxt "#90616" | |
| 3380 | msgid "Login Failed" | |
| 3381 | msgstr "Falha no inicio de sessao" | |
| 3382 | ||
| 3383 | msgctxt "#90617" | |
| 3384 | msgid "Failed to login: %s" | |
| 3385 | msgstr "Falha ao iniciar sessao: %s" | |
| 3386 | ||
| 3387 | msgctxt "#90618" | |
| 3388 | msgid "Add-ons Import Failed" | |
| 3389 | msgstr "Falha na importacao de addons" | |
| 3390 | ||
| 3391 | msgctxt "#90619" | |
| 3392 | msgid "Please try again later and report the issue if the problem persists. For more details, check the log file." | |
| 3393 | msgstr "Tente novamente mais tarde e reporte o problema se persistir. Para mais detalhes, consulte o ficheiro de registo." | |
| 3394 | ||
| 3395 | msgctxt "#90620" | |
| 3396 | msgid "Addons Imported" | |
| 3397 | msgstr "Addons importados" | |
| 3398 | ||
| 3399 | msgctxt "#90621" | |
| 3400 | msgid "Successfully imported addons from your account." | |
| 3401 | msgstr "Os addons da sua conta foram importados com sucesso." | |
| 3402 | ||
| 3403 | msgctxt "#90622" | |
| 3404 | msgid "Update Stremio Addons" | |
| 3405 | msgstr "Atualizar addons do Stremio" | |
| 3406 | ||
| 3407 | msgctxt "#90623" | |
| 3408 | msgid "Do you want to update the Addons from your account?" | |
| 3409 | msgstr "Pretende atualizar os addons da sua conta?" | |
| 3410 | ||
| 3411 | msgctxt "#90624" | |
| 3412 | msgid "Log Out from Stremio" | |
| 3413 | msgstr "Terminar sessao no Stremio" | |
| 3414 | ||
| 3415 | msgctxt "#90625" | |
| 3416 | msgid "Are you sure you want to log out? You can continue using Stremio without logging in, but your settings will be reset to the default configuration." | |
| 3417 | msgstr "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 | ||
| 3419 | msgctxt "#90626" | |
| 3420 | msgid "Log Out" | |
| 3421 | msgstr "Terminar sessao" | |
| 3422 | ||
| 3423 | msgctxt "#90627" | |
| 3424 | msgid "Use Existing" | |
| 3425 | msgstr "Usar existentes" | |
| 3426 | ||
| 3427 | msgctxt "#90628" | |
| 3428 | msgid "Download New" | |
| 3429 | msgstr "Transferir novos" | |
| 3430 | ||
| 3431 | msgctxt "#90629" | |
| 3432 | msgid "Playback Failed" | |
| 3433 | msgstr "Falha na reproducao" | |
| 3434 | ||
| 3435 | msgctxt "#90630" | |
| 3436 | msgid "The video could not be played.\nDo you want to retry?" | |
| 3437 | msgstr "Nao foi possivel reproduzir o video.\nPretende tentar novamente?" | |
| 3438 | ||
| 3439 | msgctxt "#90631" | |
| 3440 | msgid "Retry" | |
| 3441 | msgstr "Tentar novamente" | |
| 3442 | ||
| 3443 | msgctxt "#90632" | |
| 3444 | msgid "Information" | |
| 3445 | msgstr "Informacao" | |
| 3446 | ||
| 3447 | msgctxt "#90633" | |
| 3448 | msgid "Review" | |
| 3449 | msgstr "Critica" | |
| 3450 | ||
| 3451 | msgctxt "#90634" | |
| 3452 | msgid "Comment" | |
| 3453 | msgstr "Comentario" | |
| 3454 | ||
| 3455 | msgctxt "#90635" | |
| 3456 | msgid "Trivia" | |
| 3457 | msgstr "Curiosidades" | |
| 3458 | ||
| 3459 | msgctxt "#90636" | |
| 3460 | msgid "Goof" | |
| 3461 | msgstr "Erro" | |
| 3462 | ||
| 3463 | msgctxt "#90637" | |
| 3464 | msgid "Parental Guide" | |
| 3465 | msgstr "Guia parental" | |
| 3466 | ||
| 3467 | msgctxt "#90638" | |
| 3468 | msgid "Converting Magnet...\n\n%s" | |
| 3469 | msgstr "A converter magnet...\n\n%s" | |
| 3470 | ||
| 3471 | msgctxt "#90639" | |
| 3472 | msgid "Real Debrid Error." | |
| 3473 | msgstr "Erro do Real Debrid." | |
| 3474 | ||
| 3475 | msgctxt "#90640" | |
| 3476 | msgid "File already cached" | |
| 3477 | msgstr "Ficheiro ja em cache" | |
| 3478 | ||
| 3479 | msgctxt "#90641" | |
| 3480 | msgid "Saving file to the Real Debrid Cloud" | |
| 3481 | msgstr "A guardar ficheiro na nuvem do Real Debrid" | |
| 3482 | ||
| 3483 | msgctxt "#90642" | |
| 3484 | msgid "File cached" | |
| 3485 | msgstr "Ficheiro em cache" | |
| 3486 | ||
| 3487 | msgctxt "#90643" | |
| 3488 | msgid "Saving File to the Real Debrid Cloud...\n%s\n\n" | |
| 3489 | msgstr "A guardar ficheiro na nuvem do Real Debrid...\n%s\n\n" | |
| 3490 | ||
| 3491 | msgctxt "#90644" | |
| 3492 | msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed" | |
| 3493 | msgstr "A transferir %s GB a %s mbps de %s peers, %s %% concluido" | |
| 3494 | ||
| 3495 | msgctxt "#90645" | |
| 3496 | msgid "Trakt" | |
| 3497 | msgstr "Trakt" | |
| 3498 | ||
| 3499 | msgctxt "#90651" | |
| 3500 | msgid "Easynews Account Info" | |
| 3501 | msgstr "Informacao da conta Easynews" | |
| 3502 | ||
| 3503 | msgctxt "#90652" | |
| 3504 | msgid "DeepL API Error" | |
| 3505 | msgstr "Erro da API DeepL" | |
| 3506 | ||
| 3507 | msgctxt "#90653" | |
| 3508 | msgid "Download" | |
| 3509 | msgstr "Transferencia" | |
| 3510 | ||
| 3511 | msgctxt "#90654" | |
| 3512 | msgid "Failed to start download: %s" | |
| 3513 | msgstr "Falha ao iniciar a transferencia: %s" | |
| 3514 | ||
| 3515 | msgctxt "#90655" | |
| 3516 | msgid "Modify Search" | |
| 3517 | msgstr "Modificar pesquisa" | |
| 3518 | ||
| 3519 | msgctxt "#90656" | |
| 3520 | msgid "Torbox" | |
| 3521 | msgstr "Torbox" | |
| 3522 | ||
| 3523 | msgctxt "#90657" | |
| 3524 | msgid "Easy-Debrid" | |
| 3525 | msgstr "Easy-Debrid" | |
| 3526 | ||
| 3527 | msgctxt "#90658" | |
| 3528 | msgid "All-Debrid" | |
| 3529 | msgstr "All-Debrid" | |
| 3530 | ||
| 3531 | msgctxt "#90659" | |
| 3532 | msgid "Real-Debrid" | |
| 3533 | msgstr "Real-Debrid" | |
| 3534 | ||
| 3535 | msgctxt "#90660" | |
| 3536 | msgid "Getting results from providers..." | |
| 3537 | msgstr "A obter resultados dos fornecedores..." | |
| 3538 | ||
| 3539 | msgctxt "#90661" | |
| 3540 | msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3541 | msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3542 | ||
| 3543 | msgctxt "#90662" | |
| 3544 | msgid "Downloads Viewer" | |
| 3545 | msgstr "Visualizador de transferencias" | |
| 3546 | ||
| 3547 | msgctxt "#90663" | |
| 3548 | msgid "WebDAV Text Viewer" | |
| 3549 | msgstr "Visualizador de texto WebDAV" | |
| 3550 | ||
| 3551 | msgctxt "#90664" | |
| 3552 | msgid "Selected Item" | |
| 3553 | msgstr "Item selecionado" | |
| 3554 | ||
| 3555 | msgctxt "#90665" | |
| 3556 | msgid "Subtitle No. %d" | |
| 3557 | msgstr "Legenda n.o %d" | |
| 3558 | ||
| 3559 | msgctxt "#90666" | |
| 3560 | msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?" | |
| 3561 | msgstr "Utilizacao gratuita do DeepL disponivel.\nCaracteres restantes na quota gratuita: %s\nCaracteres a traduzir: %s\n\nPretende continuar com a traducao?" | |
| 3562 | ||
| 3563 | msgctxt "#90667" | |
| 3564 | msgid "Free DeepL Usage" | |
| 3565 | msgstr "Utilizacao gratuita do DeepL" | |
| 3566 | ||
| 3567 | msgctxt "#90668" | |
| 3568 | msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?" | |
| 3569 | msgstr "Total de caracteres a traduzir: %s\nCusto estimado: $%.2f USD\n\nPretende continuar com a traducao?" | |
| 3570 | ||
| 3571 | msgctxt "#90669" | |
| 3572 | msgid "Translation Cost" | |
| 3573 | msgstr "Custo da traducao" | |
| 3574 | ||
| 3575 | msgctxt "#90672" | |
| 3576 | msgid "Play Trailer" | |
| 3577 | msgstr "Reproduzir trailer" | |
| 3578 | ||
| 3579 | msgctxt "#90673" | |
| 3580 | msgid "Trailer unavailable" | |
| 3581 | msgstr "Trailer indisponivel" | |
| 3582 | ||
| 3583 | msgctxt "#90690" | |
| 3584 | msgid "Clear All Items" | |
| 3585 | msgstr "Clear All Items" | |
| 3586 | ||
| 3587 | msgctxt "#90691" | |
| 3588 | msgid "Remove all items from this library?" | |
| 3589 | msgstr "Remove all items from this library?" | |
| 3590 | ||
| 3591 | msgctxt "#90692" | |
| 3592 | msgid "Library cleared" | |
| 3593 | msgstr "Library cleared" | |
| 3594 | ||
| 3595 | msgctxt "#90693" | |
| 3596 | msgid "No items to remove" | |
| 3597 | msgstr "No items to remove" | |
| 3598 | ||
| 3599 | msgctxt "#90694" | |
| 3600 | msgid "Added to %s cloud but still downloading. Please wait before playing." | |
| 3601 | msgstr "Adicionado a nuvem do %s mas ainda baixando. Por favor aguarde antes de reproduzir." | |
| 3602 | ||
| 3603 | msgctxt "#90695" | |
| 3604 | msgid "Torrent Not Ready" | |
| 3605 | msgstr "Torrent Não Pronto" | |
| 3606 | ||
| 3607 | msgctxt "#90696" | |
| 3608 | msgid "This torrent is still downloading in %s and is not ready yet." | |
| 3609 | msgstr "Este torrent ainda está baixando no %s e não está pronto ainda." | |
| 3610 | ||
| 3611 | msgctxt "#90697" | |
| 3612 | msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?" | |
| 3613 | msgstr "Deseja reproduzir usando o cliente torrent (pode ser mais lento) ou cancelar e esperar o debrid terminar?" | |
| 3614 | ||
| 3615 | msgctxt "#90698" | |
| 3616 | msgid "Play with Torrent" | |
| 3617 | msgstr "Reproduzir com Torrent" | |
| 3618 | ||
| 3619 | msgctxt "#90699" | |
| 3620 | msgid "Cancel & Wait" | |
| 3621 | msgstr "Cancelar e Esperar" | |
| 3622 | ||
| 3623 | msgctxt "#90742" | |
| 3624 | msgid "Addon Settings" | |
| 3625 | msgstr "Configurações do addon" | |
| 3626 | ||
| 3627 | msgctxt "#90743" | |
| 3628 | msgid "Set Views" | |
| 3629 | msgstr "Definir visualizações" | |
| 3630 | ||
| 3631 | msgctxt "#90744" | |
| 3632 | msgid "Upload Subtitle" | |
| 3633 | msgstr "Carregar legendas" | |
| 3634 | ||
| 3635 | msgctxt "#90745" | |
| 3636 | msgid "Select Subtitle File" | |
| 3637 | msgstr "Selecionar ficheiro de legendas" | |
| 3638 | ||
| 3639 | msgctxt "#90746" | |
| 3640 | msgid "Local File" | |
| 3641 | msgstr "Ficheiro local" | |
| 3642 | ||
| 3643 | msgctxt "#90747" | |
| 3644 | msgid "Upload from Browser" | |
| 3645 | msgstr "Carregar do navegador" | |
| 3646 | ||
| 3647 | msgctxt "#90748" | |
| 3648 | msgid "Upload subtitle from your browser" | |
| 3649 | msgstr "Carregar legenda do seu navegador" | |
| 3650 | ||
| 3651 | msgctxt "#90749" | |
| 3652 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" | |
| 3653 | msgstr "Digitalize o código QR ou abra o URL (%s) no seu navegador para carregar um ficheiro de legendas" | |
| 3654 | ||
| 3655 | msgctxt "#90750" | |
| 3656 | msgid "Search Title Language" | |
| 3657 | msgstr "Idioma do título de pesquisa" | |
| 3658 | ||
| 3659 | msgctxt "#90751" | |
| 3660 | msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches." | |
| 3661 | msgstr "Controla como as pesquisas por título de Easynews/Jackett/Prowlarr são priorizadas nas pesquisas de contexto." | |
| 3662 | ||
| 3663 | msgctxt "#90752" | |
| 3664 | msgid "English first" | |
| 3665 | msgstr "Inglês primeiro" | |
| 3666 | ||
| 3667 | msgctxt "#90753" | |
| 3668 | msgid "English only" | |
| 3669 | msgstr "Apenas inglês" | |
| 3670 | ||
| 3671 | msgctxt "#90754" | |
| 3672 | msgid "Localized first" | |
| 3673 | msgstr "Localizado primeiro" |
| … | … | @@ -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 | # | |
| 6 | msgid "" | |
| 7 | msgstr "" | |
| 8 | "Report-Msgid-Bugs-To: \n" | |
| 9 | "POT-Creation-Date: 2023-09-19 19:53+0200\n" | |
| 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | |
| 11 | "Language: 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 | ||
| 18 | msgctxt "#30000" | |
| 19 | msgid "Players" | |
| 20 | msgstr "Плееры" | |
| 21 | ||
| 22 | msgctxt "#30001" | |
| 23 | msgid "Clients" | |
| 24 | msgstr "Клиенты" | |
| 25 | ||
| 26 | msgctxt "#30002" | |
| 27 | msgid "Torrent Client" | |
| 28 | msgstr "Торрент-клиент" | |
| 29 | ||
| 30 | msgctxt "#30020" | |
| 31 | msgid "Indexers" | |
| 32 | msgstr "Индексаторы" | |
| 33 | ||
| 34 | msgctxt "#30021" | |
| 35 | msgid "Indexer" | |
| 36 | msgstr "Индексатор" | |
| 37 | ||
| 38 | msgctxt "#30022" | |
| 39 | msgid "Jackett Configuration:" | |
| 40 | msgstr "Настройка Jackett:" | |
| 41 | ||
| 42 | msgctxt "#30023" | |
| 43 | msgid "Client Key" | |
| 44 | msgstr "Ключ клиента" | |
| 45 | ||
| 46 | msgctxt "#30025" | |
| 47 | msgid "Host" | |
| 48 | msgstr "Хост" | |
| 49 | ||
| 50 | msgctxt "#30026" | |
| 51 | msgid "Description length" | |
| 52 | msgstr "Длина описания" | |
| 53 | ||
| 54 | msgctxt "#30027" | |
| 55 | msgid "Insecured connection" | |
| 56 | msgstr "Небезопасное соединение" | |
| 57 | ||
| 58 | msgctxt "#30028" | |
| 59 | msgid "Sort by" | |
| 60 | msgstr "Сортировать по" | |
| 61 | ||
| 62 | msgctxt "#30029" | |
| 63 | msgid "Total Results" | |
| 64 | msgstr "Всего результатов" | |
| 65 | ||
| 66 | msgctxt "#30030" | |
| 67 | msgid "Timeout" | |
| 68 | msgstr "Тайм-аут" | |
| 69 | ||
| 70 | msgctxt "#30031" | |
| 71 | msgid "Timeout" | |
| 72 | msgstr "Тайм-аут" | |
| 73 | ||
| 74 | msgctxt "#30050" | |
| 75 | msgid "Prowlarr Configuration:" | |
| 76 | msgstr "Настройка Prowlarr:" | |
| 77 | ||
| 78 | msgctxt "#30051" | |
| 79 | msgid "Client Key" | |
| 80 | msgstr "Ключ клиента" | |
| 81 | ||
| 82 | msgctxt "#30052" | |
| 83 | msgid "Host" | |
| 84 | msgstr "Хост" | |
| 85 | ||
| 86 | msgctxt "#30053" | |
| 87 | msgid "Indexers-Ids" | |
| 88 | msgstr "ID индексаторов" | |
| 89 | ||
| 90 | msgctxt "#30054" | |
| 91 | msgid "Anime IndexersIds" | |
| 92 | msgstr "ID индексаторов аниме" | |
| 93 | ||
| 94 | msgctxt "#30100" | |
| 95 | msgid "TMDB" | |
| 96 | msgstr "TMDB" | |
| 97 | ||
| 98 | msgctxt "#30102" | |
| 99 | msgid "TMDB Configuration" | |
| 100 | msgstr "Настройка TMDB" | |
| 101 | ||
| 102 | msgctxt "#30103" | |
| 103 | msgid "Client Key" | |
| 104 | msgstr "Ключ клиента" | |
| 105 | ||
| 106 | msgctxt "#30104" | |
| 107 | msgid "Clear tmdb cache" | |
| 108 | msgstr "Очистить кэш TMDB" | |
| 109 | ||
| 110 | msgctxt "#30105" | |
| 111 | msgid "Cache expiration hours" | |
| 112 | msgstr "Часы истечения кэша" | |
| 113 | ||
| 114 | msgctxt "#30106" | |
| 115 | msgid "Clear Cache" | |
| 116 | msgstr "Очистить кэш" | |
| 117 | ||
| 118 | msgctxt "#30107" | |
| 119 | msgid "Enable Cache" | |
| 120 | msgstr "Включить кэш" | |
| 121 | ||
| 122 | msgctxt "#30140" | |
| 123 | msgid "AniList" | |
| 124 | msgstr "AniList" | |
| 125 | ||
| 126 | msgctxt "#30142" | |
| 127 | msgid "AniList Configuration:" | |
| 128 | msgstr "Настройка AniList:" | |
| 129 | ||
| 130 | msgctxt "#30143" | |
| 131 | msgid "Client ID" | |
| 132 | msgstr "ID клиента" | |
| 133 | ||
| 134 | msgctxt "#30144" | |
| 135 | msgid "Client Secret" | |
| 136 | msgstr "Секрет клиента" | |
| 137 | ||
| 138 | msgctxt "#30150" | |
| 139 | msgid "Services" | |
| 140 | msgstr "Сервисы" | |
| 141 | ||
| 142 | msgctxt "#30152" | |
| 143 | msgid "Real Debrid Configuration" | |
| 144 | msgstr "Настройка Real Debrid" | |
| 145 | ||
| 146 | msgctxt "#30154" | |
| 147 | msgid "Token" | |
| 148 | msgstr "Токен" | |
| 149 | ||
| 150 | msgctxt "#30200" | |
| 151 | msgid "Fanart.TV" | |
| 152 | msgstr "Fanart.TV" | |
| 153 | ||
| 154 | msgctxt "#30202" | |
| 155 | msgid "Fanart.TV Configuration:" | |
| 156 | msgstr "Настройка Fanart.TV:" | |
| 157 | ||
| 158 | msgctxt "#30203" | |
| 159 | msgid "Client ID" | |
| 160 | msgstr "ID клиента" | |
| 161 | ||
| 162 | msgctxt "#30221" | |
| 163 | msgid "No Client Key provided for" | |
| 164 | msgstr "Не предоставлен ключ клиента для" | |
| 165 | ||
| 166 | msgctxt "#30223" | |
| 167 | msgid "You need to configure" | |
| 168 | msgstr "Вам нужно настроить" | |
| 169 | ||
| 170 | msgctxt "#30228" | |
| 171 | msgid "The request to Torrentio failed." | |
| 172 | msgstr "Запрос к Torrentio не удался." | |
| 173 | ||
| 174 | msgctxt "#30229" | |
| 175 | msgid "The request to Jackett failed." | |
| 176 | msgstr "Запрос к Jackett не удался." | |
| 177 | ||
| 178 | msgctxt "#30230" | |
| 179 | msgid "The request to Prowlarr failed." | |
| 180 | msgstr "Запрос к Prowlarr не удался." | |
| 181 | ||
| 182 | msgctxt "#30231" | |
| 183 | msgid "The request to Elhosted failed." | |
| 184 | msgstr "Запрос к Elhosted не удался." | |
| 185 | ||
| 186 | msgctxt "#30232" | |
| 187 | msgid "The request to Jackgram failed." | |
| 188 | msgstr "Запрос к Jackgram не удался." | |
| 189 | ||
| 190 | msgctxt "#30233" | |
| 191 | msgid "The request to Mediafusion failed." | |
| 192 | msgstr "Запрос к Mediafusion не удался." | |
| 193 | ||
| 194 | msgctxt "#30234" | |
| 195 | msgid "The request to Peerflix failed." | |
| 196 | msgstr "Запрос к Peerflix не удался." | |
| 197 | ||
| 198 | msgctxt "#30235" | |
| 199 | msgid "Stremio" | |
| 200 | msgstr "Stremio" | |
| 201 | ||
| 202 | msgctxt "#30236" | |
| 203 | msgid "Stremio add-ons" | |
| 204 | msgstr "Аддоны Stremio" | |
| 205 | ||
| 206 | msgctxt "#30237" | |
| 207 | msgid "Import your config" | |
| 208 | msgstr "Импортировать конфигурацию" | |
| 209 | ||
| 210 | msgctxt "#30238" | |
| 211 | msgid "Manage add-ons" | |
| 212 | msgstr "Управление аддонами" | |
| 213 | ||
| 214 | msgctxt "#30240" | |
| 215 | msgid "TMDB cache cleared" | |
| 216 | msgstr "Кэш TMDB очищен" | |
| 217 | ||
| 218 | msgctxt "#30241" | |
| 219 | msgid "Search on TMDB:" | |
| 220 | msgstr "Поиск на TMDB:" | |
| 221 | ||
| 222 | msgctxt "#30242" | |
| 223 | msgid "Search on AniList:" | |
| 224 | msgstr "Поиск на AniList:" | |
| 225 | ||
| 226 | msgctxt "#30243" | |
| 227 | msgid "Search for a keyword:" | |
| 228 | msgstr "Поиск по ключевому слову:" | |
| 229 | ||
| 230 | msgctxt "#30244" | |
| 231 | msgid "Cache cleared" | |
| 232 | msgstr "Кэш очищен" | |
| 233 | ||
| 234 | msgctxt "#30249" | |
| 235 | msgid "Addon Jacktook Burst not found - (script.jacktook.burst)" | |
| 236 | msgstr "Надстройка Jacktook Burst не найдена - (script.jacktook.burst)" | |
| 237 | ||
| 238 | msgctxt "#30250" | |
| 239 | msgid "Addon Torrest not found - (plugin.video.torrest)" | |
| 240 | msgstr "Аддон Torrest не найден - (plugin.video.torrest)" | |
| 241 | ||
| 242 | msgctxt "#30251" | |
| 243 | msgid "No sources found to play" | |
| 244 | msgstr "Источники для воспроизведения не найдены" | |
| 245 | ||
| 246 | msgctxt "#30252" | |
| 247 | msgid "Addon Elementum not found - (plugin.video.elementum)" | |
| 248 | msgstr "Аддон Elementum не найден - (plugin.video.elementum)" | |
| 249 | ||
| 250 | msgctxt "#30253" | |
| 251 | msgid "Addon Jacktorr not found - (plugin.video.jacktorr)" | |
| 252 | msgstr "Аддон Jacktorr не найден - (plugin.video.jacktorr)" | |
| 253 | ||
| 254 | msgctxt "#30254" | |
| 255 | msgid "Do you want to install Elementum?" | |
| 256 | msgstr "Вы хотите установить Elementum?" | |
| 257 | ||
| 258 | msgctxt "#30255" | |
| 259 | msgid "Do you want to install Jacktorr?" | |
| 260 | msgstr "Вы хотите установить Jacktorr?" | |
| 261 | ||
| 262 | msgctxt "#30256" | |
| 263 | msgid "Do you want to install Torrest?" | |
| 264 | msgstr "Вы хотите установить Torrest?" | |
| 265 | ||
| 266 | msgctxt "#30257" | |
| 267 | msgid "Do you want to install Jacktook Burst?" | |
| 268 | msgstr "Вы хотите установить Jacktook Burst?" | |
| 269 | ||
| 270 | msgctxt "#30650" | |
| 271 | msgid "Queued" | |
| 272 | msgstr "В очереди" | |
| 273 | ||
| 274 | msgctxt "#30651" | |
| 275 | msgid "Checking" | |
| 276 | msgstr "Проверка" | |
| 277 | ||
| 278 | msgctxt "#30652" | |
| 279 | msgid "Finding" | |
| 280 | msgstr "Поиск" | |
| 281 | ||
| 282 | msgctxt "#30653" | |
| 283 | msgid "Downloading" | |
| 284 | msgstr "Загрузка" | |
| 285 | ||
| 286 | msgctxt "#30654" | |
| 287 | msgid "Finished" | |
| 288 | msgstr "Завершено" | |
| 289 | ||
| 290 | msgctxt "#30655" | |
| 291 | msgid "Seeding" | |
| 292 | msgstr "Раздача" | |
| 293 | ||
| 294 | msgctxt "#30656" | |
| 295 | msgid "Allocating" | |
| 296 | msgstr "Выделение" | |
| 297 | ||
| 298 | msgctxt "#30657" | |
| 299 | msgid "Checking resume data" | |
| 300 | msgstr "Проверка данных возобновления" | |
| 301 | ||
| 302 | msgctxt "#30658" | |
| 303 | msgid "Paused" | |
| 304 | msgstr "Пауза" | |
| 305 | ||
| 306 | msgctxt "#30659" | |
| 307 | msgid "Buffering" | |
| 308 | msgstr "Буферизация" | |
| 309 | ||
| 310 | msgctxt "#30660" | |
| 311 | msgid "Unknown" | |
| 312 | msgstr "Неизвестно" | |
| 313 | ||
| 314 | msgctxt "#30700" | |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "Буферизация и воспроизведение" | |
| 317 | ||
| 318 | msgctxt "#30701" | |
| 319 | msgid "Stop downloading" | |
| 320 | msgstr "Остановить загрузку" | |
| 321 | ||
| 322 | msgctxt "#30702" | |
| 323 | msgid "Downloads" | |
| 324 | msgstr "Загрузки" | |
| 325 | ||
| 326 | msgctxt "#30703" | |
| 327 | msgid "Resume" | |
| 328 | msgstr "Возобновить" | |
| 329 | ||
| 330 | msgctxt "#30704" | |
| 331 | msgid "Pause" | |
| 332 | msgstr "Пауза" | |
| 333 | ||
| 334 | msgctxt "#30705" | |
| 335 | msgid "Remove torrent" | |
| 336 | msgstr "Удалить торрент" | |
| 337 | ||
| 338 | msgctxt "#30706" | |
| 339 | msgid "Remove torrent and files" | |
| 340 | msgstr "Удалить торрент и файлы" | |
| 341 | ||
| 342 | msgctxt "#30707" | |
| 343 | msgid "Torrent status" | |
| 344 | msgstr "Статус торрента" | |
| 345 | ||
| 346 | msgctxt "#30708" | |
| 347 | msgid "Stop" | |
| 348 | msgstr "Стоп" | |
| 349 | ||
| 350 | msgctxt "#30709" | |
| 351 | msgid "Drop torrent" | |
| 352 | msgstr "Сбросить торрент" | |
| 353 | ||
| 354 | msgctxt "#30710" | |
| 355 | msgid "Include Season Packs" | |
| 356 | msgstr "Включить паки сезонов" | |
| 357 | ||
| 358 | msgctxt "#30711" | |
| 359 | msgid "Torrent Settings" | |
| 360 | msgstr "Настройки торрента" | |
| 361 | ||
| 362 | msgctxt "#30712" | |
| 363 | msgid "General" | |
| 364 | msgstr "Общие" | |
| 365 | ||
| 366 | msgctxt "#30713" | |
| 367 | msgid "Enable" | |
| 368 | msgstr "Включить" | |
| 369 | ||
| 370 | msgctxt "#30714" | |
| 371 | msgid "Jacktorr" | |
| 372 | msgstr "Jacktorr" | |
| 373 | ||
| 374 | msgctxt "#30715" | |
| 375 | msgid "Torrest" | |
| 376 | msgstr "Torrest" | |
| 377 | ||
| 378 | msgctxt "#30716" | |
| 379 | msgid "Elementum" | |
| 380 | msgstr "Elementum" | |
| 381 | ||
| 382 | msgctxt "#30717" | |
| 383 | msgid "General" | |
| 384 | msgstr "Общие" | |
| 385 | ||
| 386 | msgctxt "#30718" | |
| 387 | msgid "Auto-Play" | |
| 388 | msgstr "Автовоспроизведение" | |
| 389 | ||
| 390 | msgctxt "#30719" | |
| 391 | msgid "Enable Next Episode Dialog" | |
| 392 | msgstr "Включить диалог следующей серии" | |
| 393 | ||
| 394 | msgctxt "#30720" | |
| 395 | msgid "Seconds left before show Next Episode Dialog" | |
| 396 | msgstr "Секунд до показа диалога следующей серии" | |
| 397 | ||
| 398 | msgctxt "#30721" | |
| 399 | msgid "Cache Enabled" | |
| 400 | msgstr "Кэш включен" | |
| 401 | ||
| 402 | msgctxt "#30722" | |
| 403 | msgid "Cache Expiration (hours)" | |
| 404 | msgstr "Истечение кэша (часы)" | |
| 405 | ||
| 406 | msgctxt "#30723" | |
| 407 | msgid "Cache" | |
| 408 | msgstr "Кэш" | |
| 409 | ||
| 410 | msgctxt "#30724" | |
| 411 | msgid "Update" | |
| 412 | msgstr "Обновить" | |
| 413 | ||
| 414 | msgctxt "#30725" | |
| 415 | msgid "Indexers Configuration" | |
| 416 | msgstr "Настройка индексаторов" | |
| 417 | ||
| 418 | msgctxt "#30726" | |
| 419 | msgid "Sort" | |
| 420 | msgstr "Сортировка" | |
| 421 | ||
| 422 | msgctxt "#30727" | |
| 423 | msgid "Quality" | |
| 424 | msgstr "Качество" | |
| 425 | ||
| 426 | msgctxt "#30938" | |
| 427 | msgid "Silent Resume" | |
| 428 | msgstr "Тихий возврат" | |
| 429 | ||
| 430 | msgctxt "#30939" | |
| 431 | msgid "Auto-resume from last position without asking" | |
| 432 | msgstr "Автоматически возобновлять с последней позиции без вопросов" | |
| 433 | ||
| 434 | msgctxt "#30728" | |
| 435 | msgid "Seeds" | |
| 436 | msgstr "Сиды" | |
| 437 | ||
| 438 | msgctxt "#30729" | |
| 439 | msgid "Size" | |
| 440 | msgstr "Размер" | |
| 441 | ||
| 442 | msgctxt "#30730" | |
| 443 | msgid "Cached" | |
| 444 | msgstr "В кэше" | |
| 445 | ||
| 446 | msgctxt "#30731" | |
| 447 | msgid "Date" | |
| 448 | msgstr "Дата" | |
| 449 | ||
| 450 | msgctxt "#30732" | |
| 451 | msgid "None" | |
| 452 | msgstr "Нет" | |
| 453 | ||
| 454 | msgctxt "#30733" | |
| 455 | msgid "Torrentio Configuration" | |
| 456 | msgstr "Настройка Torrentio" | |
| 457 | ||
| 458 | msgctxt "#30734" | |
| 459 | msgid "Enable" | |
| 460 | msgstr "Включить" | |
| 461 | ||
| 462 | msgctxt "#30736" | |
| 463 | msgid "Subtitle Language" | |
| 464 | msgstr "Язык субтитров" | |
| 465 | ||
| 466 | msgctxt "#30737" | |
| 467 | msgid "GB" | |
| 468 | msgstr "Английский" | |
| 469 | ||
| 470 | msgctxt "#30738" | |
| 471 | msgid "JP" | |
| 472 | msgstr "Японский" | |
| 473 | ||
| 474 | msgctxt "#30739" | |
| 475 | msgid "RU" | |
| 476 | msgstr "Русский" | |
| 477 | ||
| 478 | msgctxt "#30740" | |
| 479 | msgid "IT" | |
| 480 | msgstr "Итальянский" | |
| 481 | ||
| 482 | msgctxt "#30741" | |
| 483 | msgid "PT" | |
| 484 | msgstr "Португальский" | |
| 485 | ||
| 486 | msgctxt "#30742" | |
| 487 | msgid "ES" | |
| 488 | msgstr "Испанский" | |
| 489 | ||
| 490 | msgctxt "#30743" | |
| 491 | msgid "MX" | |
| 492 | msgstr "Мексиканский" | |
| 493 | ||
| 494 | msgctxt "#30744" | |
| 495 | msgid "KR" | |
| 496 | msgstr "Корейский" | |
| 497 | ||
| 498 | msgctxt "#30745" | |
| 499 | msgid "CN" | |
| 500 | msgstr "Китайский" | |
| 501 | ||
| 502 | msgctxt "#30746" | |
| 503 | msgid "TW" | |
| 504 | msgstr "Тайваньский" | |
| 505 | ||
| 506 | msgctxt "#30747" | |
| 507 | msgid "FR" | |
| 508 | msgstr "Французский" | |
| 509 | ||
| 510 | msgctxt "#30748" | |
| 511 | msgid "DE" | |
| 512 | msgstr "Немецкий" | |
| 513 | ||
| 514 | msgctxt "#30749" | |
| 515 | msgid "NL" | |
| 516 | msgstr "Голландский" | |
| 517 | ||
| 518 | msgctxt "#30750" | |
| 519 | msgid "IN" | |
| 520 | msgstr "Индийский" | |
| 521 | ||
| 522 | msgctxt "#30751" | |
| 523 | msgid "PL" | |
| 524 | msgstr "Польский" | |
| 525 | ||
| 526 | msgctxt "#30752" | |
| 527 | msgid "LT" | |
| 528 | msgstr "Литовский" | |
| 529 | ||
| 530 | msgctxt "#30753" | |
| 531 | msgid "CZ" | |
| 532 | msgstr "Чешский" | |
| 533 | ||
| 534 | msgctxt "#30754" | |
| 535 | msgid "SK" | |
| 536 | msgstr "Словацкий" | |
| 537 | ||
| 538 | msgctxt "#30755" | |
| 539 | msgid "SI" | |
| 540 | msgstr "Словенский" | |
| 541 | ||
| 542 | msgctxt "#30756" | |
| 543 | msgid "HU" | |
| 544 | msgstr "Венгерский" | |
| 545 | ||
| 546 | msgctxt "#30757" | |
| 547 | msgid "RO" | |
| 548 | msgstr "Румынский" | |
| 549 | ||
| 550 | msgctxt "#30758" | |
| 551 | msgid "HR" | |
| 552 | msgstr "Хорватский" | |
| 553 | ||
| 554 | msgctxt "#30759" | |
| 555 | msgid "UA" | |
| 556 | msgstr "Украинский" | |
| 557 | ||
| 558 | msgctxt "#30760" | |
| 559 | msgid "GR" | |
| 560 | msgstr "Греческий" | |
| 561 | ||
| 562 | msgctxt "#30761" | |
| 563 | msgid "DK" | |
| 564 | msgstr "Датский" | |
| 565 | ||
| 566 | msgctxt "#30762" | |
| 567 | msgid "FI" | |
| 568 | msgstr "Финский" | |
| 569 | ||
| 570 | msgctxt "#30763" | |
| 571 | msgid "SE" | |
| 572 | msgstr "Шведский" | |
| 573 | ||
| 574 | msgctxt "#30764" | |
| 575 | msgid "NO" | |
| 576 | msgstr "Норвежский" | |
| 577 | ||
| 578 | msgctxt "#30765" | |
| 579 | msgid "TR" | |
| 580 | msgstr "Турецкий" | |
| 581 | ||
| 582 | msgctxt "#30766" | |
| 583 | msgid "Torrentio Providers" | |
| 584 | msgstr "Провайдеры Torrentio" | |
| 585 | ||
| 586 | msgctxt "#30767" | |
| 587 | msgid "Jackgram Configuration" | |
| 588 | msgstr "Настройка Jackgram" | |
| 589 | ||
| 590 | msgctxt "#30768" | |
| 591 | msgid "Host" | |
| 592 | msgstr "Хост" | |
| 593 | ||
| 594 | msgctxt "#30769" | |
| 595 | msgid "Elfhosted/KnightCrawler Configuration" | |
| 596 | msgstr "Настройка Elfhosted/KnightCrawler" | |
| 597 | ||
| 598 | msgctxt "#30770" | |
| 599 | msgid "Language" | |
| 600 | msgstr "Язык" | |
| 601 | ||
| 602 | msgctxt "#30771" | |
| 603 | msgid "Zilean Configuration" | |
| 604 | msgstr "Настройка Zilean" | |
| 605 | ||
| 606 | msgctxt "#30772" | |
| 607 | msgid "Zilean Host" | |
| 608 | msgstr "Хост Zilean" | |
| 609 | ||
| 610 | msgctxt "#30774" | |
| 611 | msgid "Zilean Timeout" | |
| 612 | msgstr "Тайм-аут Zilean" | |
| 613 | ||
| 614 | msgctxt "#30775" | |
| 615 | msgid "Jackett Configuration" | |
| 616 | msgstr "Настройка Jackett" | |
| 617 | ||
| 618 | msgctxt "#30776" | |
| 619 | msgid "Prowlarr Configuration" | |
| 620 | msgstr "Настройка Prowlarr" | |
| 621 | ||
| 622 | msgctxt "#30777" | |
| 623 | msgid "MediaFusion configuration" | |
| 624 | msgstr "Настройка MediaFusion" | |
| 625 | ||
| 626 | msgctxt "#30779" | |
| 627 | msgid "Manifest Url" | |
| 628 | msgstr "URL манифеста" | |
| 629 | ||
| 630 | msgctxt "#30780" | |
| 631 | msgid "Jacktook Burst Configuration" | |
| 632 | msgstr "Настройка Jacktook Burst" | |
| 633 | ||
| 634 | msgctxt "#30781" | |
| 635 | msgid "Burst Configuration" | |
| 636 | msgstr "Настройка Burst" | |
| 637 | ||
| 638 | msgctxt "#30782" | |
| 639 | msgid "User" | |
| 640 | msgstr "Пользователь" | |
| 641 | ||
| 642 | msgctxt "#30783" | |
| 643 | msgid "Authorize" | |
| 644 | msgstr "Авторизовать" | |
| 645 | ||
| 646 | msgctxt "#30784" | |
| 647 | msgid "Remove Authorization" | |
| 648 | msgstr "Удалить авторизацию" | |
| 649 | ||
| 650 | msgctxt "#30785" | |
| 651 | msgid "Premiumize Configuration" | |
| 652 | msgstr "Настройка Premiumize" | |
| 653 | ||
| 654 | msgctxt "#30786" | |
| 655 | msgid "Enabled" | |
| 656 | msgstr "Включено" | |
| 657 | ||
| 658 | msgctxt "#30787" | |
| 659 | msgid "Token" | |
| 660 | msgstr "Токен" | |
| 661 | ||
| 662 | msgctxt "#30788" | |
| 663 | msgid "TorBox Configuration" | |
| 664 | msgstr "Настройка TorBox" | |
| 665 | ||
| 666 | msgctxt "#30789" | |
| 667 | msgid "Debrider Configuration" | |
| 668 | msgstr "Настройка Debrider" | |
| 669 | ||
| 670 | msgctxt "#30790" | |
| 671 | msgid "Uncached" | |
| 672 | msgstr "Не в кэше" | |
| 673 | ||
| 674 | msgctxt "#30791" | |
| 675 | msgid "Show uncached torrents" | |
| 676 | msgstr "Показывать торренты не в кэше" | |
| 677 | ||
| 678 | msgctxt "#30792" | |
| 679 | msgid "Use Kodi Language" | |
| 680 | msgstr "Использовать язык Kodi" | |
| 681 | ||
| 682 | msgctxt "#30793" | |
| 683 | msgid "Trakt" | |
| 684 | msgstr "Trakt" | |
| 685 | ||
| 686 | msgctxt "#30794" | |
| 687 | msgid "Trakt Configuration" | |
| 688 | msgstr "Настройка Trakt" | |
| 689 | ||
| 690 | msgctxt "#30795" | |
| 691 | msgid "Expires" | |
| 692 | msgstr "Истекает" | |
| 693 | ||
| 694 | msgctxt "#30796" | |
| 695 | msgid "Refresh" | |
| 696 | msgstr "Обновить" | |
| 697 | ||
| 698 | msgctxt "#30797" | |
| 699 | msgid "Token" | |
| 700 | msgstr "Токен" | |
| 701 | ||
| 702 | msgctxt "#30798" | |
| 703 | msgid "Trakt Client" | |
| 704 | msgstr "Клиент Trakt" | |
| 705 | ||
| 706 | msgctxt "#30800" | |
| 707 | msgid "Trakt Secret" | |
| 708 | msgstr "Секрет Trakt" | |
| 709 | ||
| 710 | msgctxt "#30802" | |
| 711 | msgid "Log out" | |
| 712 | msgstr "Выйти" | |
| 713 | ||
| 714 | msgctxt "#30804" | |
| 715 | msgid "Menus" | |
| 716 | msgstr "Меню" | |
| 717 | ||
| 718 | msgctxt "#30805" | |
| 719 | msgid "Show Telegram Menu" | |
| 720 | msgstr "Показывать меню Telegram" | |
| 721 | ||
| 722 | msgctxt "#30806" | |
| 723 | msgid "Clear cache when update" | |
| 724 | msgstr "Очищать кэш при обновлении" | |
| 725 | ||
| 726 | msgctxt "#30807" | |
| 727 | msgid "Peerfix Configuration" | |
| 728 | msgstr "Настройка Peerfix" | |
| 729 | ||
| 730 | msgctxt "#30894" | |
| 731 | msgid "Show Changelog" | |
| 732 | msgstr "Показать список изменений" | |
| 733 | ||
| 734 | msgctxt "#30895" | |
| 735 | msgid "View the latest changes and updates to the addon." | |
| 736 | msgstr "Просмотр последних изменений и обновлений аддона." | |
| 737 | ||
| 738 | msgctxt "#30896" | |
| 739 | msgid "Calendar" | |
| 740 | msgstr "Календарь" | |
| 741 | ||
| 742 | msgctxt "#30897" | |
| 743 | msgid "Use broadcast day adjustment" | |
| 744 | msgstr "Использовать корректировку дня трансляции" | |
| 745 | ||
| 746 | msgctxt "#30898" | |
| 747 | msgid "Adjust broadcast day based on manual offset" | |
| 748 | msgstr "Корректировка дня трансляции на основе ручного смещения" | |
| 749 | ||
| 750 | msgctxt "#30899" | |
| 751 | msgid "Broadcast Offset (Hours)" | |
| 752 | msgstr "Смещение трансляции (часы)" | |
| 753 | ||
| 754 | msgctxt "#30900" | |
| 755 | msgid "Manual hour adjustment for broadcast day simulation" | |
| 756 | msgstr "Ручная корректировка часов для симуляции дня трансляции" | |
| 757 | ||
| 758 | msgctxt "#30905" | |
| 759 | msgid "Trakt Calendar Previous Days" | |
| 760 | msgstr "Предыдущие дни календаря Trakt" | |
| 761 | ||
| 762 | msgctxt "#30906" | |
| 763 | msgid "Number of days in the past to show in the Trakt calendar." | |
| 764 | msgstr "Количество дней в прошлом для показа в календаре Trakt." | |
| 765 | ||
| 766 | msgctxt "#30907" | |
| 767 | msgid "Trakt Calendar Future Days" | |
| 768 | msgstr "Будущие дни календаря Trakt" | |
| 769 | ||
| 770 | msgctxt "#30908" | |
| 771 | msgid "Number of days in the future to show in the Trakt calendar." | |
| 772 | msgstr "Количество дней в будущем для показа в календаре Trakt." | |
| 773 | ||
| 774 | msgctxt "#30909" | |
| 775 | msgid "Show All Shows in Calendar" | |
| 776 | msgstr "Показывать все шоу в календаре" | |
| 777 | ||
| 778 | msgctxt "#30918" | |
| 779 | msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead." | |
| 780 | msgstr "{provider} не может напрямую воспроизводить упакованные релизы (.rar/.zip). Выберите источник с видеофайлами." | |
| 781 | ||
| 782 | msgctxt "#30910" | |
| 783 | msgid "Show all airing shows instead of just your watchlist/collection." | |
| 784 | msgstr "Показывать все идущие шоу вместо только вашего списка наблюдения/коллекции." | |
| 785 | ||
| 786 | msgctxt "#30911" | |
| 787 | msgid "Your Torbox account username/email." | |
| 788 | msgstr "Имя пользователя/email вашего аккаунта Torbox." | |
| 789 | ||
| 790 | msgctxt "#30912" | |
| 791 | msgid "Authorize add-on to access Torbox account." | |
| 792 | msgstr "Авторизовать аддон для доступа к аккаунту Torbox." | |
| 793 | ||
| 794 | msgctxt "#30913" | |
| 795 | msgid "Revoke Torbox account access." | |
| 796 | msgstr "Отозвать доступ к аккаунту Torbox." | |
| 797 | ||
| 798 | msgctxt "#30914" | |
| 799 | msgid "Trakt Sync Interval (minutes)" | |
| 800 | msgstr "Интервал синхронизации Trakt (минуты)" | |
| 801 | ||
| 802 | msgctxt "#30915" | |
| 803 | msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running." | |
| 804 | msgstr "Как часто Jacktook проверяет Trakt на наличие изменений активности, пока запущен Kodi." | |
| 805 | ||
| 806 | msgctxt "#30916" | |
| 807 | msgid "Account Info" | |
| 808 | msgstr "Информация об аккаунте" | |
| 809 | ||
| 810 | msgctxt "#30917" | |
| 811 | msgid "View your Trakt account profile and library stats." | |
| 812 | msgstr "Просмотр профиля вашего аккаунта Trakt и статистики библиотеки." | |
| 813 | ||
| 814 | msgctxt "#30924" | |
| 815 | msgid "Trakt List Name" | |
| 816 | msgstr "Имя списка Trakt" | |
| 817 | ||
| 818 | msgctxt "#30925" | |
| 819 | msgid "Trakt List Description" | |
| 820 | msgstr "Описание списка Trakt" | |
| 821 | ||
| 822 | msgctxt "#30926" | |
| 823 | msgid "Create Trakt List" | |
| 824 | msgstr "Создать список Trakt" | |
| 825 | ||
| 826 | msgctxt "#30927" | |
| 827 | msgid "Choose Trakt List" | |
| 828 | msgstr "Выбрать список Trakt" | |
| 829 | ||
| 830 | msgctxt "#30936" | |
| 831 | msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position." | |
| 832 | msgstr "Включить или выключить скробблинг Trakt и возобновление воспроизведения с последней отслеженной позиции." | |
| 833 | ||
| 834 | msgctxt "#30937" | |
| 835 | msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view." | |
| 836 | msgstr "Очистить кэшированные данные Trakt и заставить Jacktook получать свежие результаты Trakt при следующем открытии вида Trakt." | |
| 837 | ||
| 838 | msgctxt "#40000" | |
| 839 | msgid "Arabic (United Arab Emirates)" | |
| 840 | msgstr "Арабский (ОАЭ)" | |
| 841 | ||
| 842 | msgctxt "#40001" | |
| 843 | msgid "Arabic (Saudi Arabia)" | |
| 844 | msgstr "Арабский (Саудовская Аравия)" | |
| 845 | ||
| 846 | msgctxt "#40002" | |
| 847 | msgid "Belarusian (Belarus)" | |
| 848 | msgstr "Белорусский (Беларусь)" | |
| 849 | ||
| 850 | msgctxt "#40003" | |
| 851 | msgid "Bulgarian (Bulgaria)" | |
| 852 | msgstr "Болгарский (Болгария)" | |
| 853 | ||
| 854 | msgctxt "#40004" | |
| 855 | msgid "Bengali (Bangladesh)" | |
| 856 | msgstr "Бенгальский (Бангладеш)" | |
| 857 | ||
| 858 | msgctxt "#40005" | |
| 859 | msgid "Catalan (Spain)" | |
| 860 | msgstr "Каталанский (Испания)" | |
| 861 | ||
| 862 | msgctxt "#40006" | |
| 863 | msgid "Chamorro (Guam)" | |
| 864 | msgstr "Чаморро (Гуам)" | |
| 865 | ||
| 866 | msgctxt "#40007" | |
| 867 | msgid "Czech (Czech Republic)" | |
| 868 | msgstr "Чешский (Чехия)" | |
| 869 | ||
| 870 | msgctxt "#40008" | |
| 871 | msgid "Danish (Denmark)" | |
| 872 | msgstr "Датский (Дания)" | |
| 873 | ||
| 874 | msgctxt "#40009" | |
| 875 | msgid "German (Austria)" | |
| 876 | msgstr "Немецкий (Австрия)" | |
| 877 | ||
| 878 | msgctxt "#40010" | |
| 879 | msgid "German (Switzerland)" | |
| 880 | msgstr "Немецкий (Швейцария)" | |
| 881 | ||
| 882 | msgctxt "#40011" | |
| 883 | msgid "German (Germany)" | |
| 884 | msgstr "Немецкий (Германия)" | |
| 885 | ||
| 886 | msgctxt "#40012" | |
| 887 | msgid "Greek (Greece)" | |
| 888 | msgstr "Греческий (Греция)" | |
| 889 | ||
| 890 | msgctxt "#40013" | |
| 891 | msgid "English (Australia)" | |
| 892 | msgstr "Английский (Австралия)" | |
| 893 | ||
| 894 | msgctxt "#40014" | |
| 895 | msgid "English (Canada)" | |
| 896 | msgstr "Английский (Канада)" | |
| 897 | ||
| 898 | msgctxt "#40015" | |
| 899 | msgid "English (United Kingdom)" | |
| 900 | msgstr "Английский (Великобритания)" | |
| 901 | ||
| 902 | msgctxt "#40016" | |
| 903 | msgid "English (Ireland)" | |
| 904 | msgstr "Английский (Ирландия)" | |
| 905 | ||
| 906 | msgctxt "#40017" | |
| 907 | msgid "English (New Zealand)" | |
| 908 | msgstr "Английский (Новая Зеландия)" | |
| 909 | ||
| 910 | msgctxt "#40018" | |
| 911 | msgid "English (United States of America)" | |
| 912 | msgstr "Английский (США)" | |
| 913 | ||
| 914 | msgctxt "#40019" | |
| 915 | msgid "Esperanto (None)" | |
| 916 | msgstr "Эсперанто" | |
| 917 | ||
| 918 | msgctxt "#40020" | |
| 919 | msgid "Spanish (Spain)" | |
| 920 | msgstr "Испанский (Испания)" | |
| 921 | ||
| 922 | msgctxt "#40021" | |
| 923 | msgid "Spanish (Mexico)" | |
| 924 | msgstr "Испанский (Мексика)" | |
| 925 | ||
| 926 | msgctxt "#40022" | |
| 927 | msgid "Estonian (Estonia)" | |
| 928 | msgstr "Эстонский (Эстония)" | |
| 929 | ||
| 930 | msgctxt "#40023" | |
| 931 | msgid "Basque (Spain)" | |
| 932 | msgstr "Баскский (Испания)" | |
| 933 | ||
| 934 | msgctxt "#40024" | |
| 935 | msgid "Persian (Iran)" | |
| 936 | msgstr "Персидский (Иран)" | |
| 937 | ||
| 938 | msgctxt "#40025" | |
| 939 | msgid "Finnish (Finland)" | |
| 940 | msgstr "Финский (Финляндия)" | |
| 941 | ||
| 942 | msgctxt "#40026" | |
| 943 | msgid "French (Canada)" | |
| 944 | msgstr "Французский (Канада)" | |
| 945 | ||
| 946 | msgctxt "#40027" | |
| 947 | msgid "French (France)" | |
| 948 | msgstr "Французский (Франция)" | |
| 949 | ||
| 950 | msgctxt "#40028" | |
| 951 | msgid "Galician (Spain)" | |
| 952 | msgstr "Галисийский (Испания)" | |
| 953 | ||
| 954 | msgctxt "#40029" | |
| 955 | msgid "Hebrew (Israel)" | |
| 956 | msgstr "Иврит (Израиль)" | |
| 957 | ||
| 958 | msgctxt "#40030" | |
| 959 | msgid "Hindi (India)" | |
| 960 | msgstr "Хинди (Индия)" | |
| 961 | ||
| 962 | msgctxt "#40031" | |
| 963 | msgid "Hungarian (Hungary)" | |
| 964 | msgstr "Венгерский (Венгрия)" | |
| 965 | ||
| 966 | msgctxt "#40032" | |
| 967 | msgid "Indonesian (Indonesia)" | |
| 968 | msgstr "Индонезийский (Индонезия)" | |
| 969 | ||
| 970 | msgctxt "#40033" | |
| 971 | msgid "Italian (Italy)" | |
| 972 | msgstr "Итальянский (Италия)" | |
| 973 | ||
| 974 | msgctxt "#40034" | |
| 975 | msgid "Japanese (Japan)" | |
| 976 | msgstr "Японский (Япония)" | |
| 977 | ||
| 978 | msgctxt "#40035" | |
| 979 | msgid "Georgian (Georgia)" | |
| 980 | msgstr "Грузинский (Грузия)" | |
| 981 | ||
| 982 | msgctxt "#40036" | |
| 983 | msgid "Kazakh (Kazakhstan)" | |
| 984 | msgstr "Казахский (Казахстан)" | |
| 985 | ||
| 986 | msgctxt "#40037" | |
| 987 | msgid "Kannada (India)" | |
| 988 | msgstr "Каннада (Индия)" | |
| 989 | ||
| 990 | msgctxt "#40038" | |
| 991 | msgid "Korean (South Korea)" | |
| 992 | msgstr "Корейский (Южная Корея)" | |
| 993 | ||
| 994 | msgctxt "#40039" | |
| 995 | msgid "Lithuanian (Lithuania)" | |
| 996 | msgstr "Литовский (Литва)" | |
| 997 | ||
| 998 | msgctxt "#40040" | |
| 999 | msgid "Latvian (Latvia)" | |
| 1000 | msgstr "Латышский (Латвия)" | |
| 1001 | ||
| 1002 | msgctxt "#40041" | |
| 1003 | msgid "Malayalam (India)" | |
| 1004 | msgstr "Малаялам (Индия)" | |
| 1005 | ||
| 1006 | msgctxt "#40042" | |
| 1007 | msgid "Malay (Malaysia)" | |
| 1008 | msgstr "Малайский (Малайзия)" | |
| 1009 | ||
| 1010 | msgctxt "#40043" | |
| 1011 | msgid "Malay (Singapore)" | |
| 1012 | msgstr "Малайский (Сингапур)" | |
| 1013 | ||
| 1014 | msgctxt "#40044" | |
| 1015 | msgid "Norwegian Bokmål (Norway)" | |
| 1016 | msgstr "Норвежский букмол (Норвегия)" | |
| 1017 | ||
| 1018 | msgctxt "#40045" | |
| 1019 | msgid "Dutch (Netherlands)" | |
| 1020 | msgstr "Голландский (Нидерланды)" | |
| 1021 | ||
| 1022 | msgctxt "#40046" | |
| 1023 | msgid "Norwegian (Norway)" | |
| 1024 | msgstr "Норвежский (Норвегия)" | |
| 1025 | ||
| 1026 | msgctxt "#40047" | |
| 1027 | msgid "Polish (Poland)" | |
| 1028 | msgstr "Польский (Польша)" | |
| 1029 | ||
| 1030 | msgctxt "#40048" | |
| 1031 | msgid "Portuguese (Brazil)" | |
| 1032 | msgstr "Португальский (Бразилия)" | |
| 1033 | ||
| 1034 | msgctxt "#40049" | |
| 1035 | msgid "Portuguese (Portugal)" | |
| 1036 | msgstr "Португальский (Португалия)" | |
| 1037 | ||
| 1038 | msgctxt "#40050" | |
| 1039 | msgid "Romanian (Romania)" | |
| 1040 | msgstr "Румынский (Румыния)" | |
| 1041 | ||
| 1042 | msgctxt "#40051" | |
| 1043 | msgid "Russian (Russia)" | |
| 1044 | msgstr "Русский (Россия)" | |
| 1045 | ||
| 1046 | msgctxt "#40052" | |
| 1047 | msgid "Sinhalese (Sri Lanka)" | |
| 1048 | msgstr "Сингальский (Шри-Ланка)" | |
| 1049 | ||
| 1050 | msgctxt "#40053" | |
| 1051 | msgid "Slovak (Slovakia)" | |
| 1052 | msgstr "Словацкий (Словакия)" | |
| 1053 | ||
| 1054 | msgctxt "#40054" | |
| 1055 | msgid "Slovenian (Slovenia)" | |
| 1056 | msgstr "Словенский (Словения)" | |
| 1057 | ||
| 1058 | msgctxt "#40055" | |
| 1059 | msgid "Serbian (Serbia)" | |
| 1060 | msgstr "Сербский (Сербия)" | |
| 1061 | ||
| 1062 | msgctxt "#40056" | |
| 1063 | msgid "Swedish (Sweden)" | |
| 1064 | msgstr "Шведский (Швеция)" | |
| 1065 | ||
| 1066 | msgctxt "#40057" | |
| 1067 | msgid "Tamil (India)" | |
| 1068 | msgstr "Тамильский (Индия)" | |
| 1069 | ||
| 1070 | msgctxt "#40058" | |
| 1071 | msgid "Telugu (India)" | |
| 1072 | msgstr "Телугу (Индия)" | |
| 1073 | ||
| 1074 | msgctxt "#40059" | |
| 1075 | msgid "Thai (Thailand)" | |
| 1076 | msgstr "Тайский (Таиланд)" | |
| 1077 | ||
| 1078 | msgctxt "#40060" | |
| 1079 | msgid "Tagalog (Philippines)" | |
| 1080 | msgstr "Тагальский (Филиппины)" | |
| 1081 | ||
| 1082 | msgctxt "#40061" | |
| 1083 | msgid "Turkish (Turkey)" | |
| 1084 | msgstr "Турецкий (Турция)" | |
| 1085 | ||
| 1086 | msgctxt "#40062" | |
| 1087 | msgid "Ukrainian (Ukraine)" | |
| 1088 | msgstr "Украинский (Украина)" | |
| 1089 | ||
| 1090 | msgctxt "#40063" | |
| 1091 | msgid "Vietnamese (Vietnam)" | |
| 1092 | msgstr "Вьетнамский (Вьетнам)" | |
| 1093 | ||
| 1094 | msgctxt "#40064" | |
| 1095 | msgid "Mandarin (China)" | |
| 1096 | msgstr "Китайский (Китай)" | |
| 1097 | ||
| 1098 | msgctxt "#40065" | |
| 1099 | msgid "Mandarin (Hong Kong)" | |
| 1100 | msgstr "Китайский (Гонконг)" | |
| 1101 | ||
| 1102 | msgctxt "#40066" | |
| 1103 | msgid "Mandarin (Taiwan)" | |
| 1104 | msgstr "Китайский (Тайвань)" | |
| 1105 | ||
| 1106 | msgctxt "#40067" | |
| 1107 | msgid "Zulu (South Africa)" | |
| 1108 | msgstr "Зулу (Южная Африка)" | |
| 1109 | ||
| 1110 | msgctxt "#42418" | |
| 1111 | msgid "Language and country" | |
| 1112 | msgstr "Язык и страна" | |
| 1113 | ||
| 1114 | msgctxt "#42419" | |
| 1115 | msgid "The language and country settings used when looking up data from TMDb" | |
| 1116 | msgstr "Настройки языка и страны, используемые при поиске данных в TMDb" | |
| 1117 | ||
| 1118 | msgctxt "#42420" | |
| 1119 | msgid "EN" | |
| 1120 | msgstr "RU" | |
| 1121 | ||
| 1122 | msgctxt "#30801" | |
| 1123 | msgid "Zilean server address (include port if required)." | |
| 1124 | msgstr "Адрес сервера Zilean (включая порт, если требуется)." | |
| 1125 | ||
| 1126 | msgctxt "#30803" | |
| 1127 | msgid "Display torrents not yet cached on your debrid service." | |
| 1128 | msgstr "Отображать торренты, еще не кэшированные в вашем debrid-сервисе." | |
| 1129 | ||
| 1130 | msgctxt "#30808" | |
| 1131 | msgid "Fanart.tv Client Key (for artwork)." | |
| 1132 | msgstr "Ключ клиента Fanart.tv (для обложек)." | |
| 1133 | ||
| 1134 | msgctxt "#30809" | |
| 1135 | msgid "Improve performance by caching metadata." | |
| 1136 | msgstr "Улучшить производительность за счет кэширования метаданных." | |
| 1137 | ||
| 1138 | msgctxt "#30810" | |
| 1139 | msgid "Seconds before Jackett requests time out (e.g., 30)." | |
| 1140 | msgstr "Секунд до тайм-аута запросов Jackett (например, 30)." | |
| 1141 | ||
| 1142 | msgctxt "#30811" | |
| 1143 | msgid "Trakt client secret from OAuth application settings." | |
| 1144 | msgstr "Секрет клиента Trakt из настроек приложения OAuth." | |
| 1145 | ||
| 1146 | msgctxt "#30812" | |
| 1147 | msgid "Limit TV show results to specific seasons/episodes." | |
| 1148 | msgstr "Ограничить результаты ТВ-шоу конкретными сезонами/сериями." | |
| 1149 | ||
| 1150 | msgctxt "#30813" | |
| 1151 | msgid "Hours before cached data is automatically refreshed (e.g., 24)." | |
| 1152 | msgstr "Часов до автоматического обновления кэшированных данных (например, 24)." | |
| 1153 | ||
| 1154 | msgctxt "#30814" | |
| 1155 | msgid "Premiumize Client Token (get from account page)." | |
| 1156 | msgstr "Токен клиента Premiumize (получите на странице аккаунта)." | |
| 1157 | ||
| 1158 | msgctxt "#30815" | |
| 1159 | msgid "Jackett server URL (e.g., http://localhost)." | |
| 1160 | msgstr "URL сервера Jackett (например, http://localhost)." | |
| 1161 | ||
| 1162 | msgctxt "#30816" | |
| 1163 | msgid "Display Telegram integration options in UI." | |
| 1164 | msgstr "Отображать параметры интеграции Telegram в интерфейсе." | |
| 1165 | ||
| 1166 | msgctxt "#30817" | |
| 1167 | msgid "Your Real-Debrid account username/email." | |
| 1168 | msgstr "Имя пользователя/email вашего аккаунта Real-Debrid." | |
| 1169 | ||
| 1170 | msgctxt "#30818" | |
| 1171 | msgid "Trakt client ID from https://trakt.tv/oauth/apps." | |
| 1172 | msgstr "ID клиента Trakt с https://trakt.tv/oauth/apps." | |
| 1173 | ||
| 1174 | msgctxt "#30819" | |
| 1175 | msgid "Connect Trakt.tv account to sync watch progress." | |
| 1176 | msgstr "Подключить аккаунт Trakt.tv для синхронизации прогресса просмотра." | |
| 1177 | ||
| 1178 | msgctxt "#30820" | |
| 1179 | msgid "Seconds before Zilean requests time out (e.g., 30)." | |
| 1180 | msgstr "Секунд до тайм-аута запросов Zilean (например, 30)." | |
| 1181 | ||
| 1182 | msgctxt "#30821" | |
| 1183 | msgid "Your Jackett Client Key (found in dashboard)." | |
| 1184 | msgstr "Ваш ключ клиента Jackett (находится в панели управления)." | |
| 1185 | ||
| 1186 | msgctxt "#30822" | |
| 1187 | msgid "Sort results by criteria (e.g., 'date', 'title', 'size')." | |
| 1188 | msgstr "Сортировать результаты по критериям (например, 'дата', 'название', 'размер')." | |
| 1189 | ||
| 1190 | msgctxt "#30823" | |
| 1191 | msgid "Configure Burst mode settings and providers." | |
| 1192 | msgstr "Настроить параметры и провайдеров режима Burst." | |
| 1193 | ||
| 1194 | msgctxt "#30824" | |
| 1195 | msgid "Enable Premiumize.me cloud service." | |
| 1196 | msgstr "Включить облачный сервис Premiumize.me." | |
| 1197 | ||
| 1198 | msgctxt "#30825" | |
| 1199 | msgid "Truncate descriptions after [X] characters (0 for full text)." | |
| 1200 | msgstr "Обрезать описания после [X] символов (0 для полного текста)." | |
| 1201 | ||
| 1202 | msgctxt "#30826" | |
| 1203 | msgid "Enable Prowlarr indexer management." | |
| 1204 | msgstr "Включить управление индексаторами Prowlarr." | |
| 1205 | ||
| 1206 | msgctxt "#30827" | |
| 1207 | msgid "Jackgram server URL (e.g., http://localhost:9117)." | |
| 1208 | msgstr "URL сервера Jackgram (например, http://localhost:9117)." | |
| 1209 | ||
| 1210 | msgctxt "#30828" | |
| 1211 | msgid "Debrider Client Key for authentication." | |
| 1212 | msgstr "Ключ клиента Debrider для аутентификации." | |
| 1213 | ||
| 1214 | msgctxt "#30829" | |
| 1215 | msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)." | |
| 1216 | msgstr "Задержка (в секундах) перед появлением диалога следующей серии (например, 10)." | |
| 1217 | ||
| 1218 | msgctxt "#30830" | |
| 1219 | msgid "Specify the torrent client." | |
| 1220 | msgstr "Укажите торрент-клиент." | |
| 1221 | ||
| 1222 | msgctxt "#30831" | |
| 1223 | msgid "Enable Debrider cached torrent support." | |
| 1224 | msgstr "Включить поддержку кэшированных торрентов Debrider." | |
| 1225 | ||
| 1226 | msgctxt "#30832" | |
| 1227 | msgid "Torbox Client Token (found in account settings)." | |
| 1228 | msgstr "Токен клиента Torbox (находится в настройках аккаунта)." | |
| 1229 | ||
| 1230 | msgctxt "#30833" | |
| 1231 | msgid "Trakt refresh token (auto-managed)." | |
| 1232 | msgstr "Токен обновления Trakt (управляется автоматически)." | |
| 1233 | ||
| 1234 | msgctxt "#30834" | |
| 1235 | msgid "Show confirmation dialog before playing next episode." | |
| 1236 | msgstr "Показывать диалог подтверждения перед воспроизведением следующей серии." | |
| 1237 | ||
| 1238 | msgctxt "#30835" | |
| 1239 | msgid "Authorize add-on to access Real-Debrid account." | |
| 1240 | msgstr "Авторизовать аддон для доступа к аккаунту Real-Debrid." | |
| 1241 | ||
| 1242 | msgctxt "#30836" | |
| 1243 | msgid "Enable torrent-based media content" | |
| 1244 | msgstr "Включить медиаконтент на основе торрентов" | |
| 1245 | ||
| 1246 | msgctxt "#30837" | |
| 1247 | msgid "Indicates if Real-Debrid account is linked." | |
| 1248 | msgstr "Указывает, привязан ли аккаунт Real-Debrid." | |
| 1249 | ||
| 1250 | msgctxt "#30838" | |
| 1251 | msgid "Automatically play next media item when current playback ends." | |
| 1252 | msgstr "Автоматически воспроизводить следующий медиаэлемент после окончания текущего." | |
| 1253 | ||
| 1254 | msgctxt "#30839" | |
| 1255 | msgid "Check for and install the latest add-on updates." | |
| 1256 | msgstr "Проверять и устанавливать последние обновления аддона." | |
| 1257 | ||
| 1258 | msgctxt "#30840" | |
| 1259 | msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)." | |
| 1260 | msgstr "ID индексаторов Prowlarr через запятую (например, 1,5,7)." | |
| 1261 | ||
| 1262 | msgctxt "#30841" | |
| 1263 | msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)." | |
| 1264 | msgstr "Тайм-аут (секунды) для запросов Prowlarr (например, 20)." | |
| 1265 | ||
| 1266 | msgctxt "#30842" | |
| 1267 | msgid "Trakt authorization expiration date (read-only)." | |
| 1268 | msgstr "Дата истечения авторизации Trakt (только для чтения)." | |
| 1269 | ||
| 1270 | msgctxt "#30843" | |
| 1271 | msgid "Enable Torbox.de cloud torrent integration." | |
| 1272 | msgstr "Включить интеграцию облачных торрентов Torbox.de." | |
| 1273 | ||
| 1274 | msgctxt "#30844" | |
| 1275 | msgid "Trakt Access Token (auto-filled after auth)." | |
| 1276 | msgstr "Токен доступа Trakt (заполняется автоматически после авторизации)." | |
| 1277 | ||
| 1278 | msgctxt "#30845" | |
| 1279 | msgid "Link Premiumize.me account to add-on." | |
| 1280 | msgstr "Привязать аккаунт Premiumize.me к аддону." | |
| 1281 | ||
| 1282 | msgctxt "#30846" | |
| 1283 | msgid "Clear cached data automatically after updating the add-on." | |
| 1284 | msgstr "Автоматически очищать кэшированные данные после обновления аддона." | |
| 1285 | ||
| 1286 | msgctxt "#30847" | |
| 1287 | msgid "Click to delete all cached data immediately." | |
| 1288 | msgstr "Нажмите, чтобы немедленно удалить все кэшированные данные." | |
| 1289 | ||
| 1290 | msgctxt "#30848" | |
| 1291 | msgid "Real-Debrid Client Token (auto-filled after auth)." | |
| 1292 | msgstr "Токен клиента Real-Debrid (заполняется автоматически после авторизации)." | |
| 1293 | ||
| 1294 | msgctxt "#30849" | |
| 1295 | msgid "Enable Jackett torrent proxy support." | |
| 1296 | msgstr "Включить поддержку прокси-торрентов Jackett." | |
| 1297 | ||
| 1298 | msgctxt "#30850" | |
| 1299 | msgid "Enable Jackgram integration for enhanced search." | |
| 1300 | msgstr "Включить интеграцию Jackgram для расширенного поиска." | |
| 1301 | ||
| 1302 | msgctxt "#30851" | |
| 1303 | msgid "Prowlarr instance URL (e.g., http://localhost)." | |
| 1304 | msgstr "URL экземпляра Prowlarr (например, http://localhost)." | |
| 1305 | ||
| 1306 | msgctxt "#30852" | |
| 1307 | msgid "Maximum number of search results to display (e.g., 50)." | |
| 1308 | msgstr "Максимальное количество результатов поиска (например, 50)." | |
| 1309 | ||
| 1310 | msgctxt "#30853" | |
| 1311 | msgid "Revoke Real-Debrid account access." | |
| 1312 | msgstr "Отозвать доступ к аккаунту Real-Debrid." | |
| 1313 | ||
| 1314 | msgctxt "#30854" | |
| 1315 | msgid "Prowlarr Client Key (found in Settings > General)." | |
| 1316 | msgstr "Ключ клиента Prowlarr (находится в Настройки > Общие)." | |
| 1317 | ||
| 1318 | msgctxt "#30855" | |
| 1319 | msgid "Trakt.tv username (auto-populated after auth)." | |
| 1320 | msgstr "Имя пользователя Trakt.tv (заполняется автоматически после авторизации)." | |
| 1321 | ||
| 1322 | msgctxt "#30856" | |
| 1323 | msgid "Disconnect Trakt.tv account from add-on." | |
| 1324 | msgstr "Отключить аккаунт Trakt.tv от аддона." | |
| 1325 | ||
| 1326 | msgctxt "#30857" | |
| 1327 | msgid "Enable Zilean integration for metadata fetching." | |
| 1328 | msgstr "Включить интеграцию Zilean для получения метаданных." | |
| 1329 | ||
| 1330 | msgctxt "#30858" | |
| 1331 | msgid "Your Client Key (required for metadata)" | |
| 1332 | msgstr "Ваш ключ клиента (требуется для метаданных)" | |
| 1333 | ||
| 1334 | msgctxt "#30859" | |
| 1335 | msgid "Enable Burst mode for accelerated searches." | |
| 1336 | msgstr "Включить режим Burst для ускоренного поиска." | |
| 1337 | ||
| 1338 | msgctxt "#30860" | |
| 1339 | msgid "Others" | |
| 1340 | msgstr "Другие" | |
| 1341 | ||
| 1342 | msgctxt "#30861" | |
| 1343 | msgid "Filters" | |
| 1344 | msgstr "Фильтры" | |
| 1345 | ||
| 1346 | msgctxt "#30862" | |
| 1347 | msgid "Torrent (P2P) Configuration" | |
| 1348 | msgstr "Конфигурация торрента (P2P)" | |
| 1349 | ||
| 1350 | msgctxt "#30863" | |
| 1351 | msgid "Specify the torrent client" | |
| 1352 | msgstr "Укажите торрент-клиент" | |
| 1353 | ||
| 1354 | msgctxt "#30864" | |
| 1355 | msgid "Sources" | |
| 1356 | msgstr "Источники" | |
| 1357 | ||
| 1358 | msgctxt "#30865" | |
| 1359 | msgid "General Filters" | |
| 1360 | msgstr "Общие фильтры" | |
| 1361 | ||
| 1362 | msgctxt "#30866" | |
| 1363 | msgid "Sign in to your Stremio account to access add-ons and personalized features." | |
| 1364 | msgstr "Войдите в свой аккаунт Stremio для доступа к аддонам и персональным функциям." | |
| 1365 | ||
| 1366 | msgctxt "#30867" | |
| 1367 | msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data." | |
| 1368 | msgstr "Выйдите из своего аккаунта Stremio. Это отключит ваш аккаунт, но не удалит ваши данные." | |
| 1369 | ||
| 1370 | msgctxt "#30868" | |
| 1371 | msgid "Log Out of Stremio" | |
| 1372 | msgstr "Выйти из Stremio" | |
| 1373 | ||
| 1374 | msgctxt "#30869" | |
| 1375 | msgid "Enable Stremio add-ons integration." | |
| 1376 | msgstr "Включить интеграцию аддонов Stremio." | |
| 1377 | ||
| 1378 | msgctxt "#30870" | |
| 1379 | msgid "Select Stream Addons" | |
| 1380 | msgstr "Выбрать аддоны потоков" | |
| 1381 | ||
| 1382 | msgctxt "#30871" | |
| 1383 | msgid "Log In to Stremio" | |
| 1384 | msgstr "Войти в Stremio" | |
| 1385 | ||
| 1386 | msgctxt "#30872" | |
| 1387 | msgid "Enable or disable Stremio add-ons." | |
| 1388 | msgstr "Включить или выключить аддоны Stremio." | |
| 1389 | ||
| 1390 | msgctxt "#30873" | |
| 1391 | msgid "Select language for player subtitle selection" | |
| 1392 | msgstr "Выберите язык для выбора субтитров в плеере" | |
| 1393 | ||
| 1394 | msgctxt "#30874" | |
| 1395 | msgid "Update Addons" | |
| 1396 | msgstr "Обновить аддоны" | |
| 1397 | ||
| 1398 | msgctxt "#30875" | |
| 1399 | msgid "Select Catalogs Addons" | |
| 1400 | msgstr "Выбрать аддоны каталогов" | |
| 1401 | ||
| 1402 | msgctxt "#30876" | |
| 1403 | msgid "Download directory" | |
| 1404 | msgstr "Каталог загрузки" | |
| 1405 | ||
| 1406 | msgctxt "#30877" | |
| 1407 | msgid "Enable Trakt.tv Scrobbling" | |
| 1408 | msgstr "Включить скробблинг Trakt.tv" | |
| 1409 | ||
| 1410 | msgctxt "#30878" | |
| 1411 | msgid "Auto select subtitle when playing" | |
| 1412 | msgstr "Автовыбор субтитров при воспроизведении" | |
| 1413 | ||
| 1414 | msgctxt "#30879" | |
| 1415 | msgid "Auto-play quality" | |
| 1416 | msgstr "Качество автовоспроизведения" | |
| 1417 | ||
| 1418 | msgctxt "#30880" | |
| 1419 | msgid "1080p" | |
| 1420 | msgstr "1080p" | |
| 1421 | ||
| 1422 | msgctxt "#30881" | |
| 1423 | msgid "720p" | |
| 1424 | msgstr "720p" | |
| 1425 | ||
| 1426 | msgctxt "#30882" | |
| 1427 | msgid "4k" | |
| 1428 | msgstr "4k" | |
| 1429 | ||
| 1430 | msgctxt "#30883" | |
| 1431 | msgid "480p" | |
| 1432 | msgstr "480p" | |
| 1433 | ||
| 1434 | msgctxt "#30884" | |
| 1435 | msgid "Subtitles Configuration" | |
| 1436 | msgstr "Настройка субтитров" | |
| 1437 | ||
| 1438 | msgctxt "#30885" | |
| 1439 | msgid "Addon Host" | |
| 1440 | msgstr "Хост аддона" | |
| 1441 | ||
| 1442 | msgctxt "#30886" | |
| 1443 | msgid "Subtitles" | |
| 1444 | msgstr "Субтитры" | |
| 1445 | ||
| 1446 | msgctxt "#30887" | |
| 1447 | msgid "Primary Language" | |
| 1448 | msgstr "Основной язык" | |
| 1449 | ||
| 1450 | msgctxt "#30888" | |
| 1451 | msgid "Secondary Language" | |
| 1452 | msgstr "Дополнительный язык" | |
| 1453 | ||
| 1454 | msgctxt "#30889" | |
| 1455 | msgid "Deepl Enabled" | |
| 1456 | msgstr "Deepl включен" | |
| 1457 | ||
| 1458 | msgctxt "#30890" | |
| 1459 | msgid "Deepl Client Key" | |
| 1460 | msgstr "Ключ клиента Deepl" | |
| 1461 | ||
| 1462 | msgctxt "#30891" | |
| 1463 | msgid "Translator Configuration" | |
| 1464 | msgstr "Настройка переводчика" | |
| 1465 | ||
| 1466 | msgctxt "#30892" | |
| 1467 | msgid "Deepl Target Language" | |
| 1468 | msgstr "Целевой язык Deepl" | |
| 1469 | ||
| 1470 | msgctxt "#30893" | |
| 1471 | msgid "Stremio Subtitle Configuration" | |
| 1472 | msgstr "Настройка субтитров Stremio" | |
| 1473 | ||
| 1474 | msgctxt "#90001" | |
| 1475 | msgid "Add Custom Addon" | |
| 1476 | msgstr "Добавить пользовательский аддон" | |
| 1477 | ||
| 1478 | msgctxt "#90002" | |
| 1479 | msgid "Add a custom Stremio addon by entering its URL" | |
| 1480 | msgstr "Добавить пользовательский аддон Stremio, введя его URL" | |
| 1481 | ||
| 1482 | msgctxt "#90003" | |
| 1483 | msgid "Auto-select audio when playing" | |
| 1484 | msgstr "Автовыбор аудио при воспроизведении" | |
| 1485 | ||
| 1486 | msgctxt "#90004" | |
| 1487 | msgid "Audio Language" | |
| 1488 | msgstr "Язык аудио" | |
| 1489 | ||
| 1490 | msgctxt "#90005" | |
| 1491 | msgid "Select audio language for playing the video" | |
| 1492 | msgstr "Выберите язык аудио для воспроизведения видео" | |
| 1493 | ||
| 1494 | msgctxt "#90006" | |
| 1495 | msgid "Search" | |
| 1496 | msgstr "Поиск" | |
| 1497 | ||
| 1498 | msgctxt "#90007" | |
| 1499 | msgid "TV Shows" | |
| 1500 | msgstr "ТВ-шоу" | |
| 1501 | ||
| 1502 | msgctxt "#90008" | |
| 1503 | msgid "Movies" | |
| 1504 | msgstr "Фильмы" | |
| 1505 | ||
| 1506 | msgctxt "#90009" | |
| 1507 | msgid "Anime" | |
| 1508 | msgstr "Аниме" | |
| 1509 | ||
| 1510 | msgctxt "#90010" | |
| 1511 | msgid "Live TV" | |
| 1512 | msgstr "Прямой эфир" | |
| 1513 | ||
| 1514 | msgctxt "#90011" | |
| 1515 | msgid "Direct Search" | |
| 1516 | msgstr "Прямой поиск" | |
| 1517 | ||
| 1518 | msgctxt "#90012" | |
| 1519 | msgid "Torrents" | |
| 1520 | msgstr "Торренты" | |
| 1521 | ||
| 1522 | msgctxt "#90013" | |
| 1523 | msgid "Telegram" | |
| 1524 | msgstr "Telegram" | |
| 1525 | ||
| 1526 | msgctxt "#90014" | |
| 1527 | msgid "Cloud" | |
| 1528 | msgstr "Облако" | |
| 1529 | ||
| 1530 | msgctxt "#90015" | |
| 1531 | msgid "Downloads" | |
| 1532 | msgstr "Загрузки" | |
| 1533 | ||
| 1534 | msgctxt "#90016" | |
| 1535 | msgid "Settings" | |
| 1536 | msgstr "Настройки" | |
| 1537 | ||
| 1538 | msgctxt "#90017" | |
| 1539 | msgid "History" | |
| 1540 | msgstr "История" | |
| 1541 | ||
| 1542 | msgctxt "#90018" | |
| 1543 | msgid "Donate" | |
| 1544 | msgstr "Пожертвовать" | |
| 1545 | ||
| 1546 | msgctxt "#90019" | |
| 1547 | msgid "Recently Watched Files" | |
| 1548 | msgstr "Недавно просмотренные файлы" | |
| 1549 | ||
| 1550 | msgctxt "#90020" | |
| 1551 | msgid "Recently Watched Titles" | |
| 1552 | msgstr "Недавно просмотренные названия" | |
| 1553 | ||
| 1554 | msgctxt "#90021" | |
| 1555 | msgid "TV Calendar" | |
| 1556 | msgstr "ТВ-календарь" | |
| 1557 | ||
| 1558 | msgctxt "#90022" | |
| 1559 | msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:" | |
| 1560 | msgstr "Если вам нравится использовать Jacktook и вы хотите поддержать его разработку, вы можете угостить меня кофе! Ваша поддержка помогает проекту оставаться активным и позволяет вносить будущие улучшения:" | |
| 1561 | ||
| 1562 | msgctxt "#90023" | |
| 1563 | msgid "Support Jacktook" | |
| 1564 | msgstr "Поддержать Jacktook" | |
| 1565 | ||
| 1566 | msgctxt "#90024" | |
| 1567 | msgid "Trending Recent" | |
| 1568 | msgstr "Популярные недавно" | |
| 1569 | ||
| 1570 | msgctxt "#90025" | |
| 1571 | msgid "Genres" | |
| 1572 | msgstr "Жанры" | |
| 1573 | ||
| 1574 | msgctxt "#90026" | |
| 1575 | msgid "Calendar" | |
| 1576 | msgstr "Календарь" | |
| 1577 | ||
| 1578 | msgctxt "#90027" | |
| 1579 | msgid "Years" | |
| 1580 | msgstr "Годы" | |
| 1581 | ||
| 1582 | msgctxt "#90028" | |
| 1583 | msgid "Trending" | |
| 1584 | msgstr "В тренде" | |
| 1585 | ||
| 1586 | msgctxt "#90029" | |
| 1587 | msgid "Most Watched" | |
| 1588 | msgstr "Самые просматриваемые" | |
| 1589 | ||
| 1590 | msgctxt "#90030" | |
| 1591 | msgid "Most Favorited" | |
| 1592 | msgstr "Самые любимые" | |
| 1593 | ||
| 1594 | msgctxt "#90031" | |
| 1595 | msgid "Popular Lists" | |
| 1596 | msgstr "Популярные списки" | |
| 1597 | ||
| 1598 | msgctxt "#90032" | |
| 1599 | msgid "Trending Lists" | |
| 1600 | msgstr "Трендовые списки" | |
| 1601 | ||
| 1602 | msgctxt "#90033" | |
| 1603 | msgid "Recomendations" | |
| 1604 | msgstr "Рекомендации" | |
| 1605 | ||
| 1606 | msgctxt "#90034" | |
| 1607 | msgid "Watched History" | |
| 1608 | msgstr "История просмотров" | |
| 1609 | ||
| 1610 | msgctxt "#90035" | |
| 1611 | msgid "Watchlist" | |
| 1612 | msgstr "Список наблюдения" | |
| 1613 | ||
| 1614 | msgctxt "#90036" | |
| 1615 | msgid "Top 10 BoxOffice" | |
| 1616 | msgstr "Топ-10 сборов" | |
| 1617 | ||
| 1618 | msgctxt "#90037" | |
| 1619 | msgid "Popular" | |
| 1620 | msgstr "Популярные" | |
| 1621 | ||
| 1622 | msgctxt "#90038" | |
| 1623 | msgid "Popular Recent" | |
| 1624 | msgstr "Популярные недавно" | |
| 1625 | ||
| 1626 | msgctxt "#90039" | |
| 1627 | msgid "Airing" | |
| 1628 | msgstr "В эфире" | |
| 1629 | ||
| 1630 | msgctxt "#90040" | |
| 1631 | msgid "Years" | |
| 1632 | msgstr "Годы" | |
| 1633 | ||
| 1634 | msgctxt "#90041" | |
| 1635 | msgid "Genres" | |
| 1636 | msgstr "Жанры" | |
| 1637 | ||
| 1638 | msgctxt "#90042" | |
| 1639 | msgid "Top Rated" | |
| 1640 | msgstr "Самые рейтинговые" | |
| 1641 | ||
| 1642 | msgctxt "#90043" | |
| 1643 | msgid "Most Watched" | |
| 1644 | msgstr "Самые просматриваемые" | |
| 1645 | ||
| 1646 | msgctxt "#90044" | |
| 1647 | msgid "Torrentio Configuration" | |
| 1648 | msgstr "Настройка Torrentio" | |
| 1649 | ||
| 1650 | msgctxt "#90045" | |
| 1651 | msgid "Enable Torrentio Settings" | |
| 1652 | msgstr "Включить настройки Torrentio" | |
| 1653 | ||
| 1654 | msgctxt "#90046" | |
| 1655 | msgid "Enable or disable the Torrentio Configuration" | |
| 1656 | msgstr "Включить или выключить настройку Torrentio" | |
| 1657 | ||
| 1658 | msgctxt "#90047" | |
| 1659 | msgid "Select Torrentio Providers" | |
| 1660 | msgstr "Выбрать провайдеров Torrentio" | |
| 1661 | ||
| 1662 | msgctxt "#90048" | |
| 1663 | msgid "Choose which providers to include in the stream request." | |
| 1664 | msgstr "Выберите, каких провайдеров включить в запрос потока." | |
| 1665 | ||
| 1666 | msgctxt "#90049" | |
| 1667 | msgid "Scrape again" | |
| 1668 | msgstr "Повторный поиск" | |
| 1669 | ||
| 1670 | msgctxt "#90050" | |
| 1671 | msgid "Recommendations" | |
| 1672 | msgstr "Рекомендации" | |
| 1673 | ||
| 1674 | msgctxt "#90051" | |
| 1675 | msgid "Similar" | |
| 1676 | msgstr "Похожие" | |
| 1677 | ||
| 1678 | msgctxt "#90052" | |
| 1679 | msgid "Thread Number" | |
| 1680 | msgstr "Количество потоков" | |
| 1681 | ||
| 1682 | msgctxt "#90053" | |
| 1683 | msgid "Select the number of threads (1-30)" | |
| 1684 | msgstr "Выберите количество потоков (1-30)" | |
| 1685 | ||
| 1686 | msgctxt "#90054" | |
| 1687 | msgid "Kodi logs" | |
| 1688 | msgstr "Логи Kodi" | |
| 1689 | ||
| 1690 | msgctxt "#90055" | |
| 1691 | msgid "Kodi old logs" | |
| 1692 | msgstr "Старые логи Kodi" | |
| 1693 | ||
| 1694 | msgctxt "#90056" | |
| 1695 | msgid "Displays Kodi logs to help diagnose issues." | |
| 1696 | msgstr "Отображает логи Kodi для помощи в диагностике проблем." | |
| 1697 | ||
| 1698 | msgctxt "#90057" | |
| 1699 | msgid "Logs" | |
| 1700 | msgstr "Логи" | |
| 1701 | ||
| 1702 | msgctxt "#90058" | |
| 1703 | msgid "Filter by Size" | |
| 1704 | msgstr "Фильтр по размеру" | |
| 1705 | ||
| 1706 | msgctxt "#90059" | |
| 1707 | msgid "Enable Size Filter" | |
| 1708 | msgstr "Включить фильтр по размеру" | |
| 1709 | ||
| 1710 | msgctxt "#90060" | |
| 1711 | msgid "Minimum Size (MB)" | |
| 1712 | msgstr "Минимальный размер (МБ)" | |
| 1713 | ||
| 1714 | msgctxt "#90061" | |
| 1715 | msgid "Maximum Size (MB)" | |
| 1716 | msgstr "Максимальный размер (МБ)" | |
| 1717 | ||
| 1718 | msgctxt "#90062" | |
| 1719 | msgid "Enable Cached Search" | |
| 1720 | msgstr "Включить поиск в кэше" | |
| 1721 | ||
| 1722 | msgctxt "#90063" | |
| 1723 | msgid "Only show cached streams from external services" | |
| 1724 | msgstr "Показывать только кэшированные потоки из внешних сервисов" | |
| 1725 | ||
| 1726 | msgctxt "#90064" | |
| 1727 | msgid "Popular" | |
| 1728 | msgstr "Популярные" | |
| 1729 | ||
| 1730 | msgctxt "#90065" | |
| 1731 | msgid "Languages" | |
| 1732 | msgstr "Языки" | |
| 1733 | ||
| 1734 | msgctxt "#90066" | |
| 1735 | msgid "Networks" | |
| 1736 | msgstr "Сети" | |
| 1737 | ||
| 1738 | msgctxt "#90067" | |
| 1739 | msgid "Collections" | |
| 1740 | msgstr "Коллекции" | |
| 1741 | ||
| 1742 | msgctxt "#90068" | |
| 1743 | msgid "Search for collections:" | |
| 1744 | msgstr "Поиск коллекций:" | |
| 1745 | ||
| 1746 | msgctxt "#90069" | |
| 1747 | msgid "Main Menu" | |
| 1748 | msgstr "Главное меню" | |
| 1749 | ||
| 1750 | msgctxt "#90070" | |
| 1751 | msgid "Last Titles" | |
| 1752 | msgstr "Последние названия" | |
| 1753 | ||
| 1754 | msgctxt "#90071" | |
| 1755 | msgid "Last Files" | |
| 1756 | msgstr "Последние файлы" | |
| 1757 | ||
| 1758 | msgctxt "#90072" | |
| 1759 | msgid "Trakt Lists" | |
| 1760 | msgstr "Списки Trakt" | |
| 1761 | ||
| 1762 | msgctxt "#90073" | |
| 1763 | msgid "Metadata" | |
| 1764 | msgstr "Метаданные" | |
| 1765 | ||
| 1766 | msgctxt "#90074" | |
| 1767 | msgid "Top Lists" | |
| 1768 | msgstr "Топ-списки" | |
| 1769 | ||
| 1770 | msgctxt "#90075" | |
| 1771 | msgid "User Lists" | |
| 1772 | msgstr "Пользовательские списки" | |
| 1773 | ||
| 1774 | msgctxt "#90076" | |
| 1775 | msgid "Search Lists" | |
| 1776 | msgstr "Поиск списков" | |
| 1777 | ||
| 1778 | msgctxt "#90077" | |
| 1779 | msgid "MDbList Configuration" | |
| 1780 | msgstr "Настройка MDbList" | |
| 1781 | ||
| 1782 | msgctxt "#90078" | |
| 1783 | msgid "People" | |
| 1784 | msgstr "Люди" | |
| 1785 | ||
| 1786 | msgctxt "#90079" | |
| 1787 | msgid "Popular People" | |
| 1788 | msgstr "Популярные люди" | |
| 1789 | ||
| 1790 | msgctxt "#90080" | |
| 1791 | msgid "Latest People" | |
| 1792 | msgstr "Последние люди" | |
| 1793 | ||
| 1794 | msgctxt "#90081" | |
| 1795 | msgid "Search People" | |
| 1796 | msgstr "Поиск людей" | |
| 1797 | ||
| 1798 | msgctxt "#90082" | |
| 1799 | msgid "Download subtitles" | |
| 1800 | msgstr "Загрузить субтитры" | |
| 1801 | ||
| 1802 | msgctxt "#90083" | |
| 1803 | msgid "Download video" | |
| 1804 | msgstr "Загрузить видео" | |
| 1805 | ||
| 1806 | msgctxt "#90084" | |
| 1807 | msgid "Browse into" | |
| 1808 | msgstr "Просмотреть внутри" | |
| 1809 | ||
| 1810 | msgctxt "#90085" | |
| 1811 | msgid "Include TV show Specials" | |
| 1812 | msgstr "Включить спецвыпуски ТВ-шоу" | |
| 1813 | ||
| 1814 | msgctxt "#90086" | |
| 1815 | msgid "Airing Today" | |
| 1816 | msgstr "Сегодня в эфире" | |
| 1817 | ||
| 1818 | msgctxt "#90099" | |
| 1819 | msgid "Filter by Source Type" | |
| 1820 | msgstr "Фильтр по типу источника" | |
| 1821 | ||
| 1822 | msgctxt "#90100" | |
| 1823 | msgid "Include BluRay/UHD sources" | |
| 1824 | msgstr "Включить источники BluRay/UHD" | |
| 1825 | ||
| 1826 | msgctxt "#90105" | |
| 1827 | msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1828 | msgstr "Включает релизы, такие как: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1829 | ||
| 1830 | msgctxt "#90101" | |
| 1831 | msgid "Include WEB/HD sources" | |
| 1832 | msgstr "Включить источники WEB/HD" | |
| 1833 | ||
| 1834 | msgctxt "#90106" | |
| 1835 | msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1836 | msgstr "Включает релизы, такие как: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1837 | ||
| 1838 | msgctxt "#90102" | |
| 1839 | msgid "Include DVD/TV/SAT sources" | |
| 1840 | msgstr "Включить источники DVD/TV/SAT" | |
| 1841 | ||
| 1842 | msgctxt "#90107" | |
| 1843 | msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1844 | msgstr "Включает релизы, такие как: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1845 | ||
| 1846 | msgctxt "#90103" | |
| 1847 | msgid "Include CAM/Screener sources" | |
| 1848 | msgstr "Включить источники CAM/Screener" | |
| 1849 | ||
| 1850 | msgctxt "#90108" | |
| 1851 | msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR" | |
| 1852 | msgstr "Включает релизы, такие как: CAM, TeleSync, TeleCine, SCR" | |
| 1853 | ||
| 1854 | msgctxt "#90104" | |
| 1855 | msgid "Include Unknown sources" | |
| 1856 | msgstr "Включить неизвестные источники" | |
| 1857 | ||
| 1858 | msgctxt "#90109" | |
| 1859 | msgid "Includes uncategorized or unidentified sources" | |
| 1860 | msgstr "Включает некатегоризированные или неидентифицированные источники" | |
| 1861 | ||
| 1862 | msgctxt "#90110" | |
| 1863 | msgid "Do you want to clear your watched history?." | |
| 1864 | msgstr "Вы хотите очистить историю просмотров?" | |
| 1865 | ||
| 1866 | msgctxt "#90111" | |
| 1867 | msgid "Do you want to clear your history?." | |
| 1868 | msgstr "Вы хотите очистить историю?" | |
| 1869 | ||
| 1870 | msgctxt "#90112" | |
| 1871 | msgid "Clear History" | |
| 1872 | msgstr "Очистить историю" | |
| 1873 | ||
| 1874 | msgctxt "#90113" | |
| 1875 | msgid "Click to delete all watched history immediately." | |
| 1876 | msgstr "Нажмите, чтобы немедленно удалить всю историю просмотров." | |
| 1877 | ||
| 1878 | msgctxt "#90114" | |
| 1879 | msgid "History cleared" | |
| 1880 | msgstr "История очищена" | |
| 1881 | ||
| 1882 | msgctxt "#90115" | |
| 1883 | msgid "Source Select" | |
| 1884 | msgstr "Выбор источника" | |
| 1885 | ||
| 1886 | msgctxt "#90116" | |
| 1887 | msgid "Open Settings" | |
| 1888 | msgstr "Открыть настройки" | |
| 1889 | ||
| 1890 | msgctxt "#90117" | |
| 1891 | msgid "Select Torrentio Providers" | |
| 1892 | msgstr "Выбрать провайдеров Torrentio" | |
| 1893 | ||
| 1894 | msgctxt "#90118" | |
| 1895 | msgid "Port" | |
| 1896 | msgstr "Порт" | |
| 1897 | ||
| 1898 | msgctxt "#90119" | |
| 1899 | msgid "Jackett port (default:9117)" | |
| 1900 | msgstr "Порт Jackett (по умолчанию: 9117)" | |
| 1901 | ||
| 1902 | msgctxt "#90120" | |
| 1903 | msgid "Prowlarr port (default:9696)" | |
| 1904 | msgstr "Порт Prowlarr (по умолчанию: 9696)" | |
| 1905 | ||
| 1906 | msgctxt "#90121" | |
| 1907 | msgid "All Debrid Configuration" | |
| 1908 | msgstr "Настройка All Debrid" | |
| 1909 | ||
| 1910 | msgctxt "#90122" | |
| 1911 | msgid "Auto download subtitles" | |
| 1912 | msgstr "Автозагрузка субтитров" | |
| 1913 | ||
| 1914 | msgctxt "#90123" | |
| 1915 | msgid "Stremio Timeout" | |
| 1916 | msgstr "Тайм-аут Stremio" | |
| 1917 | ||
| 1918 | msgctxt "#90124" | |
| 1919 | msgid "Webdav Client Configuration" | |
| 1920 | msgstr "Настройка клиента Webdav" | |
| 1921 | ||
| 1922 | msgctxt "#90126" | |
| 1923 | msgid "Host" | |
| 1924 | msgstr "Хост" | |
| 1925 | ||
| 1926 | msgctxt "#90127" | |
| 1927 | msgid "Username" | |
| 1928 | msgstr "Имя пользователя" | |
| 1929 | ||
| 1930 | msgctxt "#90128" | |
| 1931 | msgid "Password" | |
| 1932 | msgstr "Пароль" | |
| 1933 | ||
| 1934 | msgctxt "#90129" | |
| 1935 | msgid "Test Connection" | |
| 1936 | msgstr "Проверить соединение" | |
| 1937 | ||
| 1938 | msgctxt "#90131" | |
| 1939 | msgid "Webdav" | |
| 1940 | msgstr "Webdav" | |
| 1941 | ||
| 1942 | msgctxt "#90132" | |
| 1943 | msgid "Remote Path" | |
| 1944 | msgstr "Удаленный путь" | |
| 1945 | ||
| 1946 | msgctxt "#90133" | |
| 1947 | msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)." | |
| 1948 | msgstr "Имя хоста или IP-адрес вашего WebDAV-сервера (например, http://mywebdav.com или https://192.168.1.100)." | |
| 1949 | ||
| 1950 | msgctxt "#90134" | |
| 1951 | msgid "The username required for authenticating with your WebDAV server" | |
| 1952 | msgstr "Имя пользователя, необходимое для аутентификации на вашем WebDAV-сервере" | |
| 1953 | ||
| 1954 | msgctxt "#90135" | |
| 1955 | msgid "The password for the specified username to authenticate with your WebDAV server" | |
| 1956 | msgstr "Пароль для указанного имени пользователя для аутентификации на вашем WebDAV-сервере" | |
| 1957 | ||
| 1958 | msgctxt "#90136" | |
| 1959 | msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)." | |
| 1960 | msgstr "Базовый путь или каталог на WebDAV-сервере, где расположены ваши медиафайлы (например, /movies)." | |
| 1961 | ||
| 1962 | msgctxt "#90137" | |
| 1963 | msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials." | |
| 1964 | msgstr "Нажмите, чтобы проверить, может ли клиент WebDAV успешно подключиться к настроенному серверу с использованием предоставленных учетных данных." | |
| 1965 | ||
| 1966 | msgctxt "#90138" | |
| 1967 | msgid "Remove Custom Addon" | |
| 1968 | msgstr "Удалить пользовательский аддон" | |
| 1969 | ||
| 1970 | msgctxt "#90139" | |
| 1971 | msgid "Playback" | |
| 1972 | msgstr "Воспроизведение" | |
| 1973 | ||
| 1974 | msgctxt "#90140" | |
| 1975 | msgid "Super quick play" | |
| 1976 | msgstr "Супербыстрое воспроизведение" | |
| 1977 | ||
| 1978 | msgctxt "#90141" | |
| 1979 | msgid "Re-ask to play last played video" | |
| 1980 | msgstr "Спрашивать снова о воспроизведении последнего торрента" | |
| 1981 | ||
| 1982 | msgctxt "#90142" | |
| 1983 | msgid "Replay Dialog" | |
| 1984 | msgstr "Диалог повтора" | |
| 1985 | ||
| 1986 | msgctxt "#90143" | |
| 1987 | msgid "Do you want to play last watched?" | |
| 1988 | msgstr "Вы хотите воспроизвести последнее просмотренное?" | |
| 1989 | ||
| 1990 | msgctxt "#90144" | |
| 1991 | msgid "Could not resolve playback URL" | |
| 1992 | msgstr "Не удалось разрешить URL воспроизведения" | |
| 1993 | ||
| 1994 | msgctxt "#90145" | |
| 1995 | msgid "Precaching Enabled" | |
| 1996 | msgstr "Предкэширование включено" | |
| 1997 | ||
| 1998 | msgctxt "#90146" | |
| 1999 | msgid "Enable or disable precaching of next episodes." | |
| 2000 | msgstr "Включить или выключить предкэширование следующих серий." | |
| 2001 | ||
| 2002 | msgctxt "#90147" | |
| 2003 | msgid "Precaching Episode Count" | |
| 2004 | msgstr "Количество предкэшируемых серий" | |
| 2005 | ||
| 2006 | msgctxt "#90148" | |
| 2007 | msgid "Number of next episodes to precache." | |
| 2008 | msgstr "Количество следующих серий для предкэширования." | |
| 2009 | ||
| 2010 | msgctxt "#30901" | |
| 2011 | msgid "Show Only Stremio Catalogs" | |
| 2012 | msgstr "Показывать только каталоги Stremio" | |
| 2013 | ||
| 2014 | msgctxt "#30902" | |
| 2015 | msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus." | |
| 2016 | msgstr "Скрывает встроенные элементы (TMDb, Trakt и т. д.) и показывает только каталоги Stremio в меню." | |
| 2017 | ||
| 2018 | msgctxt "#30903" | |
| 2019 | msgid "Select Live TV Addons" | |
| 2020 | msgstr "Выбрать аддоны Live TV" | |
| 2021 | ||
| 2022 | msgctxt "#30904" | |
| 2023 | msgid "Choose specific Stremio addons for Live TV content" | |
| 2024 | msgstr "Выберите конкретные аддоны Stremio для контента Live TV" | |
| 2025 | ||
| 2026 | msgctxt "#30920" | |
| 2027 | msgid "Clear Trakt Cache" | |
| 2028 | msgstr "Очистить кэш Trakt" | |
| 2029 | ||
| 2030 | msgctxt "#30921" | |
| 2031 | msgid "Clear TMDB Cache" | |
| 2032 | msgstr "Очистить кэш TMDB" | |
| 2033 | ||
| 2034 | msgctxt "#30922" | |
| 2035 | msgid "Clear Providers Cache" | |
| 2036 | msgstr "Очистить кэш провайдеров" | |
| 2037 | ||
| 2038 | msgctxt "#30923" | |
| 2039 | msgid "Clear Settings Cache" | |
| 2040 | msgstr "Очистить кэш настроек" | |
| 2041 | ||
| 2042 | msgctxt "#90149" | |
| 2043 | msgid "Maintenance" | |
| 2044 | msgstr "Обслуживание" | |
| 2045 | ||
| 2046 | msgctxt "#90150" | |
| 2047 | msgid "Stremio: General" | |
| 2048 | msgstr "Stremio: Общие" | |
| 2049 | ||
| 2050 | msgctxt "#90151" | |
| 2051 | msgid "Stremio: Account" | |
| 2052 | msgstr "Stremio: Аккаунт" | |
| 2053 | ||
| 2054 | msgctxt "#90152" | |
| 2055 | msgid "Stremio: Addons & Catalogs" | |
| 2056 | msgstr "Stremio: Аддоны и каталоги" | |
| 2057 | ||
| 2058 | msgctxt "#90153" | |
| 2059 | msgid "Stremio: Torrentio" | |
| 2060 | msgstr "Stremio: Torrentio" | |
| 2061 | ||
| 2062 | msgctxt "#90154" | |
| 2063 | msgid "Trending Recent" | |
| 2064 | msgstr "Популярные недавно" | |
| 2065 | ||
| 2066 | msgctxt "#90155" | |
| 2067 | msgid "Stremio: Filters" | |
| 2068 | msgstr "Stremio: Фильтры" | |
| 2069 | ||
| 2070 | msgctxt "#32300" | |
| 2071 | msgid "Filter by Quality" | |
| 2072 | msgstr "Фильтр по качеству" | |
| 2073 | ||
| 2074 | msgctxt "#32301" | |
| 2075 | msgid "Filter by Provider" | |
| 2076 | msgstr "Фильтр по провайдеру" | |
| 2077 | ||
| 2078 | msgctxt "#32302" | |
| 2079 | msgid "Filter by Source" | |
| 2080 | msgstr "Фильтр по источнику" | |
| 2081 | ||
| 2082 | msgctxt "#32303" | |
| 2083 | msgid "Filter by Language" | |
| 2084 | msgstr "Фильтр по языку" | |
| 2085 | ||
| 2086 | msgctxt "#32304" | |
| 2087 | msgid "Filter by Torrents" | |
| 2088 | msgstr "Фильтр по торрентам" | |
| 2089 | ||
| 2090 | msgctxt "#32305" | |
| 2091 | msgid "Filter by Debrid" | |
| 2092 | msgstr "Фильтр по Debrid" | |
| 2093 | ||
| 2094 | msgctxt "#32306" | |
| 2095 | msgid "Reset Filter" | |
| 2096 | msgstr "Сбросить фильтр" | |
| 2097 | ||
| 2098 | msgctxt "#30930" | |
| 2099 | msgid "Ping Addons" | |
| 2100 | msgstr "Пинг аддонов" | |
| 2101 | ||
| 2102 | msgctxt "#30931" | |
| 2103 | msgid "Check availability of addons and show only reachable ones" | |
| 2104 | msgstr "Проверить доступность аддонов и показывать только доступные" | |
| 2105 | ||
| 2106 | msgctxt "#90164" | |
| 2107 | msgid "Skip Intro / Recap" | |
| 2108 | msgstr "Пропустить интро / пересказ" | |
| 2109 | ||
| 2110 | msgctxt "#90165" | |
| 2111 | msgid "Enable Skip Intro" | |
| 2112 | msgstr "Включить пропуск интро" | |
| 2113 | ||
| 2114 | msgctxt "#90166" | |
| 2115 | msgid "Detect intros and recaps using IntroDB and offer to skip them during playback" | |
| 2116 | msgstr "Обнаруживать интро и пересказы с помощью IntroDB и предлагать пропустить их во время воспроизведения" | |
| 2117 | ||
| 2118 | msgctxt "#90167" | |
| 2119 | msgid "Auto-skip" | |
| 2120 | msgstr "Автопропуск" | |
| 2121 | ||
| 2122 | msgctxt "#90168" | |
| 2123 | msgid "Automatically skip intros and recaps without showing the button" | |
| 2124 | msgstr "Автоматически пропускать интро и пересказы без показа кнопки" | |
| 2125 | ||
| 2126 | msgctxt "#90160" | |
| 2127 | msgid "Skip Intro" | |
| 2128 | msgstr "Пропустить интро" | |
| 2129 | ||
| 2130 | msgctxt "#90161" | |
| 2131 | msgid "Skip Recap" | |
| 2132 | msgstr "Пропустить пересказ" | |
| 2133 | ||
| 2134 | msgctxt "#90162" | |
| 2135 | msgid "Manage from Phone" | |
| 2136 | msgstr "Управление с телефона" | |
| 2137 | ||
| 2138 | msgctxt "#90163" | |
| 2139 | msgid "Start a local web server and show a QR code to manage Stremio sources from your phone" | |
| 2140 | msgstr "Запустить локальный веб-сервер и показать QR-код для управления источниками Stremio с телефона" | |
| 2141 | ||
| 2142 | msgctxt "#90200" | |
| 2143 | msgid "Continue Watching" | |
| 2144 | msgstr "Продолжить просмотр" | |
| 2145 | ||
| 2146 | msgctxt "#90201" | |
| 2147 | msgid "Library" | |
| 2148 | msgstr "Библиотека" | |
| 2149 | ||
| 2150 | msgctxt "#90202" | |
| 2151 | msgid "My Shows" | |
| 2152 | msgstr "Мои шоу" | |
| 2153 | ||
| 2154 | msgctxt "#90203" | |
| 2155 | msgid "My Movies" | |
| 2156 | msgstr "Мои фильмы" | |
| 2157 | ||
| 2158 | msgctxt "#90204" | |
| 2159 | msgid "Remove from Library" | |
| 2160 | msgstr "Удалить из библиотеки" | |
| 2161 | ||
| 2162 | msgctxt "#90205" | |
| 2163 | msgid "Add to Library" | |
| 2164 | msgstr "Добавить в библиотеку" | |
| 2165 | ||
| 2166 | msgctxt "#90206" | |
| 2167 | msgid "Remove" | |
| 2168 | msgstr "Удалить" | |
| 2169 | ||
| 2170 | msgctxt "#90207" | |
| 2171 | msgid "Search Movies & TV" | |
| 2172 | msgstr "Поиск фильмов и ТВ" | |
| 2173 | ||
| 2174 | msgctxt "#90208" | |
| 2175 | msgid "Recent Searches" | |
| 2176 | msgstr "Недавние поиски" | |
| 2177 | ||
| 2178 | msgctxt "#90209" | |
| 2179 | msgid "Recent Direct Searches" | |
| 2180 | msgstr "Недавние прямые поиски" | |
| 2181 | ||
| 2182 | msgctxt "#90210" | |
| 2183 | msgid "Clear All Search History" | |
| 2184 | msgstr "Очистить всю историю поиска" | |
| 2185 | ||
| 2186 | msgctxt "#90211" | |
| 2187 | msgid "Bypass Stremio Addons Native Sorting" | |
| 2188 | msgstr "Обход нативной сортировки аддонов Stremio" | |
| 2189 | ||
| 2190 | msgctxt "#90212" | |
| 2191 | msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds." | |
| 2192 | msgstr "Если включено, результаты указанных аддонов не будут переупорядочиваться или фильтроваться нативными порогами качества и размера Jacktook." | |
| 2193 | ||
| 2194 | msgctxt "#90213" | |
| 2195 | msgid "Select Bypassed Addons" | |
| 2196 | msgstr "Выбрать аддоны для обхода" | |
| 2197 | ||
| 2198 | msgctxt "#90214" | |
| 2199 | msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering." | |
| 2200 | msgstr "Выберите, какие аддоны Stremio обходят нативную фильтрацию размера и качества Jacktook." | |
| 2201 | ||
| 2202 | msgctxt "#90215" | |
| 2203 | msgid "Downgrade" | |
| 2204 | msgstr "Понижение версии" | |
| 2205 | ||
| 2206 | msgctxt "#90671" | |
| 2207 | msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails." | |
| 2208 | msgstr "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails." | |
| 2209 | ||
| 2210 | msgctxt "#30932" | |
| 2211 | msgid "Update Action" | |
| 2212 | msgstr "Действие при обновлении" | |
| 2213 | ||
| 2214 | msgctxt "#30933" | |
| 2215 | msgid "Choose how to handle background update checks." | |
| 2216 | msgstr "Выберите, как обрабатывать проверки фоновых обновлений." | |
| 2217 | ||
| 2218 | msgctxt "#30934" | |
| 2219 | msgid "Ask" | |
| 2220 | msgstr "Спрашивать" | |
| 2221 | ||
| 2222 | msgctxt "#30935" | |
| 2223 | msgid "Notify" | |
| 2224 | msgstr "Уведомлять" | |
| 2225 | ||
| 2226 | msgctxt "#90216" | |
| 2227 | msgid "Volume Check Enabled" | |
| 2228 | msgstr "Проверка громкости включена" | |
| 2229 | ||
| 2230 | msgctxt "#90217" | |
| 2231 | msgid "Automatically limits volume if it is above the threshold before playback." | |
| 2232 | msgstr "Автоматически ограничивает громкость, если она выше порога перед воспроизведением." | |
| 2233 | ||
| 2234 | msgctxt "#90218" | |
| 2235 | msgid "Volume Threshold" | |
| 2236 | msgstr "Порог громкости" | |
| 2237 | ||
| 2238 | msgctxt "#90219" | |
| 2239 | msgid "The volume percentage above which the warning triggers." | |
| 2240 | msgstr "Процент громкости, выше которого срабатывает предупреждение." | |
| 2241 | ||
| 2242 | msgctxt "#90220" | |
| 2243 | msgid "High Volume Warning" | |
| 2244 | msgstr "Предупреждение о высокой громкости" | |
| 2245 | ||
| 2246 | msgctxt "#90221" | |
| 2247 | msgid "High volume detected. Adjusting to %s%% for safety." | |
| 2248 | msgstr "Обнаружена высокая громкость. Регулировка до %s%% для безопасности." | |
| 2249 | ||
| 2250 | msgctxt "#90222" | |
| 2251 | msgid "Fanart.tv Configuration" | |
| 2252 | msgstr "Конфигурация Fanart.tv" | |
| 2253 | ||
| 2254 | msgctxt "#90223" | |
| 2255 | msgid "Client Key" | |
| 2256 | msgstr "Ключ клиента" | |
| 2257 | ||
| 2258 | msgctxt "#90224" | |
| 2259 | msgid "Your personal Fanart.tv client key for higher quality assets." | |
| 2260 | msgstr "Ваш личный ключ клиента Fanart.tv для ресурсов более высокого качества." | |
| 2261 | ||
| 2262 | msgctxt "#90225" | |
| 2263 | msgid "Debrid Notifications" | |
| 2264 | msgstr "Уведомления Debrid" | |
| 2265 | ||
| 2266 | msgctxt "#90226" | |
| 2267 | msgid "Debrid Expiration Notification" | |
| 2268 | msgstr "Уведомление об истечении Debrid" | |
| 2269 | ||
| 2270 | msgctxt "#90227" | |
| 2271 | msgid "Enable or disable notifications when your Debrid subscription is about to expire." | |
| 2272 | msgstr "Включить или выключить уведомления, когда срок вашей подписки Debrid подходит к концу." | |
| 2273 | ||
| 2274 | msgctxt "#90228" | |
| 2275 | msgid "Expiration Threshold (Days)" | |
| 2276 | msgstr "Порог истечения (дни)" | |
| 2277 | ||
| 2278 | msgctxt "#90229" | |
| 2279 | msgid "How many days before expiration to start notifying." | |
| 2280 | msgstr "За сколько дней до истечения начинать уведомление." | |
| 2281 | ||
| 2282 | msgctxt "#90230" | |
| 2283 | msgid "%s expires in %d days." | |
| 2284 | msgstr "%s истекает через %d дней." | |
| 2285 | ||
| 2286 | msgctxt "#90231" | |
| 2287 | msgid "%s expires today!" | |
| 2288 | msgstr "%s истекает сегодня!" | |
| 2289 | ||
| 2290 | msgctxt "#90232" | |
| 2291 | msgid "Easynews" | |
| 2292 | msgstr "Easynews" | |
| 2293 | ||
| 2294 | msgctxt "#90233" | |
| 2295 | msgid "Enable EasyNews as a direct stream provider." | |
| 2296 | msgstr "Включить EasyNews как провайдера прямого потока." | |
| 2297 | ||
| 2298 | msgctxt "#90234" | |
| 2299 | msgid "Your EasyNews username." | |
| 2300 | msgstr "Ваше имя пользователя EasyNews." | |
| 2301 | ||
| 2302 | msgctxt "#90235" | |
| 2303 | msgid "Your EasyNews password." | |
| 2304 | msgstr "Ваш пароль EasyNews." | |
| 2305 | ||
| 2306 | msgctxt "#90236" | |
| 2307 | msgid "Request timeout in seconds." | |
| 2308 | msgstr "Тайм-аут запроса в секундах." | |
| 2309 | ||
| 2310 | msgctxt "#90237" | |
| 2311 | msgid "Account Information" | |
| 2312 | msgstr "Информация об аккаунте" | |
| 2313 | ||
| 2314 | msgctxt "#90238" | |
| 2315 | msgid "Search Dialog Style" | |
| 2316 | msgstr "Стиль диалога поиска" | |
| 2317 | ||
| 2318 | msgctxt "#90239" | |
| 2319 | msgid "Background" | |
| 2320 | msgstr "Фон" | |
| 2321 | ||
| 2322 | msgctxt "#90240" | |
| 2323 | msgid "Detailed Status" | |
| 2324 | msgstr "Детальный статус" | |
| 2325 | ||
| 2326 | msgctxt "#90241" | |
| 2327 | msgid "Search Status" | |
| 2328 | msgstr "Статус поиска" | |
| 2329 | ||
| 2330 | msgctxt "#90242" | |
| 2331 | msgid "Cancel" | |
| 2332 | msgstr "Отмена" | |
| 2333 | ||
| 2334 | msgctxt "#90243" | |
| 2335 | msgid "Results found: " | |
| 2336 | msgstr "Найдено результатов: " | |
| 2337 | ||
| 2338 | msgctxt "#90244" | |
| 2339 | msgid "Pending" | |
| 2340 | msgstr "В ожидании" | |
| 2341 | ||
| 2342 | msgctxt "#90245" | |
| 2343 | msgid "In Progress" | |
| 2344 | msgstr "В процессе" | |
| 2345 | ||
| 2346 | msgctxt "#90246" | |
| 2347 | msgid "Completed" | |
| 2348 | msgstr "Завершено" | |
| 2349 | ||
| 2350 | msgctxt "#90247" | |
| 2351 | msgid "Failed" | |
| 2352 | msgstr "Ошибка" | |
| 2353 | ||
| 2354 | msgctxt "#90248" | |
| 2355 | msgid "Cancelled" | |
| 2356 | msgstr "Отменено" | |
| 2357 | ||
| 2358 | msgctxt "#90249" | |
| 2359 | msgid "Your personal Jackgram API token for authentication." | |
| 2360 | msgstr "Ваш личный токен API Jackgram для аутентификации." | |
| 2361 | ||
| 2362 | msgctxt "#90250" | |
| 2363 | msgid "Subtitles Found" | |
| 2364 | msgstr "Субтитры найдены" | |
| 2365 | ||
| 2366 | msgctxt "#90251" | |
| 2367 | msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?" | |
| 2368 | msgstr "Для этого видео уже есть загруженные субтитры.\nВы хотите использовать существующие субтитры?" | |
| 2369 | ||
| 2370 | msgctxt "#90252" | |
| 2371 | msgid "No subtitles found" | |
| 2372 | msgstr "Субтитры не найдены" | |
| 2373 | ||
| 2374 | msgctxt "#90253" | |
| 2375 | msgid "No subtitles selected" | |
| 2376 | msgstr "Субтитры не выбраны" | |
| 2377 | ||
| 2378 | msgctxt "#90254" | |
| 2379 | msgid "Translate Subtitles" | |
| 2380 | msgstr "Перевести субтитры" | |
| 2381 | ||
| 2382 | msgctxt "#90255" | |
| 2383 | msgid "Do you want to also translate the selected subtitles?" | |
| 2384 | msgstr "Вы хотите также перевести выбранные субтитры?" | |
| 2385 | ||
| 2386 | msgctxt "#90256" | |
| 2387 | msgid "Select Subtitle to Download" | |
| 2388 | msgstr "Выбрать субтитры для загрузки" | |
| 2389 | ||
| 2390 | msgctxt "#90257" | |
| 2391 | msgid "Subtitles Loaded" | |
| 2392 | msgstr "Субтитры загружены" | |
| 2393 | ||
| 2394 | msgctxt "#90258" | |
| 2395 | msgid "Backup" | |
| 2396 | msgstr "Резервное копирование" | |
| 2397 | ||
| 2398 | msgctxt "#90259" | |
| 2399 | msgid "Export Backup" | |
| 2400 | msgstr "Экспорт бэкапа" | |
| 2401 | ||
| 2402 | msgctxt "#90260" | |
| 2403 | msgid "Export addon settings and custom Stremio addons to a JSON backup file." | |
| 2404 | msgstr "Экспорт настроек аддона и пользовательских аддонов Stremio в файл JSON." | |
| 2405 | ||
| 2406 | msgctxt "#90261" | |
| 2407 | msgid "Restore Backup" | |
| 2408 | msgstr "Восстановить бэкап" | |
| 2409 | ||
| 2410 | msgctxt "#90262" | |
| 2411 | msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file." | |
| 2412 | msgstr "Восстановить настройки аддона и заменить пользовательские аддоны Stremio из файла JSON." | |
| 2413 | ||
| 2414 | msgctxt "#90263" | |
| 2415 | msgid "Remove Sensitive Data" | |
| 2416 | msgstr "Удалить конфиденциальные данные" | |
| 2417 | ||
| 2418 | msgctxt "#90264" | |
| 2419 | msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups." | |
| 2420 | msgstr "Удалить ключи API, пароли, имена пользователей, email-адреса, состояние авторизации и URL-адреса пользовательских аддонов Stremio из экспортируемых бэкапов." | |
| 2421 | ||
| 2422 | msgctxt "#90265" | |
| 2423 | msgid "Settings backup exported" | |
| 2424 | msgstr "Бэкап настроек экспортирован" | |
| 2425 | ||
| 2426 | msgctxt "#90266" | |
| 2427 | msgid "Settings backup restored" | |
| 2428 | msgstr "Бэкап настроек восстановлен" | |
| 2429 | ||
| 2430 | msgctxt "#90267" | |
| 2431 | msgid "Settings Backup Failed" | |
| 2432 | msgstr "Сбой бэкапа настроек" | |
| 2433 | ||
| 2434 | msgctxt "#90268" | |
| 2435 | msgid "Select export folder" | |
| 2436 | msgstr "Выберите папку для экспорта" | |
| 2437 | ||
| 2438 | msgctxt "#90269" | |
| 2439 | msgid "Select backup file" | |
| 2440 | msgstr "Выберите файл бэкапа" | |
| 2441 | ||
| 2442 | msgctxt "#90270" | |
| 2443 | msgid "Backup file is invalid." | |
| 2444 | msgstr "Файл бэкапа недействителен." | |
| 2445 | ||
| 2446 | msgctxt "#90271" | |
| 2447 | msgid "No export folder selected." | |
| 2448 | msgstr "Папка для экспорта не выбрана." | |
| 2449 | ||
| 2450 | msgctxt "#90272" | |
| 2451 | msgid "No backup file selected." | |
| 2452 | msgstr "Файл бэкапа не выбран." | |
| 2453 | ||
| 2454 | msgctxt "#90273" | |
| 2455 | msgid "Unable to export settings backup." | |
| 2456 | msgstr "Не удалось экспортировать бэкап настроек." | |
| 2457 | ||
| 2458 | msgctxt "#90274" | |
| 2459 | msgid "Unable to restore settings backup." | |
| 2460 | msgstr "Не удалось восстановить бэкап настроек." | |
| 2461 | ||
| 2462 | msgctxt "#90275" | |
| 2463 | msgid "Restore Backup" | |
| 2464 | msgstr "Восстановить бэкап" | |
| 2465 | ||
| 2466 | msgctxt "#90276" | |
| 2467 | msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?" | |
| 2468 | msgstr "Это перезапишет ваши текущие настройки аддона и заменит ваши пользовательские аддоны Stremio данными из бэкапа. Продолжить?" | |
| 2469 | ||
| 2470 | msgctxt "#90277" | |
| 2471 | msgid "Restore cancelled" | |
| 2472 | msgstr "Восстановление отменено" | |
| 2473 | ||
| 2474 | msgctxt "#90278" | |
| 2475 | msgid "Reset All Settings" | |
| 2476 | msgstr "Сбросить все настройки" | |
| 2477 | ||
| 2478 | msgctxt "#90279" | |
| 2479 | msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?" | |
| 2480 | msgstr "Это сбросит все настройки аддона к значениям по умолчанию и удалит ваши пользовательские аддоны Stremio. Продолжить?" | |
| 2481 | ||
| 2482 | msgctxt "#90280" | |
| 2483 | msgid "Reset cancelled" | |
| 2484 | msgstr "Сброс отменен" | |
| 2485 | ||
| 2486 | msgctxt "#90281" | |
| 2487 | msgid "All addon settings reset" | |
| 2488 | msgstr "Все настройки аддона сброшены" | |
| 2489 | ||
| 2490 | msgctxt "#90282" | |
| 2491 | msgid "Reset Failed" | |
| 2492 | msgstr "Сброс не удался" | |
| 2493 | ||
| 2494 | msgctxt "#90283" | |
| 2495 | msgid "Unable to reset addon settings." | |
| 2496 | msgstr "Не удалось сбросить настройки аддона." | |
| 2497 | ||
| 2498 | msgctxt "#90284" | |
| 2499 | msgid "Reset every addon setting back to its default value and clear custom Stremio addons." | |
| 2500 | msgstr "Сбросить каждую настройку аддона к значению по умолчанию и очистить пользовательские аддоны Stremio." | |
| 2501 | ||
| 2502 | msgctxt "#90285" | |
| 2503 | msgid "Factory Reset" | |
| 2504 | msgstr "Заводской сброс" | |
| 2505 | ||
| 2506 | msgctxt "#90286" | |
| 2507 | msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?" | |
| 2508 | msgstr "Это сбросит настройки, очистит кэши, удалит пользовательские аддоны Stremio и сотрет локальную историю аддона и данные базы данных. Продолжить?" | |
| 2509 | ||
| 2510 | msgctxt "#90287" | |
| 2511 | msgid "Factory reset cancelled" | |
| 2512 | msgstr "Заводской сброс отменен" | |
| 2513 | ||
| 2514 | msgctxt "#90288" | |
| 2515 | msgid "Factory reset completed" | |
| 2516 | msgstr "Заводской сброс завершен" | |
| 2517 | ||
| 2518 | msgctxt "#90289" | |
| 2519 | msgid "Factory Reset Failed" | |
| 2520 | msgstr "Заводской сброс не удался" | |
| 2521 | ||
| 2522 | msgctxt "#90290" | |
| 2523 | msgid "Unable to complete factory reset." | |
| 2524 | msgstr "Не удалось выполнить заводской сброс." | |
| 2525 | ||
| 2526 | msgctxt "#90291" | |
| 2527 | msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data." | |
| 2528 | msgstr "Сбросить настройки к значениям по умолчанию, очистить кэши и стереть локальную историю аддона и данные базы данных." | |
| 2529 | ||
| 2530 | msgctxt "#90292" | |
| 2531 | msgid "Trakt Library" | |
| 2532 | msgstr "Библиотека Trakt" | |
| 2533 | ||
| 2534 | msgctxt "#90293" | |
| 2535 | msgid "Trakt Discovery" | |
| 2536 | msgstr "Дискавери Trakt" | |
| 2537 | ||
| 2538 | msgctxt "#90294" | |
| 2539 | msgid "Collection" | |
| 2540 | msgstr "Коллекция" | |
| 2541 | ||
| 2542 | msgctxt "#90295" | |
| 2543 | msgid "Favorites" | |
| 2544 | msgstr "Избранное" | |
| 2545 | ||
| 2546 | msgctxt "#90296" | |
| 2547 | msgid "Up Next" | |
| 2548 | msgstr "Далее" | |
| 2549 | ||
| 2550 | msgctxt "#90297" | |
| 2551 | msgid "My Lists" | |
| 2552 | msgstr "Мои списки" | |
| 2553 | ||
| 2554 | msgctxt "#90298" | |
| 2555 | msgid "Liked Lists" | |
| 2556 | msgstr "Понравившиеся списки" | |
| 2557 | ||
| 2558 | msgctxt "#90331" | |
| 2559 | msgid "Stremio Addons" | |
| 2560 | msgstr "Аддоны Stremio" | |
| 2561 | ||
| 2562 | msgctxt "#90332" | |
| 2563 | msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first." | |
| 2564 | msgstr "Аддоны пока не доступны. Сначала войдите в Stremio или добавьте пользовательский аддон." | |
| 2565 | ||
| 2566 | msgctxt "#90333" | |
| 2567 | msgid "Stremio Stream Addons" | |
| 2568 | msgstr "Аддоны потоков Stremio" | |
| 2569 | ||
| 2570 | msgctxt "#90334" | |
| 2571 | msgid "Stremio Catalog Addons" | |
| 2572 | msgstr "Аддоны каталогов Stremio" | |
| 2573 | ||
| 2574 | msgctxt "#90335" | |
| 2575 | msgid "Stremio Live TV Addons" | |
| 2576 | msgstr "Аддоны Live TV Stremio" | |
| 2577 | ||
| 2578 | msgctxt "#90336" | |
| 2579 | msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged." | |
| 2580 | msgstr "Обновить аддоны, импортированные из вашего аккаунта Stremio. Пользовательские аддоны останутся без изменений." | |
| 2581 | ||
| 2582 | msgctxt "#90337" | |
| 2583 | msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first." | |
| 2584 | msgstr "Выберите, какие импортированные или пользовательские аддоны потоков Stremio использовать. Если они еще не доступны, сначала войдите в систему или добавьте пользовательский аддон." | |
| 2585 | ||
| 2586 | msgctxt "#90338" | |
| 2587 | msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first." | |
| 2588 | msgstr "Выберите, какие импортированные или пользовательские аддоны каталогов Stremio использовать. Если они еще не доступны, сначала войдите в систему или добавьте пользовательский аддон." | |
| 2589 | ||
| 2590 | msgctxt "#90339" | |
| 2591 | msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first." | |
| 2592 | msgstr "Выберите, какие импортированные или пользовательские аддоны Live TV Stremio использовать. Если они еще не доступны, сначала войдите в систему или добавьте пользовательский аддон." | |
| 2593 | ||
| 2594 | msgctxt "#90340" | |
| 2595 | msgid "Remove custom Stremio addons you previously added manually." | |
| 2596 | msgstr "Удалить пользовательские аддоны Stremio, которые вы ранее добавили вручную." | |
| 2597 | ||
| 2598 | msgctxt "#90341" | |
| 2599 | msgid "Choose Torrent Client" | |
| 2600 | msgstr "Выберите торрент-клиент" | |
| 2601 | ||
| 2602 | msgctxt "#90342" | |
| 2603 | msgid "Clear Database Cache" | |
| 2604 | msgstr "Очистить кэш базы данных" | |
| 2605 | ||
| 2606 | msgctxt "#90343" | |
| 2607 | msgid "Clear Stremio Cache" | |
| 2608 | msgstr "Очистить кэш Stremio" | |
| 2609 | ||
| 2610 | msgctxt "#90344" | |
| 2611 | msgid "Clear Debrid Cache" | |
| 2612 | msgstr "Очистить кэш Debrid" | |
| 2613 | ||
| 2614 | msgctxt "#90345" | |
| 2615 | msgid "Clear MDBList Cache" | |
| 2616 | msgstr "Очистить кэш MDBList" | |
| 2617 | ||
| 2618 | msgctxt "#90346" | |
| 2619 | msgid "Restore backup from" | |
| 2620 | msgstr "Восстановить бэкап из" | |
| 2621 | ||
| 2622 | msgctxt "#90347" | |
| 2623 | msgid "File" | |
| 2624 | msgstr "Файл" | |
| 2625 | ||
| 2626 | msgctxt "#90348" | |
| 2627 | msgid "URL" | |
| 2628 | msgstr "URL" | |
| 2629 | ||
| 2630 | msgctxt "#90349" | |
| 2631 | msgid "Enter backup URL" | |
| 2632 | msgstr "Введите URL бэкапа" | |
| 2633 | ||
| 2634 | msgctxt "#90350" | |
| 2635 | msgid "No backup URL provided." | |
| 2636 | msgstr "URL бэкапа не предоставлен." | |
| 2637 | ||
| 2638 | msgctxt "#90351" | |
| 2639 | msgid "Unable to download settings backup." | |
| 2640 | msgstr "Не удалось загрузить бэкап настроек." | |
| 2641 | ||
| 2642 | msgctxt "#90352" | |
| 2643 | msgid "Source Select Style" | |
| 2644 | msgstr "Стиль выбора источника" | |
| 2645 | ||
| 2646 | msgctxt "#90353" | |
| 2647 | msgid "Modern" | |
| 2648 | msgstr "Современный" | |
| 2649 | ||
| 2650 | msgctxt "#90354" | |
| 2651 | msgid "OLED" | |
| 2652 | msgstr "OLED" | |
| 2653 | ||
| 2654 | msgctxt "#90355" | |
| 2655 | msgid "Cyberpunk" | |
| 2656 | msgstr "Киберпанк" | |
| 2657 | ||
| 2658 | msgctxt "#90356" | |
| 2659 | msgid "Forest" | |
| 2660 | msgstr "Лес" | |
| 2661 | ||
| 2662 | msgctxt "#90357" | |
| 2663 | msgid "Gold" | |
| 2664 | msgstr "Золото" | |
| 2665 | ||
| 2666 | msgctxt "#90358" | |
| 2667 | msgid "No playable results found" | |
| 2668 | msgstr "Воспроизводимые результаты не найдены" | |
| 2669 | ||
| 2670 | msgctxt "#90359" | |
| 2671 | msgid "Add to TorrServer" | |
| 2672 | msgstr "Добавить в TorrServer" | |
| 2673 | ||
| 2674 | msgctxt "#90360" | |
| 2675 | msgid "Added to TorrServer" | |
| 2676 | msgstr "Добавлено в TorrServer" | |
| 2677 | ||
| 2678 | msgctxt "#90361" | |
| 2679 | msgid "Could not resolve torrent details" | |
| 2680 | msgstr "Не удалось разрешить детали торрента" | |
| 2681 | ||
| 2682 | msgctxt "#90362" | |
| 2683 | msgid "Added to %s cloud" | |
| 2684 | msgstr "Добавлено в облако %s" | |
| 2685 | ||
| 2686 | msgctxt "#90363" | |
| 2687 | msgid "Choose Debrid Service" | |
| 2688 | msgstr "Выберите сервис Debrid" | |
| 2689 | ||
| 2690 | msgctxt "#90364" | |
| 2691 | msgid "No supported debrid service is enabled" | |
| 2692 | msgstr "Ни один поддерживаемый сервис debrid не включен" | |
| 2693 | ||
| 2694 | msgctxt "#90365" | |
| 2695 | msgid "Download to Debrid" | |
| 2696 | msgstr "Загрузить в Debrid" | |
| 2697 | ||
| 2698 | msgctxt "#90366" | |
| 2699 | msgid "Include Unknown quality" | |
| 2700 | msgstr "Включить неизвестное качество" | |
| 2701 | ||
| 2702 | msgctxt "#90367" | |
| 2703 | msgid "Includes releases where video quality could not be identified" | |
| 2704 | msgstr "Включает релизы, качество видео в которых не удалось определить" | |
| 2705 | ||
| 2706 | msgctxt "#90368" | |
| 2707 | msgid "Search by Keyword" | |
| 2708 | msgstr "Поиск по ключевому слову" | |
| 2709 | ||
| 2710 | msgctxt "#90369" | |
| 2711 | msgid "Latest Movies" | |
| 2712 | msgstr "Последние фильмы" | |
| 2713 | ||
| 2714 | msgctxt "#90370" | |
| 2715 | msgid "Latest Series" | |
| 2716 | msgstr "Последние сериалы" | |
| 2717 | ||
| 2718 | msgctxt "#90371" | |
| 2719 | msgid "Raw Files" | |
| 2720 | msgstr "Исходные файлы" | |
| 2721 | ||
| 2722 | msgctxt "#90372" | |
| 2723 | msgid "MDblist" | |
| 2724 | msgstr "MDblist" | |
| 2725 | ||
| 2726 | msgctxt "#90373" | |
| 2727 | msgid "Active Downloads" | |
| 2728 | msgstr "Активные загрузки" | |
| 2729 | ||
| 2730 | msgctxt "#90374" | |
| 2731 | msgid "Cancel Download" | |
| 2732 | msgstr "Отменить загрузку" | |
| 2733 | ||
| 2734 | msgctxt "#90375" | |
| 2735 | msgid "Delete File" | |
| 2736 | msgstr "Удалить файл" | |
| 2737 | ||
| 2738 | msgctxt "#90376" | |
| 2739 | msgid "Search Collections" | |
| 2740 | msgstr "Поиск коллекций" | |
| 2741 | ||
| 2742 | msgctxt "#90377" | |
| 2743 | msgid "Popular Collections" | |
| 2744 | msgstr "Популярные коллекции" | |
| 2745 | ||
| 2746 | msgctxt "#90378" | |
| 2747 | msgid "Top Rated Collections" | |
| 2748 | msgstr "Лучшие коллекции" | |
| 2749 | ||
| 2750 | msgctxt "#90379" | |
| 2751 | msgid "Recommendations" | |
| 2752 | msgstr "Рекомендации" | |
| 2753 | ||
| 2754 | msgctxt "#90380" | |
| 2755 | msgid "Similar" | |
| 2756 | msgstr "Похожие" | |
| 2757 | ||
| 2758 | msgctxt "#90381" | |
| 2759 | msgid "Keyword: %s" | |
| 2760 | msgstr "Ключевое слово: %s" | |
| 2761 | ||
| 2762 | msgctxt "#90410" | |
| 2763 | msgid "Error modifying Trakt Watchlist" | |
| 2764 | msgstr "Ошибка изменения списка наблюдения Trakt" | |
| 2765 | ||
| 2766 | msgctxt "#90411" | |
| 2767 | msgid "Marked as Watched on Trakt" | |
| 2768 | msgstr "Отмечено как просмотренное на Trakt" | |
| 2769 | ||
| 2770 | msgctxt "#90412" | |
| 2771 | msgid "Error modifying Trakt Watched status" | |
| 2772 | msgstr "Ошибка изменения статуса просмотра на Trakt" | |
| 2773 | ||
| 2774 | msgctxt "#90413" | |
| 2775 | msgid "Play" | |
| 2776 | msgstr "Воспроизвести" | |
| 2777 | ||
| 2778 | msgctxt "#90414" | |
| 2779 | msgid "Trailer" | |
| 2780 | msgstr "Трейлер" | |
| 2781 | ||
| 2782 | msgctxt "#90415" | |
| 2783 | msgid "Watched" | |
| 2784 | msgstr "Просмотрено" | |
| 2785 | ||
| 2786 | msgctxt "#90416" | |
| 2787 | msgid "Refresh" | |
| 2788 | msgstr "Обновить" | |
| 2789 | ||
| 2790 | msgctxt "#90418" | |
| 2791 | msgid "No credits found for this ID" | |
| 2792 | msgstr "Кредиты для этого ID не найдены" | |
| 2793 | ||
| 2794 | msgctxt "#90419" | |
| 2795 | msgid "Credited People" | |
| 2796 | msgstr "Актеры и съемочная группа" | |
| 2797 | ||
| 2798 | msgctxt "#90420" | |
| 2799 | msgid "Person Details" | |
| 2800 | msgstr "Сведения о человеке" | |
| 2801 | ||
| 2802 | msgctxt "#90421" | |
| 2803 | msgid "Person not found" | |
| 2804 | msgstr "Человек не найден" | |
| 2805 | ||
| 2806 | msgctxt "#90422" | |
| 2807 | msgid "Jackgram not activated" | |
| 2808 | msgstr "Индексатор Jackgram не активирован." | |
| 2809 | ||
| 2810 | msgctxt "#90423" | |
| 2811 | msgid "Not yet implemented" | |
| 2812 | msgstr "Еще не реализовано" | |
| 2813 | ||
| 2814 | msgctxt "#90424" | |
| 2815 | msgid "Unsupported debrid type" | |
| 2816 | msgstr "Неподдерживаемый тип debrid" | |
| 2817 | ||
| 2818 | msgctxt "#90425" | |
| 2819 | msgid "Easynews credentials required" | |
| 2820 | msgstr "Требуются учетные данные Easynews" | |
| 2821 | ||
| 2822 | msgctxt "#90426" | |
| 2823 | msgid "No Corrupt or Missing Databases" | |
| 2824 | msgstr "Нет поврежденных или отсутствующих баз данных" | |
| 2825 | ||
| 2826 | msgctxt "#90427" | |
| 2827 | msgid "Otaku (script.otaku.mappings) not found" | |
| 2828 | msgstr "Otaku (script.otaku.mappings) не найден" | |
| 2829 | ||
| 2830 | msgctxt "#90431" | |
| 2831 | msgid "No items found in this list" | |
| 2832 | msgstr "В этом списке ничего не найдено" | |
| 2833 | ||
| 2834 | msgctxt "#90432" | |
| 2835 | msgid "Databases Rebuilt" | |
| 2836 | msgstr "Базы данных пересозданы" | |
| 2837 | ||
| 2838 | msgctxt "#90433" | |
| 2839 | msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s" | |
| 2840 | msgstr "[B]Были пересозданы следующие базы данных:[/B][CR][CR]%s" | |
| 2841 | ||
| 2842 | msgctxt "#90434" | |
| 2843 | msgid "Added to Trakt watchlist" | |
| 2844 | msgstr "Добавлено в список просмотра Trakt" | |
| 2845 | ||
| 2846 | msgctxt "#90435" | |
| 2847 | msgid "Failed to add to Trakt watchlist" | |
| 2848 | msgstr "Не удалось добавить в список просмотра Trakt" | |
| 2849 | ||
| 2850 | msgctxt "#90436" | |
| 2851 | msgid "Removed from Trakt watchlist" | |
| 2852 | msgstr "Удалено из списка просмотра Trakt" | |
| 2853 | ||
| 2854 | msgctxt "#90437" | |
| 2855 | msgid "Failed to remove from Trakt watchlist" | |
| 2856 | msgstr "Не удалось удалить из списка просмотра Trakt" | |
| 2857 | ||
| 2858 | msgctxt "#90438" | |
| 2859 | msgid "Failed to mark as watched on Trakt" | |
| 2860 | msgstr "Не удалось отметить как просмотренное в Trakt" | |
| 2861 | ||
| 2862 | msgctxt "#90439" | |
| 2863 | msgid "Marked as unwatched on Trakt" | |
| 2864 | msgstr "Отмечено как непросмотренное в Trakt" | |
| 2865 | ||
| 2866 | msgctxt "#90440" | |
| 2867 | msgid "Failed to mark as unwatched on Trakt" | |
| 2868 | msgstr "Не удалось отметить как непросмотренное в Trakt" | |
| 2869 | ||
| 2870 | msgctxt "#90441" | |
| 2871 | msgid "Added to Trakt collection" | |
| 2872 | msgstr "Добавлено в коллекцию Trakt" | |
| 2873 | ||
| 2874 | msgctxt "#90442" | |
| 2875 | msgid "Removed from Trakt collection" | |
| 2876 | msgstr "Удалено из коллекции Trakt" | |
| 2877 | ||
| 2878 | msgctxt "#90443" | |
| 2879 | msgid "Failed to add to Trakt collection" | |
| 2880 | msgstr "Не удалось добавить в коллекцию Trakt" | |
| 2881 | ||
| 2882 | msgctxt "#90444" | |
| 2883 | msgid "Failed to remove from Trakt collection" | |
| 2884 | msgstr "Не удалось удалить из коллекции Trakt" | |
| 2885 | ||
| 2886 | msgctxt "#90445" | |
| 2887 | msgid "Added to Trakt favorites" | |
| 2888 | msgstr "Добавлено в избранное Trakt" | |
| 2889 | ||
| 2890 | msgctxt "#90446" | |
| 2891 | msgid "Removed from Trakt favorites" | |
| 2892 | msgstr "Удалено из избранного Trakt" | |
| 2893 | ||
| 2894 | msgctxt "#90447" | |
| 2895 | msgid "Failed to add to Trakt favorites" | |
| 2896 | msgstr "Не удалось добавить в избранное Trakt" | |
| 2897 | ||
| 2898 | msgctxt "#90448" | |
| 2899 | msgid "Failed to remove from Trakt favorites" | |
| 2900 | msgstr "Не удалось удалить из избранного Trakt" | |
| 2901 | ||
| 2902 | msgctxt "#90449" | |
| 2903 | msgid "Created Trakt list" | |
| 2904 | msgstr "Список Trakt создан" | |
| 2905 | ||
| 2906 | msgctxt "#90450" | |
| 2907 | msgid "Failed to create Trakt list" | |
| 2908 | msgstr "Не удалось создать список Trakt" | |
| 2909 | ||
| 2910 | msgctxt "#90451" | |
| 2911 | msgid "Deleted Trakt list" | |
| 2912 | msgstr "Список Trakt удален" | |
| 2913 | ||
| 2914 | msgctxt "#90452" | |
| 2915 | msgid "Failed to delete Trakt list" | |
| 2916 | msgstr "Не удалось удалить список Trakt" | |
| 2917 | ||
| 2918 | msgctxt "#90453" | |
| 2919 | msgid "Liked Trakt list" | |
| 2920 | msgstr "Лайк поставлен списку Trakt" | |
| 2921 | ||
| 2922 | msgctxt "#90454" | |
| 2923 | msgid "Failed to like Trakt list" | |
| 2924 | msgstr "Не удалось поставить лайк списку Trakt" | |
| 2925 | ||
| 2926 | msgctxt "#90455" | |
| 2927 | msgid "Unliked Trakt list" | |
| 2928 | msgstr "Лайк со списка Trakt снят" | |
| 2929 | ||
| 2930 | msgctxt "#90456" | |
| 2931 | msgid "Failed to unlike Trakt list" | |
| 2932 | msgstr "Не удалось снять лайк со списка Trakt" | |
| 2933 | ||
| 2934 | msgctxt "#90457" | |
| 2935 | msgid "Added to Trakt list" | |
| 2936 | msgstr "Добавлено в список Trakt" | |
| 2937 | ||
| 2938 | msgctxt "#90459" | |
| 2939 | msgid "Failed to add to Trakt list" | |
| 2940 | msgstr "Не удалось добавить в список Trakt" | |
| 2941 | ||
| 2942 | msgctxt "#90461" | |
| 2943 | msgid "No Trakt lists found" | |
| 2944 | msgstr "Списки Trakt не найдены" | |
| 2945 | ||
| 2946 | msgctxt "#90462" | |
| 2947 | msgid "Item not found in Trakt collection" | |
| 2948 | msgstr "Элемент не найден в коллекции Trakt" | |
| 2949 | ||
| 2950 | msgctxt "#90463" | |
| 2951 | msgid "Trakt could not match this item for collection" | |
| 2952 | msgstr "Trakt не смог сопоставить этот элемент с коллекцией" | |
| 2953 | ||
| 2954 | msgctxt "#90464" | |
| 2955 | msgid "No Trakt collection changes were applied" | |
| 2956 | msgstr "Изменения в коллекции Trakt не были применены" | |
| 2957 | ||
| 2958 | msgctxt "#90465" | |
| 2959 | msgid "Could not build Trakt collection payload for show" | |
| 2960 | msgstr "Не удалось сформировать payload коллекции Trakt для сериала" | |
| 2961 | ||
| 2962 | msgctxt "#90466" | |
| 2963 | msgid "Delete this Trakt list?" | |
| 2964 | msgstr "Удалить этот список Trakt?" | |
| 2965 | ||
| 2966 | msgctxt "#90510" | |
| 2967 | msgid "No meta available" | |
| 2968 | msgstr "Метаданные недоступны" | |
| 2969 | ||
| 2970 | msgctxt "#90511" | |
| 2971 | msgid "No content available for" | |
| 2972 | msgstr "Нет доступного контента для" | |
| 2973 | ||
| 2974 | msgctxt "#90512" | |
| 2975 | msgid "Season" | |
| 2976 | msgstr "Сезон" | |
| 2977 | ||
| 2978 | msgctxt "#90513" | |
| 2979 | msgid "No episodes available" | |
| 2980 | msgstr "Эпизоды недоступны" | |
| 2981 | ||
| 2982 | msgctxt "#90514" | |
| 2983 | msgid "No videos available" | |
| 2984 | msgstr "Видео недоступны" | |
| 2985 | ||
| 2986 | msgctxt "#90515" | |
| 2987 | msgid "Next Page" | |
| 2988 | msgstr "Следующая страница" | |
| 2989 | ||
| 2990 | msgctxt "#90516" | |
| 2991 | msgid "No metas available" | |
| 2992 | msgstr "Метаданные недоступны" | |
| 2993 | ||
| 2994 | msgctxt "#90517" | |
| 2995 | msgid "No seasons available" | |
| 2996 | msgstr "Сезоны недоступны" | |
| 2997 | ||
| 2998 | msgctxt "#90518" | |
| 2999 | msgid "Invalid query" | |
| 3000 | msgstr "Недопустимый запрос" | |
| 3001 | ||
| 3002 | msgctxt "#90519" | |
| 3003 | msgid "No lists found" | |
| 3004 | msgstr "Списки не найдены" | |
| 3005 | ||
| 3006 | msgctxt "#90520" | |
| 3007 | msgid "No user lists found" | |
| 3008 | msgstr "Пользовательские списки не найдены" | |
| 3009 | ||
| 3010 | msgctxt "#90521" | |
| 3011 | msgid "No top lists found" | |
| 3012 | msgstr "Лучшие списки не найдены" | |
| 3013 | ||
| 3014 | msgctxt "#90522" | |
| 3015 | msgid "Custom Addon" | |
| 3016 | msgstr "Пользовательский аддон" | |
| 3017 | ||
| 3018 | msgctxt "#90523" | |
| 3019 | msgid "Enter the custom Stremio addon URL" | |
| 3020 | msgstr "Введите URL пользовательского аддона Stremio" | |
| 3021 | ||
| 3022 | msgctxt "#90524" | |
| 3023 | msgid "No URL provided." | |
| 3024 | msgstr "URL не указан." | |
| 3025 | ||
| 3026 | msgctxt "#90525" | |
| 3027 | msgid "Failed to fetch manifest: %s" | |
| 3028 | msgstr "Не удалось получить манифест: %s" | |
| 3029 | ||
| 3030 | msgctxt "#90526" | |
| 3031 | msgid "Manifest missing 'id' or 'name'." | |
| 3032 | msgstr "В манифесте отсутствует 'id' или 'name'." | |
| 3033 | ||
| 3034 | msgctxt "#90527" | |
| 3035 | msgid "Custom Stremio addon added successfully!" | |
| 3036 | msgstr "Пользовательский аддон Stremio успешно добавлен!" | |
| 3037 | ||
| 3038 | msgctxt "#90528" | |
| 3039 | msgid "Addon does not provide 'stream' or 'catalog' resources." | |
| 3040 | msgstr "Аддон не предоставляет ресурсы 'stream' или 'catalog'." | |
| 3041 | ||
| 3042 | msgctxt "#90529" | |
| 3043 | msgid "This addon is already added to your list." | |
| 3044 | msgstr "Этот аддон уже добавлен в ваш список." | |
| 3045 | ||
| 3046 | msgctxt "#90530" | |
| 3047 | msgid "Failed to add custom addon: %s" | |
| 3048 | msgstr "Не удалось добавить пользовательский аддон: %s" | |
| 3049 | ||
| 3050 | msgctxt "#90531" | |
| 3051 | msgid "Remove Addon" | |
| 3052 | msgstr "Удалить аддон" | |
| 3053 | ||
| 3054 | msgctxt "#90532" | |
| 3055 | msgid "No custom Stremio addons to remove." | |
| 3056 | msgstr "Нет пользовательских аддонов Stremio для удаления." | |
| 3057 | ||
| 3058 | msgctxt "#90533" | |
| 3059 | msgid "Remove Stremio Addons" | |
| 3060 | msgstr "Удалить аддоны Stremio" | |
| 3061 | ||
| 3062 | msgctxt "#90534" | |
| 3063 | msgid "Selected addon(s) removed." | |
| 3064 | msgstr "Выбранные аддоны удалены." | |
| 3065 | ||
| 3066 | msgctxt "#90535" | |
| 3067 | msgid "Untitled List" | |
| 3068 | msgstr "Список без названия" | |
| 3069 | ||
| 3070 | msgctxt "#90536" | |
| 3071 | msgid "Removed from Trakt list" | |
| 3072 | msgstr "Удалено из списка Trakt" | |
| 3073 | ||
| 3074 | msgctxt "#90537" | |
| 3075 | msgid "Failed to remove from Trakt list" | |
| 3076 | msgstr "Не удалось удалить из списка Trakt" | |
| 3077 | ||
| 3078 | msgctxt "#90538" | |
| 3079 | msgid "Resume from %.1f%%" | |
| 3080 | msgstr "Продолжить с %.1f%%" | |
| 3081 | ||
| 3082 | msgctxt "#90539" | |
| 3083 | msgid "Start from Beginning" | |
| 3084 | msgstr "Начать сначала" | |
| 3085 | ||
| 3086 | msgctxt "#90540" | |
| 3087 | msgid "Authorize Debrid Services" | |
| 3088 | msgstr "Авторизовать сервисы Debrid" | |
| 3089 | ||
| 3090 | msgctxt "#90541" | |
| 3091 | msgid "Navigate to: [B]%s[/B]" | |
| 3092 | msgstr "Перейдите по адресу: [B]%s[/B]" | |
| 3093 | ||
| 3094 | msgctxt "#90542" | |
| 3095 | msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3096 | msgstr "Введите следующий код: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3097 | ||
| 3098 | msgctxt "#90543" | |
| 3099 | msgid "Premiumize Auth" | |
| 3100 | msgstr "Авторизация Premiumize" | |
| 3101 | ||
| 3102 | msgctxt "#90544" | |
| 3103 | msgid "Success" | |
| 3104 | msgstr "Успешно" | |
| 3105 | ||
| 3106 | msgctxt "#90545" | |
| 3107 | msgid "Authentication completed." | |
| 3108 | msgstr "Аутентификация завершена." | |
| 3109 | ||
| 3110 | msgctxt "#90546" | |
| 3111 | msgid "Error:" | |
| 3112 | msgstr "Ошибка:" | |
| 3113 | ||
| 3114 | msgctxt "#90547" | |
| 3115 | msgid "Error: %s" | |
| 3116 | msgstr "Ошибка: %s" | |
| 3117 | ||
| 3118 | msgctxt "#90548" | |
| 3119 | msgid "Auth Error" | |
| 3120 | msgstr "Ошибка авторизации" | |
| 3121 | ||
| 3122 | msgctxt "#90549" | |
| 3123 | msgid "Torbox authentication successful." | |
| 3124 | msgstr "Аутентификация Torbox успешно завершена." | |
| 3125 | ||
| 3126 | msgctxt "#90554" | |
| 3127 | msgid "Please wait..." | |
| 3128 | msgstr "Пожалуйста, подождите..." | |
| 3129 | ||
| 3130 | msgctxt "#90555" | |
| 3131 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3132 | msgstr "Перейдите:\n[COLOR cyan]%s[/COLOR]\nВведите код: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3133 | ||
| 3134 | msgctxt "#90556" | |
| 3135 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3136 | msgstr "Перейдите:\n[COLOR cyan]%s[/COLOR]\n\nВведите код: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3137 | ||
| 3138 | msgctxt "#90557" | |
| 3139 | msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]" | |
| 3140 | msgstr "Ссылка Pastebin:\n[COLOR cyan]%s[/COLOR]" | |
| 3141 | ||
| 3142 | msgctxt "#90670" | |
| 3143 | msgid "Local URL:\n[COLOR cyan]%s[/COLOR]" | |
| 3144 | msgstr "Локальный URL:\n[COLOR cyan]%s[/COLOR]" | |
| 3145 | ||
| 3146 | msgctxt "#90558" | |
| 3147 | msgid "QR Code Authentication" | |
| 3148 | msgstr "Аутентификация по QR-коду" | |
| 3149 | ||
| 3150 | msgctxt "#90559" | |
| 3151 | msgid "Kodi 20 or above required. Please update Kodi to use this addon." | |
| 3152 | msgstr "Требуется Kodi 20 или выше. Обновите Kodi, чтобы использовать этот аддон." | |
| 3153 | ||
| 3154 | msgctxt "#90560" | |
| 3155 | msgid "Failed to start web server. Port may be in use." | |
| 3156 | msgstr "Не удалось запустить веб-сервер. Возможно, порт уже используется." | |
| 3157 | ||
| 3158 | msgctxt "#90561" | |
| 3159 | msgid "Authentification Removed." | |
| 3160 | msgstr "Аутентификация удалена." | |
| 3161 | ||
| 3162 | msgctxt "#90562" | |
| 3163 | msgid "WebDAV Error" | |
| 3164 | msgstr "Ошибка WebDAV" | |
| 3165 | ||
| 3166 | msgctxt "#90563" | |
| 3167 | msgid "Trakt Authorization" | |
| 3168 | msgstr "Авторизация Trakt" | |
| 3169 | ||
| 3170 | msgctxt "#90564" | |
| 3171 | msgid "Do you want to continue transfer in background?" | |
| 3172 | msgstr "Хотите продолжить передачу в фоновом режиме?" | |
| 3173 | ||
| 3174 | msgctxt "#90565" | |
| 3175 | msgid "Cloud Transfer" | |
| 3176 | msgstr "Передача в облако" | |
| 3177 | ||
| 3178 | msgctxt "#90566" | |
| 3179 | msgid "Kodi Logs" | |
| 3180 | msgstr "Журналы Kodi" | |
| 3181 | ||
| 3182 | msgctxt "#90567" | |
| 3183 | msgid "Show Logs" | |
| 3184 | msgstr "Показать журналы" | |
| 3185 | ||
| 3186 | msgctxt "#90568" | |
| 3187 | msgid "Export to paste.kodi.tv" | |
| 3188 | msgstr "Экспортировать в paste.kodi.tv" | |
| 3189 | ||
| 3190 | msgctxt "#90569" | |
| 3191 | msgid "Kodi Logs Exported" | |
| 3192 | msgstr "Журналы Kodi экспортированы" | |
| 3193 | ||
| 3194 | msgctxt "#90570" | |
| 3195 | msgid "Providers Selection Dialog" | |
| 3196 | msgstr "Диалог выбора провайдеров" | |
| 3197 | ||
| 3198 | msgctxt "#90571" | |
| 3199 | msgid "Your current Providers are: \n%s\n\nDo you want to change?" | |
| 3200 | msgstr "Текущие провайдеры: \n%s\n\nХотите изменить?" | |
| 3201 | ||
| 3202 | msgctxt "#90572" | |
| 3203 | msgid "Select Providers" | |
| 3204 | msgstr "Выберите провайдеров" | |
| 3205 | ||
| 3206 | msgctxt "#90573" | |
| 3207 | msgid "Selection Dialog" | |
| 3208 | msgstr "Диалог выбора" | |
| 3209 | ||
| 3210 | msgctxt "#90574" | |
| 3211 | msgid "Successfully selected: %s" | |
| 3212 | msgstr "Успешно выбрано: %s" | |
| 3213 | ||
| 3214 | msgctxt "#90575" | |
| 3215 | msgid "Selection" | |
| 3216 | msgstr "Выбор" | |
| 3217 | ||
| 3218 | msgctxt "#90576" | |
| 3219 | msgid "No providers selected" | |
| 3220 | msgstr "Провайдеры не выбраны" | |
| 3221 | ||
| 3222 | msgctxt "#90577" | |
| 3223 | msgid "Changelog" | |
| 3224 | msgstr "Список изменений" | |
| 3225 | ||
| 3226 | msgctxt "#90578" | |
| 3227 | msgid "[B]Unable to check for updates[/B]" | |
| 3228 | msgstr "[B]Не удалось проверить обновления[/B]" | |
| 3229 | ||
| 3230 | msgctxt "#90579" | |
| 3231 | msgid "[B]No update available[/B]" | |
| 3232 | msgstr "[B]Обновлений нет[/B]" | |
| 3233 | ||
| 3234 | msgctxt "#90580" | |
| 3235 | msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3236 | msgstr "Установлено: [B]%s[/B][CR]Доступно онлайн: [B]%s[/B][CR][CR]" | |
| 3237 | ||
| 3238 | msgctxt "#90581" | |
| 3239 | msgid "[B]Update available. Do you want to update?[/B]" | |
| 3240 | msgstr "[B]Доступно обновление. Хотите обновить?[/B]" | |
| 3241 | ||
| 3242 | msgctxt "#90582" | |
| 3243 | msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]" | |
| 3244 | msgstr "[B]Доступно обновление (v%s).[/B] [I]Проверьте настройки для обновления.[/I]" | |
| 3245 | ||
| 3246 | msgctxt "#90583" | |
| 3247 | msgid "Error fetching versions: %s" | |
| 3248 | msgstr "Ошибка получения версий: %s" | |
| 3249 | ||
| 3250 | msgctxt "#90584" | |
| 3251 | msgid "No older/different versions found." | |
| 3252 | msgstr "Старые или отличающиеся версии не найдены." | |
| 3253 | ||
| 3254 | msgctxt "#90585" | |
| 3255 | msgid "Select Version to Downgrade" | |
| 3256 | msgstr "Выберите версию для отката" | |
| 3257 | ||
| 3258 | msgctxt "#90586" | |
| 3259 | msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?" | |
| 3260 | msgstr "Вы уверены, что хотите откатить или переустановить версию [B]%s[/B]?" | |
| 3261 | ||
| 3262 | msgctxt "#90587" | |
| 3263 | msgid "[B]Downloading update...[/B]" | |
| 3264 | msgstr "[B]Загрузка обновления...[/B]" | |
| 3265 | ||
| 3266 | msgctxt "#90588" | |
| 3267 | msgid "Error: Unable to download update." | |
| 3268 | msgstr "Ошибка: не удалось загрузить обновление." | |
| 3269 | ||
| 3270 | msgctxt "#90589" | |
| 3271 | msgid "Error saving update file: %s" | |
| 3272 | msgstr "Ошибка сохранения файла обновления: %s" | |
| 3273 | ||
| 3274 | msgctxt "#90590" | |
| 3275 | msgid "Error extracting update. Please install manually." | |
| 3276 | msgstr "Ошибка распаковки обновления. Установите вручную." | |
| 3277 | ||
| 3278 | msgctxt "#90591" | |
| 3279 | msgid "Do you want to view the changelog for the new version?" | |
| 3280 | msgstr "Хотите посмотреть список изменений новой версии?" | |
| 3281 | ||
| 3282 | msgctxt "#90592" | |
| 3283 | msgid "New Release v%s - Changelog" | |
| 3284 | msgstr "Новый релиз v%s - Список изменений" | |
| 3285 | ||
| 3286 | msgctxt "#90593" | |
| 3287 | msgid "[B]Update complete.[/B]" | |
| 3288 | msgstr "[B]Обновление завершено.[/B]" | |
| 3289 | ||
| 3290 | msgctxt "#90594" | |
| 3291 | msgid "Real Debrid Auth" | |
| 3292 | msgstr "Авторизация Real Debrid" | |
| 3293 | ||
| 3294 | msgctxt "#90595" | |
| 3295 | msgid "Torbox Auth" | |
| 3296 | msgstr "Авторизация Torbox" | |
| 3297 | ||
| 3298 | msgctxt "#90596" | |
| 3299 | msgid "Debrider Auth" | |
| 3300 | msgstr "Авторизация Debrider" | |
| 3301 | ||
| 3302 | msgctxt "#90597" | |
| 3303 | msgid "Download Error" | |
| 3304 | msgstr "Ошибка загрузки" | |
| 3305 | ||
| 3306 | msgctxt "#90598" | |
| 3307 | msgid "Failed to download text: %s" | |
| 3308 | msgstr "Не удалось загрузить текст: %s" | |
| 3309 | ||
| 3310 | msgctxt "#90599" | |
| 3311 | msgid "General Error" | |
| 3312 | msgstr "Общая ошибка" | |
| 3313 | ||
| 3314 | msgctxt "#90600" | |
| 3315 | msgid "Could not display text file." | |
| 3316 | msgstr "Не удалось отобразить текстовый файл." | |
| 3317 | ||
| 3318 | msgctxt "#90601" | |
| 3319 | msgid "Failed to export logs." | |
| 3320 | msgstr "Не удалось экспортировать журналы." | |
| 3321 | ||
| 3322 | msgctxt "#90602" | |
| 3323 | msgid "Error reading log: %s" | |
| 3324 | msgstr "Ошибка чтения журнала: %s" | |
| 3325 | ||
| 3326 | msgctxt "#90603" | |
| 3327 | msgid "Kodi log file not found." | |
| 3328 | msgstr "Файл журнала Kodi не найден." | |
| 3329 | ||
| 3330 | msgctxt "#90604" | |
| 3331 | msgid "%s Auth" | |
| 3332 | msgstr "Авторизация %s" | |
| 3333 | ||
| 3334 | msgctxt "#90605" | |
| 3335 | msgid "Ok" | |
| 3336 | msgstr "Ок" | |
| 3337 | ||
| 3338 | msgctxt "#90606" | |
| 3339 | msgid "No" | |
| 3340 | msgstr "Нет" | |
| 3341 | ||
| 3342 | msgctxt "#90607" | |
| 3343 | msgid "Pinging addons for availability..." | |
| 3344 | msgstr "Проверка доступности аддонов..." | |
| 3345 | ||
| 3346 | msgctxt "#90608" | |
| 3347 | msgid "Pinging addons... %d/%d" | |
| 3348 | msgstr "Проверка аддонов... %d/%d" | |
| 3349 | ||
| 3350 | msgctxt "#90609" | |
| 3351 | msgid "Ping Results" | |
| 3352 | msgstr "Результаты ping" | |
| 3353 | ||
| 3354 | msgctxt "#90610" | |
| 3355 | msgid "Reachable: %d\nUnreachable: %d" | |
| 3356 | msgstr "Доступно: %d\nНедоступно: %d" | |
| 3357 | ||
| 3358 | msgctxt "#90611" | |
| 3359 | msgid "Select Category to Filter" | |
| 3360 | msgstr "Выберите категорию для фильтрации" | |
| 3361 | ||
| 3362 | msgctxt "#90612" | |
| 3363 | msgid "Stremio Add-ons Import" | |
| 3364 | msgstr "Импорт аддонов Stremio" | |
| 3365 | ||
| 3366 | msgctxt "#90613" | |
| 3367 | msgid "To import your add-ons, please log in with your Stremio email and password." | |
| 3368 | msgstr "Чтобы импортировать ваши аддоны, войдите с помощью email и пароля Stremio." | |
| 3369 | ||
| 3370 | msgctxt "#90614" | |
| 3371 | msgid "Enter your Email" | |
| 3372 | msgstr "Введите ваш email" | |
| 3373 | ||
| 3374 | msgctxt "#90615" | |
| 3375 | msgid "Enter your Password" | |
| 3376 | msgstr "Введите ваш пароль" | |
| 3377 | ||
| 3378 | msgctxt "#90616" | |
| 3379 | msgid "Login Failed" | |
| 3380 | msgstr "Ошибка входа" | |
| 3381 | ||
| 3382 | msgctxt "#90617" | |
| 3383 | msgid "Failed to login: %s" | |
| 3384 | msgstr "Не удалось войти: %s" | |
| 3385 | ||
| 3386 | msgctxt "#90618" | |
| 3387 | msgid "Add-ons Import Failed" | |
| 3388 | msgstr "Ошибка импорта аддонов" | |
| 3389 | ||
| 3390 | msgctxt "#90619" | |
| 3391 | msgid "Please try again later and report the issue if the problem persists. For more details, check the log file." | |
| 3392 | msgstr "Пожалуйста, попробуйте позже и сообщите о проблеме, если она не исчезнет. Подробности смотрите в файле журнала." | |
| 3393 | ||
| 3394 | msgctxt "#90620" | |
| 3395 | msgid "Addons Imported" | |
| 3396 | msgstr "Аддоны импортированы" | |
| 3397 | ||
| 3398 | msgctxt "#90621" | |
| 3399 | msgid "Successfully imported addons from your account." | |
| 3400 | msgstr "Аддоны из вашей учетной записи успешно импортированы." | |
| 3401 | ||
| 3402 | msgctxt "#90622" | |
| 3403 | msgid "Update Stremio Addons" | |
| 3404 | msgstr "Обновить аддоны Stremio" | |
| 3405 | ||
| 3406 | msgctxt "#90623" | |
| 3407 | msgid "Do you want to update the Addons from your account?" | |
| 3408 | msgstr "Хотите обновить аддоны из вашей учетной записи?" | |
| 3409 | ||
| 3410 | msgctxt "#90624" | |
| 3411 | msgid "Log Out from Stremio" | |
| 3412 | msgstr "Выйти из Stremio" | |
| 3413 | ||
| 3414 | msgctxt "#90625" | |
| 3415 | msgid "Are you sure you want to log out? You can continue using Stremio without logging in, but your settings will be reset to the default configuration." | |
| 3416 | msgstr "Вы уверены, что хотите выйти? Вы можете продолжать использовать Stremio без входа, но настройки будут сброшены к значениям по умолчанию." | |
| 3417 | ||
| 3418 | msgctxt "#90626" | |
| 3419 | msgid "Log Out" | |
| 3420 | msgstr "Выйти" | |
| 3421 | ||
| 3422 | msgctxt "#90627" | |
| 3423 | msgid "Use Existing" | |
| 3424 | msgstr "Использовать существующие" | |
| 3425 | ||
| 3426 | msgctxt "#90628" | |
| 3427 | msgid "Download New" | |
| 3428 | msgstr "Скачать новые" | |
| 3429 | ||
| 3430 | msgctxt "#90629" | |
| 3431 | msgid "Playback Failed" | |
| 3432 | msgstr "Ошибка воспроизведения" | |
| 3433 | ||
| 3434 | msgctxt "#90630" | |
| 3435 | msgid "The video could not be played.\nDo you want to retry?" | |
| 3436 | msgstr "Видео не удалось воспроизвести.\nХотите попробовать снова?" | |
| 3437 | ||
| 3438 | msgctxt "#90631" | |
| 3439 | msgid "Retry" | |
| 3440 | msgstr "Повторить" | |
| 3441 | ||
| 3442 | msgctxt "#90632" | |
| 3443 | msgid "Information" | |
| 3444 | msgstr "Информация" | |
| 3445 | ||
| 3446 | msgctxt "#90633" | |
| 3447 | msgid "Review" | |
| 3448 | msgstr "Обзор" | |
| 3449 | ||
| 3450 | msgctxt "#90634" | |
| 3451 | msgid "Comment" | |
| 3452 | msgstr "Комментарий" | |
| 3453 | ||
| 3454 | msgctxt "#90635" | |
| 3455 | msgid "Trivia" | |
| 3456 | msgstr "Факты" | |
| 3457 | ||
| 3458 | msgctxt "#90636" | |
| 3459 | msgid "Goof" | |
| 3460 | msgstr "Ляп" | |
| 3461 | ||
| 3462 | msgctxt "#90637" | |
| 3463 | msgid "Parental Guide" | |
| 3464 | msgstr "Родительский контроль" | |
| 3465 | ||
| 3466 | msgctxt "#90638" | |
| 3467 | msgid "Converting Magnet...\n\n%s" | |
| 3468 | msgstr "Преобразование magnet...\n\n%s" | |
| 3469 | ||
| 3470 | msgctxt "#90639" | |
| 3471 | msgid "Real Debrid Error." | |
| 3472 | msgstr "Ошибка Real Debrid." | |
| 3473 | ||
| 3474 | msgctxt "#90640" | |
| 3475 | msgid "File already cached" | |
| 3476 | msgstr "Файл уже в кеше" | |
| 3477 | ||
| 3478 | msgctxt "#90641" | |
| 3479 | msgid "Saving file to the Real Debrid Cloud" | |
| 3480 | msgstr "Сохранение файла в облако Real Debrid" | |
| 3481 | ||
| 3482 | msgctxt "#90642" | |
| 3483 | msgid "File cached" | |
| 3484 | msgstr "Файл в кеше" | |
| 3485 | ||
| 3486 | msgctxt "#90643" | |
| 3487 | msgid "Saving File to the Real Debrid Cloud...\n%s\n\n" | |
| 3488 | msgstr "Сохранение файла в облако Real Debrid...\n%s\n\n" | |
| 3489 | ||
| 3490 | msgctxt "#90644" | |
| 3491 | msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed" | |
| 3492 | msgstr "Загрузка %s ГБ @ %s mbps от %s пиров, %s %% завершено" | |
| 3493 | ||
| 3494 | msgctxt "#90645" | |
| 3495 | msgid "Trakt" | |
| 3496 | msgstr "Trakt" | |
| 3497 | ||
| 3498 | msgctxt "#90651" | |
| 3499 | msgid "Easynews Account Info" | |
| 3500 | msgstr "Информация об аккаунте Easynews" | |
| 3501 | ||
| 3502 | msgctxt "#90652" | |
| 3503 | msgid "DeepL API Error" | |
| 3504 | msgstr "Ошибка API DeepL" | |
| 3505 | ||
| 3506 | msgctxt "#90653" | |
| 3507 | msgid "Download" | |
| 3508 | msgstr "Загрузка" | |
| 3509 | ||
| 3510 | msgctxt "#90654" | |
| 3511 | msgid "Failed to start download: %s" | |
| 3512 | msgstr "Не удалось начать загрузку: %s" | |
| 3513 | ||
| 3514 | msgctxt "#90655" | |
| 3515 | msgid "Modify Search" | |
| 3516 | msgstr "Изменить поиск" | |
| 3517 | ||
| 3518 | msgctxt "#90656" | |
| 3519 | msgid "Torbox" | |
| 3520 | msgstr "Torbox" | |
| 3521 | ||
| 3522 | msgctxt "#90657" | |
| 3523 | msgid "Easy-Debrid" | |
| 3524 | msgstr "Easy-Debrid" | |
| 3525 | ||
| 3526 | msgctxt "#90658" | |
| 3527 | msgid "All-Debrid" | |
| 3528 | msgstr "All-Debrid" | |
| 3529 | ||
| 3530 | msgctxt "#90659" | |
| 3531 | msgid "Real-Debrid" | |
| 3532 | msgstr "Real-Debrid" | |
| 3533 | ||
| 3534 | msgctxt "#90660" | |
| 3535 | msgid "Getting results from providers..." | |
| 3536 | msgstr "Получение результатов от провайдеров..." | |
| 3537 | ||
| 3538 | msgctxt "#90661" | |
| 3539 | msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3540 | msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3541 | ||
| 3542 | msgctxt "#90662" | |
| 3543 | msgid "Downloads Viewer" | |
| 3544 | msgstr "Просмотр загрузок" | |
| 3545 | ||
| 3546 | msgctxt "#90663" | |
| 3547 | msgid "WebDAV Text Viewer" | |
| 3548 | msgstr "Просмотр текста WebDAV" | |
| 3549 | ||
| 3550 | msgctxt "#90664" | |
| 3551 | msgid "Selected Item" | |
| 3552 | msgstr "Выбранный элемент" | |
| 3553 | ||
| 3554 | msgctxt "#90665" | |
| 3555 | msgid "Subtitle No. %d" | |
| 3556 | msgstr "Субтитр № %d" | |
| 3557 | ||
| 3558 | msgctxt "#90666" | |
| 3559 | msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?" | |
| 3560 | msgstr "Доступно бесплатное использование DeepL.\nОсталось символов в бесплатной квоте: %s\nСимволов для перевода: %s\n\nХотите продолжить перевод?" | |
| 3561 | ||
| 3562 | msgctxt "#90667" | |
| 3563 | msgid "Free DeepL Usage" | |
| 3564 | msgstr "Бесплатное использование DeepL" | |
| 3565 | ||
| 3566 | msgctxt "#90668" | |
| 3567 | msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?" | |
| 3568 | msgstr "Всего символов для перевода: %s\nОценочная стоимость: $%.2f USD\n\nХотите продолжить перевод?" | |
| 3569 | ||
| 3570 | msgctxt "#90669" | |
| 3571 | msgid "Translation Cost" | |
| 3572 | msgstr "Стоимость перевода" | |
| 3573 | ||
| 3574 | msgctxt "#90672" | |
| 3575 | msgid "Play Trailer" | |
| 3576 | msgstr "Смотреть трейлер" | |
| 3577 | ||
| 3578 | msgctxt "#90673" | |
| 3579 | msgid "Trailer unavailable" | |
| 3580 | msgstr "Трейлер недоступен" | |
| 3581 | ||
| 3582 | msgctxt "#90690" | |
| 3583 | msgid "Clear All Items" | |
| 3584 | msgstr "Clear All Items" | |
| 3585 | ||
| 3586 | msgctxt "#90691" | |
| 3587 | msgid "Remove all items from this library?" | |
| 3588 | msgstr "Remove all items from this library?" | |
| 3589 | ||
| 3590 | msgctxt "#90692" | |
| 3591 | msgid "Library cleared" | |
| 3592 | msgstr "Library cleared" | |
| 3593 | ||
| 3594 | msgctxt "#90693" | |
| 3595 | msgid "No items to remove" | |
| 3596 | msgstr "No items to remove" | |
| 3597 | ||
| 3598 | msgctxt "#90694" | |
| 3599 | msgid "Added to %s cloud but still downloading. Please wait before playing." | |
| 3600 | msgstr "Добавлено в облако %s, но все еще загружается. Пожалуйста, подождите перед воспроизведением." | |
| 3601 | ||
| 3602 | msgctxt "#90695" | |
| 3603 | msgid "Torrent Not Ready" | |
| 3604 | msgstr "Торрент Не Готов" | |
| 3605 | ||
| 3606 | msgctxt "#90696" | |
| 3607 | msgid "This torrent is still downloading in %s and is not ready yet." | |
| 3608 | msgstr "Этот торрент все еще загружается в %s и еще не готов." | |
| 3609 | ||
| 3610 | msgctxt "#90697" | |
| 3611 | msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?" | |
| 3612 | msgstr "Хотите ли вы воспроизвести с помощью торрент-клиента (может быть медленнее) или отменить и дождаться завершения debrid?" | |
| 3613 | ||
| 3614 | msgctxt "#90698" | |
| 3615 | msgid "Play with Torrent" | |
| 3616 | msgstr "Воспроизвести через Торрент" | |
| 3617 | ||
| 3618 | msgctxt "#90699" | |
| 3619 | msgid "Cancel & Wait" | |
| 3620 | msgstr "Отменить и Подождать" | |
| 3621 | ||
| 3622 | msgctxt "#90742" | |
| 3623 | msgid "Addon Settings" | |
| 3624 | msgstr "Настройки аддона" | |
| 3625 | ||
| 3626 | msgctxt "#90743" | |
| 3627 | msgid "Set Views" | |
| 3628 | msgstr "Настроить виды" | |
| 3629 | ||
| 3630 | msgctxt "#90744" | |
| 3631 | msgid "Upload Subtitle" | |
| 3632 | msgstr "Загрузить субтитры" | |
| 3633 | ||
| 3634 | msgctxt "#90745" | |
| 3635 | msgid "Select Subtitle File" | |
| 3636 | msgstr "Выбрать файл субтитров" | |
| 3637 | ||
| 3638 | msgctxt "#90746" | |
| 3639 | msgid "Local File" | |
| 3640 | msgstr "Локальный файл" | |
| 3641 | ||
| 3642 | msgctxt "#90747" | |
| 3643 | msgid "Upload from Browser" | |
| 3644 | msgstr "Загрузить из браузера" | |
| 3645 | ||
| 3646 | msgctxt "#90748" | |
| 3647 | msgid "Upload subtitle from your browser" | |
| 3648 | msgstr "Загрузите субтитры из браузера" | |
| 3649 | ||
| 3650 | msgctxt "#90749" | |
| 3651 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" | |
| 3652 | msgstr "Отсканируйте QR-код или откройте URL (%s) в браузере, чтобы загрузить файл субтитров" | |
| 3653 | ||
| 3654 | msgctxt "#90750" | |
| 3655 | msgid "Search Title Language" | |
| 3656 | msgstr "Язык заголовка поиска" | |
| 3657 | ||
| 3658 | msgctxt "#90751" | |
| 3659 | msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches." | |
| 3660 | msgstr "Управляет приоритетом запросов по названию в Easynews/Jackett/Prowlarr для контекстного поиска." | |
| 3661 | ||
| 3662 | msgctxt "#90752" | |
| 3663 | msgid "English first" | |
| 3664 | msgstr "Сначала английский" | |
| 3665 | ||
| 3666 | msgctxt "#90753" | |
| 3667 | msgid "English only" | |
| 3668 | msgstr "Только английский" | |
| 3669 | ||
| 3670 | msgctxt "#90754" | |
| 3671 | msgid "Localized first" | |
| 3672 | msgstr "Сначала локализованный" |
| … | … | @@ -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 | # | |
| 6 | msgid "" | |
| 7 | msgstr "" | |
| 8 | "Report-Msgid-Bugs-To: \n" | |
| 9 | "POT-Creation-Date: 2023-09-19 19:53+0200\n" | |
| 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | |
| 11 | "Language: 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 | ||
| 18 | msgctxt "#30000" | |
| 19 | msgid "Players" | |
| 20 | msgstr "Players" | |
| 21 | ||
| 22 | msgctxt "#30001" | |
| 23 | msgid "Clients" | |
| 24 | msgstr "Clientes" | |
| 25 | ||
| 26 | msgctxt "#30002" | |
| 27 | msgid "Torrent Client" | |
| 28 | msgstr "Cliente Torrent" | |
| 29 | ||
| 30 | msgctxt "#30020" | |
| 31 | msgid "Indexers" | |
| 32 | msgstr "Indexadores" | |
| 33 | ||
| 34 | msgctxt "#30021" | |
| 35 | msgid "Indexer" | |
| 36 | msgstr "Indexador" | |
| 37 | ||
| 38 | msgctxt "#30022" | |
| 39 | msgid "Jackett Configuration:" | |
| 40 | msgstr "Configuracion de Jackett" | |
| 41 | ||
| 42 | msgctxt "#30023" | |
| 43 | msgid "Client Key" | |
| 44 | msgstr "Clave de Cliente" | |
| 45 | ||
| 46 | msgctxt "#30025" | |
| 47 | msgid "Host" | |
| 48 | msgstr "Servidor" | |
| 49 | ||
| 50 | msgctxt "#30026" | |
| 51 | msgid "Description length" | |
| 52 | msgstr "Longitud de la Descripcion" | |
| 53 | ||
| 54 | msgctxt "#30027" | |
| 55 | msgid "Insecured connection" | |
| 56 | msgstr "Conexion insegura" | |
| 57 | ||
| 58 | msgctxt "#30028" | |
| 59 | msgid "Sort by" | |
| 60 | msgstr "Ordenar por" | |
| 61 | ||
| 62 | msgctxt "#30029" | |
| 63 | msgid "Total Results" | |
| 64 | msgstr "Total de resultados" | |
| 65 | ||
| 66 | msgctxt "#30030" | |
| 67 | msgid "Timeout" | |
| 68 | msgstr "Tiempo de espera" | |
| 69 | ||
| 70 | msgctxt "#30031" | |
| 71 | msgid "Timeout" | |
| 72 | msgstr "Tiempo de espera" | |
| 73 | ||
| 74 | msgctxt "#30050" | |
| 75 | msgid "Prowlarr Configuration:" | |
| 76 | msgstr "Configuracion de Prowlarr" | |
| 77 | ||
| 78 | msgctxt "#30051" | |
| 79 | msgid "Client Key" | |
| 80 | msgstr "Clave de Cliente" | |
| 81 | ||
| 82 | msgctxt "#30052" | |
| 83 | msgid "Host" | |
| 84 | msgstr "Servidor" | |
| 85 | ||
| 86 | msgctxt "#30053" | |
| 87 | msgid "Indexers-Ids" | |
| 88 | msgstr "Ids de los Indexadores" | |
| 89 | ||
| 90 | msgctxt "#30054" | |
| 91 | msgid "Anime IndexersIds" | |
| 92 | msgstr "Ids Indexadores de Anime" | |
| 93 | ||
| 94 | msgctxt "#30100" | |
| 95 | msgid "TMDB" | |
| 96 | msgstr "TMDB" | |
| 97 | ||
| 98 | msgctxt "#30102" | |
| 99 | msgid "TMDB Configuration" | |
| 100 | msgstr "Configuracion de TMDB" | |
| 101 | ||
| 102 | msgctxt "#30103" | |
| 103 | msgid "Client Key" | |
| 104 | msgstr "Clave de Cliente" | |
| 105 | ||
| 106 | msgctxt "#30104" | |
| 107 | msgid "Clear tmdb cache" | |
| 108 | msgstr "Borrar la cache de TMDB" | |
| 109 | ||
| 110 | msgctxt "#30105" | |
| 111 | msgid "Cache expiration hours" | |
| 112 | msgstr "Expiracion de Cache" | |
| 113 | ||
| 114 | msgctxt "#30106" | |
| 115 | msgid "Clear Cache" | |
| 116 | msgstr "Borrar la Cache" | |
| 117 | ||
| 118 | msgctxt "#30107" | |
| 119 | msgid "Enable Cache" | |
| 120 | msgstr "Habilitar Cache" | |
| 121 | ||
| 122 | msgctxt "#30140" | |
| 123 | msgid "AniList" | |
| 124 | msgstr "AniList" | |
| 125 | ||
| 126 | msgctxt "#30142" | |
| 127 | msgid "AniList Configuration:" | |
| 128 | msgstr "Configuracion de AniList" | |
| 129 | ||
| 130 | msgctxt "#30143" | |
| 131 | msgid "Client ID" | |
| 132 | msgstr "Client ID" | |
| 133 | ||
| 134 | msgctxt "#30144" | |
| 135 | msgid "Client Secret" | |
| 136 | msgstr "Client Secret" | |
| 137 | ||
| 138 | msgctxt "#30150" | |
| 139 | msgid "Services" | |
| 140 | msgstr "Servicios" | |
| 141 | ||
| 142 | msgctxt "#30152" | |
| 143 | msgid "Real Debrid Configuration" | |
| 144 | msgstr "Configuracion de Real Debrid" | |
| 145 | ||
| 146 | msgctxt "#30154" | |
| 147 | msgid "Token" | |
| 148 | msgstr "Token" | |
| 149 | ||
| 150 | msgctxt "#30200" | |
| 151 | msgid "Fanart.TV" | |
| 152 | msgstr "Fanart.TV" | |
| 153 | ||
| 154 | msgctxt "#30202" | |
| 155 | msgid "Fanart.TV Configuration:" | |
| 156 | msgstr "Configuracion de Fanart.TV:" | |
| 157 | ||
| 158 | msgctxt "#30203" | |
| 159 | msgid "Client ID" | |
| 160 | msgstr "Client ID" | |
| 161 | ||
| 162 | msgctxt "#30221" | |
| 163 | msgid "No Client Key provided for" | |
| 164 | msgstr "Clave de Cliente no provista para" | |
| 165 | ||
| 166 | msgctxt "#30223" | |
| 167 | msgid "You need to configure" | |
| 168 | msgstr "Necesitas configurar" | |
| 169 | ||
| 170 | msgctxt "#30228" | |
| 171 | msgid "The request to Torrentio failed." | |
| 172 | msgstr "La peticion a Torrentio ha fallado" | |
| 173 | ||
| 174 | msgctxt "#30229" | |
| 175 | msgid "The request to Jackett failed." | |
| 176 | msgstr "La peticion a Jackett ha fallado" | |
| 177 | ||
| 178 | msgctxt "#30230" | |
| 179 | msgid "The request to Prowlarr failed." | |
| 180 | msgstr "La peticion a Prowlarr ha fallado" | |
| 181 | ||
| 182 | msgctxt "#30231" | |
| 183 | msgid "The request to Elhosted failed." | |
| 184 | msgstr "La peticion a Elhosted ha fallado" | |
| 185 | ||
| 186 | msgctxt "#30232" | |
| 187 | msgid "The request to Jackgram failed." | |
| 188 | msgstr "La peticion a Jackgram ha fallado" | |
| 189 | ||
| 190 | msgctxt "#30233" | |
| 191 | msgid "The request to Mediafusion failed." | |
| 192 | msgstr "La peticion a Mediafusion ha fallado" | |
| 193 | ||
| 194 | msgctxt "#30234" | |
| 195 | msgid "The request to Peerflix failed." | |
| 196 | msgstr "La peticion a Peerflix ha fallado" | |
| 197 | ||
| 198 | msgctxt "#30235" | |
| 199 | msgid "Stremio" | |
| 200 | msgstr "Stremio" | |
| 201 | ||
| 202 | msgctxt "#30236" | |
| 203 | msgid "Stremio add-ons" | |
| 204 | msgstr "Addons de Stremio" | |
| 205 | ||
| 206 | msgctxt "#30237" | |
| 207 | msgid "Import your config" | |
| 208 | msgstr "Importar tu configuración" | |
| 209 | ||
| 210 | msgctxt "#30238" | |
| 211 | msgid "Manage add-ons" | |
| 212 | msgstr "Gestionar add-ons" | |
| 213 | ||
| 214 | msgctxt "#30240" | |
| 215 | msgid "TMDB cache cleared" | |
| 216 | msgstr "Cache de TMDB limpiada" | |
| 217 | ||
| 218 | msgctxt "#30241" | |
| 219 | msgid "Search on TMDB:" | |
| 220 | msgstr "Buscar en TMDB" | |
| 221 | ||
| 222 | msgctxt "#30242" | |
| 223 | msgid "Search on AniList:" | |
| 224 | msgstr "Buscar en AniList" | |
| 225 | ||
| 226 | msgctxt "#30243" | |
| 227 | msgid "Search for a keyword:" | |
| 228 | msgstr "Buscar por una palabra-clave:" | |
| 229 | ||
| 230 | msgctxt "#30244" | |
| 231 | msgid "Cache cleared" | |
| 232 | msgstr "Cache limpiada" | |
| 233 | ||
| 234 | msgctxt "#30249" | |
| 235 | msgid "Addon Jacktook Burst not found - (script.jacktook.burst)" | |
| 236 | msgstr "Addon Jacktook Burst no encontrado - (script.jacktook.burst)" | |
| 237 | ||
| 238 | msgctxt "#30250" | |
| 239 | msgid "Addon Torrest not found - (plugin.video.torrest)" | |
| 240 | msgstr "El Addon de Torrest no esta instalado - (plugin.video.torrest)" | |
| 241 | ||
| 242 | msgctxt "#30251" | |
| 243 | msgid "No sources found to play" | |
| 244 | msgstr "No hay fuentes disponibles para reproducir" | |
| 245 | ||
| 246 | msgctxt "#30252" | |
| 247 | msgid "Addon Elementum not found - (plugin.video.elementum)" | |
| 248 | msgstr "El Addon de Elementum no esta instalado - (plugin.video.elementum)" | |
| 249 | ||
| 250 | msgctxt "#30253" | |
| 251 | msgid "Addon Jacktorr not found - (plugin.video.jacktorr)" | |
| 252 | msgstr "El Addon Jacktorr no esta instalado - (plugin.video.jacktorr)" | |
| 253 | ||
| 254 | msgctxt "#30254" | |
| 255 | msgid "Do you want to install Elementum?" | |
| 256 | msgstr "¿Quieres instalar Elementum?" | |
| 257 | ||
| 258 | msgctxt "#30255" | |
| 259 | msgid "Do you want to install Jacktorr?" | |
| 260 | msgstr "¿Quieres instalar Jacktorr?" | |
| 261 | ||
| 262 | msgctxt "#30256" | |
| 263 | msgid "Do you want to install Torrest?" | |
| 264 | msgstr "¿Quieres instalar Torrest?" | |
| 265 | ||
| 266 | msgctxt "#30257" | |
| 267 | msgid "Do you want to install Jacktook Burst?" | |
| 268 | msgstr "¿Quieres instalar Jacktook Burst?" | |
| 269 | ||
| 270 | msgctxt "#30650" | |
| 271 | msgid "Queued" | |
| 272 | msgstr "Encolado" | |
| 273 | ||
| 274 | msgctxt "#30651" | |
| 275 | msgid "Checking" | |
| 276 | msgstr "Comprobando" | |
| 277 | ||
| 278 | msgctxt "#30652" | |
| 279 | msgid "Finding" | |
| 280 | msgstr "Buscando" | |
| 281 | ||
| 282 | msgctxt "#30653" | |
| 283 | msgid "Downloading" | |
| 284 | msgstr "Descargando" | |
| 285 | ||
| 286 | msgctxt "#30654" | |
| 287 | msgid "Finished" | |
| 288 | msgstr "Terminado" | |
| 289 | ||
| 290 | msgctxt "#30655" | |
| 291 | msgid "Seeding" | |
| 292 | msgstr "Seeding" | |
| 293 | ||
| 294 | msgctxt "#30656" | |
| 295 | msgid "Allocating" | |
| 296 | msgstr "Asignando" | |
| 297 | ||
| 298 | msgctxt "#30657" | |
| 299 | msgid "Checking resume data" | |
| 300 | msgstr "Comprobando datos para resumir" | |
| 301 | ||
| 302 | msgctxt "#30658" | |
| 303 | msgid "Paused" | |
| 304 | msgstr "Pausado" | |
| 305 | ||
| 306 | msgctxt "#30659" | |
| 307 | msgid "Buffering" | |
| 308 | msgstr "Buffering" | |
| 309 | ||
| 310 | msgctxt "#30660" | |
| 311 | msgid "Unknown" | |
| 312 | msgstr "Desconocido" | |
| 313 | ||
| 314 | msgctxt "#30700" | |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "Buffer y reproducir" | |
| 317 | ||
| 318 | msgctxt "#30701" | |
| 319 | msgid "Stop downloading" | |
| 320 | msgstr "Detener descarga" | |
| 321 | ||
| 322 | msgctxt "#30702" | |
| 323 | msgid "Downloads" | |
| 324 | msgstr "Descargas" | |
| 325 | ||
| 326 | msgctxt "#30703" | |
| 327 | msgid "Resume" | |
| 328 | msgstr "Resumir" | |
| 329 | ||
| 330 | msgctxt "#30704" | |
| 331 | msgid "Pause" | |
| 332 | msgstr "Pausar" | |
| 333 | ||
| 334 | msgctxt "#30705" | |
| 335 | msgid "Remove torrent" | |
| 336 | msgstr "Borrar torrent" | |
| 337 | ||
| 338 | msgctxt "#30706" | |
| 339 | msgid "Remove torrent and files" | |
| 340 | msgstr "Borrar torrents y archivos" | |
| 341 | ||
| 342 | msgctxt "#30707" | |
| 343 | msgid "Torrent status" | |
| 344 | msgstr "Estado del torrent" | |
| 345 | ||
| 346 | msgctxt "#30708" | |
| 347 | msgid "Stop" | |
| 348 | msgstr "Detener" | |
| 349 | ||
| 350 | msgctxt "#30709" | |
| 351 | msgid "Drop torrent" | |
| 352 | msgstr "Drop torrent" | |
| 353 | ||
| 354 | msgctxt "#30710" | |
| 355 | msgid "Include Season Packs" | |
| 356 | msgstr "Incluir Packs de Temporadas" | |
| 357 | ||
| 358 | msgctxt "#30711" | |
| 359 | msgid "Torrent Settings" | |
| 360 | msgstr "Configuración de Torrents" | |
| 361 | ||
| 362 | msgctxt "#30712" | |
| 363 | msgid "General" | |
| 364 | msgstr "General" | |
| 365 | ||
| 366 | msgctxt "#30713" | |
| 367 | msgid "Enable" | |
| 368 | msgstr "Habilitar" | |
| 369 | ||
| 370 | msgctxt "#30714" | |
| 371 | msgid "Jacktorr" | |
| 372 | msgstr "Jacktorr" | |
| 373 | ||
| 374 | msgctxt "#30715" | |
| 375 | msgid "Torrest" | |
| 376 | msgstr "Torrest" | |
| 377 | ||
| 378 | msgctxt "#30716" | |
| 379 | msgid "Elementum" | |
| 380 | msgstr "Elementum" | |
| 381 | ||
| 382 | msgctxt "#30717" | |
| 383 | msgid "General" | |
| 384 | msgstr "General" | |
| 385 | ||
| 386 | msgctxt "#30718" | |
| 387 | msgid "Auto-Play" | |
| 388 | msgstr "Reproducción automática" | |
| 389 | ||
| 390 | msgctxt "#30719" | |
| 391 | msgid "Enable Next Episode Dialog" | |
| 392 | msgstr "Próximo episodio" | |
| 393 | ||
| 394 | msgctxt "#30720" | |
| 395 | msgid "Seconds left before show Next Episode Dialog" | |
| 396 | msgstr "Segundos para siguiente episodio" | |
| 397 | ||
| 398 | msgctxt "#30721" | |
| 399 | msgid "Cache Enabled" | |
| 400 | msgstr "Caché habilitada" | |
| 401 | ||
| 402 | msgctxt "#30722" | |
| 403 | msgid "Cache Expiration (hours)" | |
| 404 | msgstr "Expiración de caché (horas)" | |
| 405 | ||
| 406 | msgctxt "#30723" | |
| 407 | msgid "Cache" | |
| 408 | msgstr "Caché" | |
| 409 | ||
| 410 | msgctxt "#30724" | |
| 411 | msgid "Update" | |
| 412 | msgstr "Actualizar" | |
| 413 | ||
| 414 | msgctxt "#30725" | |
| 415 | msgid "Indexers Configuration" | |
| 416 | msgstr "Configuración de indexadores" | |
| 417 | ||
| 418 | msgctxt "#30726" | |
| 419 | msgid "Sort" | |
| 420 | msgstr "Ordenar" | |
| 421 | ||
| 422 | msgctxt "#30727" | |
| 423 | msgid "Quality" | |
| 424 | msgstr "Calidad" | |
| 425 | ||
| 426 | msgctxt "#30938" | |
| 427 | msgid "Silent Resume" | |
| 428 | msgstr "Reanudar Silenciosamente" | |
| 429 | ||
| 430 | msgctxt "#30939" | |
| 431 | msgid "Auto-resume from last position without asking" | |
| 432 | msgstr "Reanudar automáticamente desde la última posición sin preguntar" | |
| 433 | ||
| 434 | msgctxt "#30728" | |
| 435 | msgid "Seeds" | |
| 436 | msgstr "Seeds" | |
| 437 | ||
| 438 | msgctxt "#30729" | |
| 439 | msgid "Size" | |
| 440 | msgstr "Tamaño" | |
| 441 | ||
| 442 | msgctxt "#30730" | |
| 443 | msgid "Cached" | |
| 444 | msgstr "En caché" | |
| 445 | ||
| 446 | msgctxt "#30731" | |
| 447 | msgid "Date" | |
| 448 | msgstr "Fecha" | |
| 449 | ||
| 450 | msgctxt "#30732" | |
| 451 | msgid "None" | |
| 452 | msgstr "Ninguno" | |
| 453 | ||
| 454 | msgctxt "#30733" | |
| 455 | msgid "Torrentio Configuration" | |
| 456 | msgstr "Configuración de Torrentio" | |
| 457 | ||
| 458 | msgctxt "#30734" | |
| 459 | msgid "Enable" | |
| 460 | msgstr "Habilitar" | |
| 461 | ||
| 462 | msgctxt "#30736" | |
| 463 | msgid "Subtitle Language" | |
| 464 | msgstr "Idioma prioritario" | |
| 465 | ||
| 466 | msgctxt "#30737" | |
| 467 | msgid "GB" | |
| 468 | msgstr "GB" | |
| 469 | ||
| 470 | msgctxt "#30738" | |
| 471 | msgid "JP" | |
| 472 | msgstr "JP" | |
| 473 | ||
| 474 | msgctxt "#30739" | |
| 475 | msgid "RU" | |
| 476 | msgstr "RU" | |
| 477 | ||
| 478 | msgctxt "#30740" | |
| 479 | msgid "IT" | |
| 480 | msgstr "IT" | |
| 481 | ||
| 482 | msgctxt "#30741" | |
| 483 | msgid "PT" | |
| 484 | msgstr "PT" | |
| 485 | ||
| 486 | msgctxt "#30742" | |
| 487 | msgid "ES" | |
| 488 | msgstr "ES" | |
| 489 | ||
| 490 | msgctxt "#30743" | |
| 491 | msgid "MX" | |
| 492 | msgstr "MX" | |
| 493 | ||
| 494 | msgctxt "#30744" | |
| 495 | msgid "KR" | |
| 496 | msgstr "KR" | |
| 497 | ||
| 498 | msgctxt "#30745" | |
| 499 | msgid "CN" | |
| 500 | msgstr "CN" | |
| 501 | ||
| 502 | msgctxt "#30746" | |
| 503 | msgid "TW" | |
| 504 | msgstr "TW" | |
| 505 | ||
| 506 | msgctxt "#30747" | |
| 507 | msgid "FR" | |
| 508 | msgstr "FR" | |
| 509 | ||
| 510 | msgctxt "#30748" | |
| 511 | msgid "DE" | |
| 512 | msgstr "DE" | |
| 513 | ||
| 514 | msgctxt "#30749" | |
| 515 | msgid "NL" | |
| 516 | msgstr "NL" | |
| 517 | ||
| 518 | msgctxt "#30750" | |
| 519 | msgid "IN" | |
| 520 | msgstr "IN" | |
| 521 | ||
| 522 | msgctxt "#30751" | |
| 523 | msgid "PL" | |
| 524 | msgstr "PL" | |
| 525 | ||
| 526 | msgctxt "#30752" | |
| 527 | msgid "LT" | |
| 528 | msgstr "LT" | |
| 529 | ||
| 530 | msgctxt "#30753" | |
| 531 | msgid "CZ" | |
| 532 | msgstr "CZ" | |
| 533 | ||
| 534 | msgctxt "#30754" | |
| 535 | msgid "SK" | |
| 536 | msgstr "SK" | |
| 537 | ||
| 538 | msgctxt "#30755" | |
| 539 | msgid "SI" | |
| 540 | msgstr "SI" | |
| 541 | ||
| 542 | msgctxt "#30756" | |
| 543 | msgid "HU" | |
| 544 | msgstr "HU" | |
| 545 | ||
| 546 | msgctxt "#30757" | |
| 547 | msgid "RO" | |
| 548 | msgstr "RO" | |
| 549 | ||
| 550 | msgctxt "#30758" | |
| 551 | msgid "HR" | |
| 552 | msgstr "HR" | |
| 553 | ||
| 554 | msgctxt "#30759" | |
| 555 | msgid "UA" | |
| 556 | msgstr "UA" | |
| 557 | ||
| 558 | msgctxt "#30760" | |
| 559 | msgid "GR" | |
| 560 | msgstr "GR" | |
| 561 | ||
| 562 | msgctxt "#30761" | |
| 563 | msgid "DK" | |
| 564 | msgstr "DK" | |
| 565 | ||
| 566 | msgctxt "#30762" | |
| 567 | msgid "FI" | |
| 568 | msgstr "FI" | |
| 569 | ||
| 570 | msgctxt "#30763" | |
| 571 | msgid "SE" | |
| 572 | msgstr "SE" | |
| 573 | ||
| 574 | msgctxt "#30764" | |
| 575 | msgid "NO" | |
| 576 | msgstr "NO" | |
| 577 | ||
| 578 | msgctxt "#30765" | |
| 579 | msgid "TR" | |
| 580 | msgstr "TR" | |
| 581 | ||
| 582 | msgctxt "#30766" | |
| 583 | msgid "Torrentio Providers" | |
| 584 | msgstr "Proveedores de Torrentio" | |
| 585 | ||
| 586 | msgctxt "#30767" | |
| 587 | msgid "Jackgram Configuration" | |
| 588 | msgstr "Configuración de Jackgram" | |
| 589 | ||
| 590 | msgctxt "#30768" | |
| 591 | msgid "Host" | |
| 592 | msgstr "Host" | |
| 593 | ||
| 594 | msgctxt "#30769" | |
| 595 | msgid "Elfhosted/KnightCrawler Configuration" | |
| 596 | msgstr "Configuración de Elfhosted/KnightCrawler" | |
| 597 | ||
| 598 | msgctxt "#30770" | |
| 599 | msgid "Language" | |
| 600 | msgstr "Idioma" | |
| 601 | ||
| 602 | msgctxt "#30771" | |
| 603 | msgid "Zilean Configuration" | |
| 604 | msgstr "Configuración de Zilean" | |
| 605 | ||
| 606 | msgctxt "#30772" | |
| 607 | msgid "Zilean Host" | |
| 608 | msgstr "Host de Zilean" | |
| 609 | ||
| 610 | msgctxt "#30774" | |
| 611 | msgid "Zilean Timeout" | |
| 612 | msgstr "Tiempo de espera de Zilean" | |
| 613 | ||
| 614 | msgctxt "#30775" | |
| 615 | msgid "Jackett Configuration" | |
| 616 | msgstr "Configuración de Jackett" | |
| 617 | ||
| 618 | msgctxt "#30776" | |
| 619 | msgid "Prowlarr Configuration" | |
| 620 | msgstr "Configuración de Prowlarr" | |
| 621 | ||
| 622 | msgctxt "#30777" | |
| 623 | msgid "MediaFusion configuration" | |
| 624 | msgstr "Configuración de MediaFusion" | |
| 625 | ||
| 626 | msgctxt "#30779" | |
| 627 | msgid "Manifest Url" | |
| 628 | msgstr "URL del manifiesto" | |
| 629 | ||
| 630 | msgctxt "#30780" | |
| 631 | msgid "Jacktook Burst Configuration" | |
| 632 | msgstr "Configuración de Jacktook Burst" | |
| 633 | ||
| 634 | msgctxt "#30781" | |
| 635 | msgid "Burst Configuration" | |
| 636 | msgstr "Configuración de Burst" | |
| 637 | ||
| 638 | msgctxt "#30782" | |
| 639 | msgid "User" | |
| 640 | msgstr "Usuario" | |
| 641 | ||
| 642 | msgctxt "#30783" | |
| 643 | msgid "Authorize" | |
| 644 | msgstr "Autorizar" | |
| 645 | ||
| 646 | msgctxt "#30784" | |
| 647 | msgid "Remove Authorization" | |
| 648 | msgstr "Eliminar autorización" | |
| 649 | ||
| 650 | msgctxt "#30785" | |
| 651 | msgid "Premiumize Configuration" | |
| 652 | msgstr "Configuración de Premiumize" | |
| 653 | ||
| 654 | msgctxt "#30786" | |
| 655 | msgid "Enabled" | |
| 656 | msgstr "Habilitar" | |
| 657 | ||
| 658 | msgctxt "#30787" | |
| 659 | msgid "Token" | |
| 660 | msgstr "Token" | |
| 661 | ||
| 662 | msgctxt "#30788" | |
| 663 | msgid "TorBox Configuration" | |
| 664 | msgstr "Configuración de TorBox" | |
| 665 | ||
| 666 | msgctxt "#30789" | |
| 667 | msgid "Debrider Configuration" | |
| 668 | msgstr "Configuración de Debrider" | |
| 669 | ||
| 670 | msgctxt "#30790" | |
| 671 | msgid "Uncached" | |
| 672 | msgstr "No cacheado" | |
| 673 | ||
| 674 | msgctxt "#30791" | |
| 675 | msgid "Show uncached torrents" | |
| 676 | msgstr "Mostrar torrents no cacheados" | |
| 677 | ||
| 678 | msgctxt "#30792" | |
| 679 | msgid "Use Kodi Language" | |
| 680 | msgstr "Usar idioma de Kodi" | |
| 681 | ||
| 682 | msgctxt "#30793" | |
| 683 | msgid "Trakt" | |
| 684 | msgstr "Trakt" | |
| 685 | ||
| 686 | msgctxt "#30794" | |
| 687 | msgid "Trakt Configuration" | |
| 688 | msgstr "Configuración de Trakt" | |
| 689 | ||
| 690 | msgctxt "#30795" | |
| 691 | msgid "Expires" | |
| 692 | msgstr "Expira" | |
| 693 | ||
| 694 | msgctxt "#30796" | |
| 695 | msgid "Refresh" | |
| 696 | msgstr "Actualizar" | |
| 697 | ||
| 698 | msgctxt "#30797" | |
| 699 | msgid "Token" | |
| 700 | msgstr "Token" | |
| 701 | ||
| 702 | msgctxt "#30798" | |
| 703 | msgid "Trakt Client" | |
| 704 | msgstr "Cliente de Trakt" | |
| 705 | ||
| 706 | msgctxt "#30800" | |
| 707 | msgid "Trakt Secret" | |
| 708 | msgstr "Secreto de Trakt" | |
| 709 | ||
| 710 | msgctxt "#30802" | |
| 711 | msgid "Log out" | |
| 712 | msgstr "Cerrar sesión" | |
| 713 | ||
| 714 | msgctxt "#30804" | |
| 715 | msgid "Menus" | |
| 716 | msgstr "Menús" | |
| 717 | ||
| 718 | msgctxt "#30805" | |
| 719 | msgid "Show Telegram Menu" | |
| 720 | msgstr "Mostrar menú de Telegram" | |
| 721 | ||
| 722 | msgctxt "#30806" | |
| 723 | msgid "Clear cache when update" | |
| 724 | msgstr "Limpiar cache al actualizar" | |
| 725 | ||
| 726 | msgctxt "#30807" | |
| 727 | msgid "Peerfix Configuration" | |
| 728 | msgstr "Configuración de Peerfix" | |
| 729 | ||
| 730 | msgctxt "#30894" | |
| 731 | msgid "Show Changelog" | |
| 732 | msgstr "Mostrar cambios" | |
| 733 | ||
| 734 | msgctxt "#30895" | |
| 735 | msgid "View the latest changes and updates to the addon." | |
| 736 | msgstr "Ver los últimos cambios y actualizaciones del complemento." | |
| 737 | ||
| 738 | msgctxt "#30896" | |
| 739 | msgid "Calendar" | |
| 740 | msgstr "Calendario" | |
| 741 | ||
| 742 | msgctxt "#30897" | |
| 743 | msgid "Use broadcast day adjustment" | |
| 744 | msgstr "Usar ajuste de día de emisión" | |
| 745 | ||
| 746 | msgctxt "#30898" | |
| 747 | msgid "Adjust broadcast day based on manual offset" | |
| 748 | msgstr "Ajustar día de emisión según desplazamiento manual" | |
| 749 | ||
| 750 | msgctxt "#30899" | |
| 751 | msgid "Broadcast Offset (Hours)" | |
| 752 | msgstr "Desplazamiento de emisión (Horas)" | |
| 753 | ||
| 754 | msgctxt "#30900" | |
| 755 | msgid "Manual hour adjustment for broadcast day simulation" | |
| 756 | msgstr "Ajuste manual de horas para simulación de emisión" | |
| 757 | ||
| 758 | msgctxt "#30905" | |
| 759 | msgid "Trakt Calendar Previous Days" | |
| 760 | msgstr "Días anteriores del calendario Trakt" | |
| 761 | ||
| 762 | msgctxt "#30906" | |
| 763 | msgid "Number of days in the past to show in the Trakt calendar." | |
| 764 | msgstr "Número de días pasados a mostrar en el calendario de Trakt." | |
| 765 | ||
| 766 | msgctxt "#30907" | |
| 767 | msgid "Trakt Calendar Future Days" | |
| 768 | msgstr "Días futuros del calendario Trakt" | |
| 769 | ||
| 770 | msgctxt "#30908" | |
| 771 | msgid "Number of days in the future to show in the Trakt calendar." | |
| 772 | msgstr "Número de días futuros a mostrar en el calendario de Trakt." | |
| 773 | ||
| 774 | msgctxt "#30909" | |
| 775 | msgid "Show All Shows in Calendar" | |
| 776 | msgstr "Mostrar todas las series en el calendario" | |
| 777 | ||
| 778 | msgctxt "#30918" | |
| 779 | msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead." | |
| 780 | msgstr "{provider} no puede reproducir directamente releases empaquetados (.rar/.zip). Elige una fuente con archivos de video." | |
| 781 | ||
| 782 | msgctxt "#30910" | |
| 783 | msgid "Show all airing shows instead of just your watchlist/collection." | |
| 784 | msgstr "Muestra todas las series en emisión en lugar de solo las de tu lista/colección." | |
| 785 | ||
| 786 | msgctxt "#30911" | |
| 787 | msgid "Your Torbox account username/email." | |
| 788 | msgstr "Tu nombre de usuario/email de cuenta Torbox." | |
| 789 | ||
| 790 | msgctxt "#30912" | |
| 791 | msgid "Authorize add-on to access Torbox account." | |
| 792 | msgstr "Autorizar complemento para acceder a la cuenta de Torbox." | |
| 793 | ||
| 794 | msgctxt "#30913" | |
| 795 | msgid "Revoke Torbox account access." | |
| 796 | msgstr "Revocar acceso a la cuenta de Torbox." | |
| 797 | ||
| 798 | msgctxt "#30914" | |
| 799 | msgid "Trakt Sync Interval (minutes)" | |
| 800 | msgstr "Intervalo de sincronizacion de Trakt (minutos)" | |
| 801 | ||
| 802 | msgctxt "#30915" | |
| 803 | msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running." | |
| 804 | msgstr "Con que frecuencia Jacktook comprueba cambios remotos de actividad en Trakt mientras Kodi esta en ejecucion." | |
| 805 | ||
| 806 | msgctxt "#30916" | |
| 807 | msgid "Account Info" | |
| 808 | msgstr "Informacion de cuenta de Trakt" | |
| 809 | ||
| 810 | msgctxt "#30917" | |
| 811 | msgid "View your Trakt account profile and library stats." | |
| 812 | msgstr "Ver el perfil de tu cuenta de Trakt y las estadisticas de tu biblioteca." | |
| 813 | ||
| 814 | msgctxt "#30924" | |
| 815 | msgid "Trakt List Name" | |
| 816 | msgstr "Nombre de la lista de Trakt" | |
| 817 | ||
| 818 | msgctxt "#30925" | |
| 819 | msgid "Trakt List Description" | |
| 820 | msgstr "Descripcion de la lista de Trakt" | |
| 821 | ||
| 822 | msgctxt "#30926" | |
| 823 | msgid "Create Trakt List" | |
| 824 | msgstr "Crear lista de Trakt" | |
| 825 | ||
| 826 | msgctxt "#30927" | |
| 827 | msgid "Choose Trakt List" | |
| 828 | msgstr "Elegir lista de Trakt" | |
| 829 | ||
| 830 | msgctxt "#30936" | |
| 831 | msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position." | |
| 832 | msgstr "Activar o desactivar el scrobbling de Trakt y reanudar la reproduccion desde la ultima posicion registrada." | |
| 833 | ||
| 834 | msgctxt "#30937" | |
| 835 | msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view." | |
| 836 | msgstr "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 | ||
| 838 | msgctxt "#40000" | |
| 839 | msgid "Arabic (United Arab Emirates)" | |
| 840 | msgstr "Árabe (Emiratos Árabes Unidos)" | |
| 841 | ||
| 842 | msgctxt "#40001" | |
| 843 | msgid "Arabic (Saudi Arabia)" | |
| 844 | msgstr "Árabe (Arabia Saudita)" | |
| 845 | ||
| 846 | msgctxt "#40002" | |
| 847 | msgid "Belarusian (Belarus)" | |
| 848 | msgstr "Bielorruso (Bielorrusia)" | |
| 849 | ||
| 850 | msgctxt "#40003" | |
| 851 | msgid "Bulgarian (Bulgaria)" | |
| 852 | msgstr "Búlgaro (Bulgaria)" | |
| 853 | ||
| 854 | msgctxt "#40004" | |
| 855 | msgid "Bengali (Bangladesh)" | |
| 856 | msgstr "Bengalí (Bangladesh)" | |
| 857 | ||
| 858 | msgctxt "#40005" | |
| 859 | msgid "Catalan (Spain)" | |
| 860 | msgstr "Catalán (España)" | |
| 861 | ||
| 862 | msgctxt "#40006" | |
| 863 | msgid "Chamorro (Guam)" | |
| 864 | msgstr "Chamorro (Guam)" | |
| 865 | ||
| 866 | msgctxt "#40007" | |
| 867 | msgid "Czech (Czech Republic)" | |
| 868 | msgstr "Checo (República Checa)" | |
| 869 | ||
| 870 | msgctxt "#40008" | |
| 871 | msgid "Danish (Denmark)" | |
| 872 | msgstr "Danés (Dinamarca)" | |
| 873 | ||
| 874 | msgctxt "#40009" | |
| 875 | msgid "German (Austria)" | |
| 876 | msgstr "Alemán (Austria)" | |
| 877 | ||
| 878 | msgctxt "#40010" | |
| 879 | msgid "German (Switzerland)" | |
| 880 | msgstr "Alemán (Suiza)" | |
| 881 | ||
| 882 | msgctxt "#40011" | |
| 883 | msgid "German (Germany)" | |
| 884 | msgstr "Alemán (Alemania)" | |
| 885 | ||
| 886 | msgctxt "#40012" | |
| 887 | msgid "Greek (Greece)" | |
| 888 | msgstr "Griego (Grecia)" | |
| 889 | ||
| 890 | msgctxt "#40013" | |
| 891 | msgid "English (Australia)" | |
| 892 | msgstr "Inglés (Australia)" | |
| 893 | ||
| 894 | msgctxt "#40014" | |
| 895 | msgid "English (Canada)" | |
| 896 | msgstr "Inglés (Canadá)" | |
| 897 | ||
| 898 | msgctxt "#40015" | |
| 899 | msgid "English (United Kingdom)" | |
| 900 | msgstr "Inglés (Reino Unido)" | |
| 901 | ||
| 902 | msgctxt "#40016" | |
| 903 | msgid "English (Ireland)" | |
| 904 | msgstr "Inglés (Irlanda)" | |
| 905 | ||
| 906 | msgctxt "#40017" | |
| 907 | msgid "English (New Zealand)" | |
| 908 | msgstr "Inglés (Nueva Zelanda)" | |
| 909 | ||
| 910 | msgctxt "#40018" | |
| 911 | msgid "English (United States of America)" | |
| 912 | msgstr "Inglés (Estados Unidos de América)" | |
| 913 | ||
| 914 | msgctxt "#40019" | |
| 915 | msgid "Esperanto (None)" | |
| 916 | msgstr "Esperanto (Ninguno)" | |
| 917 | ||
| 918 | msgctxt "#40020" | |
| 919 | msgid "Spanish (Spain)" | |
| 920 | msgstr "Español (España)" | |
| 921 | ||
| 922 | msgctxt "#40021" | |
| 923 | msgid "Spanish (Mexico)" | |
| 924 | msgstr "Español (México)" | |
| 925 | ||
| 926 | msgctxt "#40022" | |
| 927 | msgid "Estonian (Estonia)" | |
| 928 | msgstr "Estonio (Estonia)" | |
| 929 | ||
| 930 | msgctxt "#40023" | |
| 931 | msgid "Basque (Spain)" | |
| 932 | msgstr "Vasco (España)" | |
| 933 | ||
| 934 | msgctxt "#40024" | |
| 935 | msgid "Persian (Iran)" | |
| 936 | msgstr "Persa (Irán)" | |
| 937 | ||
| 938 | msgctxt "#40025" | |
| 939 | msgid "Finnish (Finland)" | |
| 940 | msgstr "Finlandés (Finlandia)" | |
| 941 | ||
| 942 | msgctxt "#40026" | |
| 943 | msgid "French (Canada)" | |
| 944 | msgstr "Francés (Canadá)" | |
| 945 | ||
| 946 | msgctxt "#40027" | |
| 947 | msgid "French (France)" | |
| 948 | msgstr "Francés (Francia)" | |
| 949 | ||
| 950 | msgctxt "#40028" | |
| 951 | msgid "Galician (Spain)" | |
| 952 | msgstr "Gallego (España)" | |
| 953 | ||
| 954 | msgctxt "#40029" | |
| 955 | msgid "Hebrew (Israel)" | |
| 956 | msgstr "Hebreo (Israel)" | |
| 957 | ||
| 958 | msgctxt "#40030" | |
| 959 | msgid "Hindi (India)" | |
| 960 | msgstr "Hindi (India)" | |
| 961 | ||
| 962 | msgctxt "#40031" | |
| 963 | msgid "Hungarian (Hungary)" | |
| 964 | msgstr "Húngaro (Hungría)" | |
| 965 | ||
| 966 | msgctxt "#40032" | |
| 967 | msgid "Indonesian (Indonesia)" | |
| 968 | msgstr "Indonesio (Indonesia)" | |
| 969 | ||
| 970 | msgctxt "#40033" | |
| 971 | msgid "Italian (Italy)" | |
| 972 | msgstr "Italiano (Italia)" | |
| 973 | ||
| 974 | msgctxt "#40034" | |
| 975 | msgid "Japanese (Japan)" | |
| 976 | msgstr "Japonés (Japón)" | |
| 977 | ||
| 978 | msgctxt "#40035" | |
| 979 | msgid "Georgian (Georgia)" | |
| 980 | msgstr "Georgiano (Georgia)" | |
| 981 | ||
| 982 | msgctxt "#40036" | |
| 983 | msgid "Kazakh (Kazakhstan)" | |
| 984 | msgstr "Kazajo (Kazajistán)" | |
| 985 | ||
| 986 | msgctxt "#40037" | |
| 987 | msgid "Kannada (India)" | |
| 988 | msgstr "Kannada (India)" | |
| 989 | ||
| 990 | msgctxt "#40038" | |
| 991 | msgid "Korean (South Korea)" | |
| 992 | msgstr "Coreano (Corea del Sur)" | |
| 993 | ||
| 994 | msgctxt "#40039" | |
| 995 | msgid "Lithuanian (Lithuania)" | |
| 996 | msgstr "Lituano (Lituania)" | |
| 997 | ||
| 998 | msgctxt "#40040" | |
| 999 | msgid "Latvian (Latvia)" | |
| 1000 | msgstr "Letón (Letonia)" | |
| 1001 | ||
| 1002 | msgctxt "#40041" | |
| 1003 | msgid "Malayalam (India)" | |
| 1004 | msgstr "Malayalam (India)" | |
| 1005 | ||
| 1006 | msgctxt "#40042" | |
| 1007 | msgid "Malay (Malaysia)" | |
| 1008 | msgstr "Malayo (Malasia)" | |
| 1009 | ||
| 1010 | msgctxt "#40043" | |
| 1011 | msgid "Malay (Singapore)" | |
| 1012 | msgstr "Malayo (Singapur)" | |
| 1013 | ||
| 1014 | msgctxt "#40044" | |
| 1015 | msgid "Norwegian Bokmål (Norway)" | |
| 1016 | msgstr "Bokmål noruego (Noruega)" | |
| 1017 | ||
| 1018 | msgctxt "#40045" | |
| 1019 | msgid "Dutch (Netherlands)" | |
| 1020 | msgstr "Holandés (Países Bajos)" | |
| 1021 | ||
| 1022 | msgctxt "#40046" | |
| 1023 | msgid "Norwegian (Norway)" | |
| 1024 | msgstr "Noruego (Noruega)" | |
| 1025 | ||
| 1026 | msgctxt "#40047" | |
| 1027 | msgid "Polish (Poland)" | |
| 1028 | msgstr "Polaco (Polonia)" | |
| 1029 | ||
| 1030 | msgctxt "#40048" | |
| 1031 | msgid "Portuguese (Brazil)" | |
| 1032 | msgstr "Portugués (Brasil)" | |
| 1033 | ||
| 1034 | msgctxt "#40049" | |
| 1035 | msgid "Portuguese (Portugal)" | |
| 1036 | msgstr "Portugués (portugal)" | |
| 1037 | ||
| 1038 | msgctxt "#40050" | |
| 1039 | msgid "Romanian (Romania)" | |
| 1040 | msgstr "Rumano (Rumania)" | |
| 1041 | ||
| 1042 | msgctxt "#40051" | |
| 1043 | msgid "Russian (Russia)" | |
| 1044 | msgstr "Ruso (Rusia)" | |
| 1045 | ||
| 1046 | msgctxt "#40052" | |
| 1047 | msgid "Sinhalese (Sri Lanka)" | |
| 1048 | msgstr "Cingales (Sri Lanka)" | |
| 1049 | ||
| 1050 | msgctxt "#40053" | |
| 1051 | msgid "Slovak (Slovakia)" | |
| 1052 | msgstr "Eslovaco (Eslovaquia)" | |
| 1053 | ||
| 1054 | msgctxt "#40054" | |
| 1055 | msgid "Slovenian (Slovenia)" | |
| 1056 | msgstr "Esloveno (Eslovenia)" | |
| 1057 | ||
| 1058 | msgctxt "#40055" | |
| 1059 | msgid "Serbian (Serbia)" | |
| 1060 | msgstr "Serbio (Serbia)" | |
| 1061 | ||
| 1062 | msgctxt "#40056" | |
| 1063 | msgid "Swedish (Sweden)" | |
| 1064 | msgstr "Sueco (Suecia)" | |
| 1065 | ||
| 1066 | msgctxt "#40057" | |
| 1067 | msgid "Tamil (India)" | |
| 1068 | msgstr "Tamil (India)" | |
| 1069 | ||
| 1070 | msgctxt "#40058" | |
| 1071 | msgid "Telugu (India)" | |
| 1072 | msgstr "Telugu (India)" | |
| 1073 | ||
| 1074 | msgctxt "#40059" | |
| 1075 | msgid "Thai (Thailand)" | |
| 1076 | msgstr "Tailandés (Tailandia)" | |
| 1077 | ||
| 1078 | msgctxt "#40060" | |
| 1079 | msgid "Tagalog (Philippines)" | |
| 1080 | msgstr "Tagalo (Philippines)" | |
| 1081 | ||
| 1082 | msgctxt "#40061" | |
| 1083 | msgid "Turkish (Turkey)" | |
| 1084 | msgstr "Turco (Turquía" | |
| 1085 | ||
| 1086 | msgctxt "#40062" | |
| 1087 | msgid "Ukrainian (Ukraine)" | |
| 1088 | msgstr "Ucraniano (Ucrania)" | |
| 1089 | ||
| 1090 | msgctxt "#40063" | |
| 1091 | msgid "Vietnamese (Vietnam)" | |
| 1092 | msgstr "Vietnamita (Vietnam)" | |
| 1093 | ||
| 1094 | msgctxt "#40064" | |
| 1095 | msgid "Mandarin (China)" | |
| 1096 | msgstr "Mandarin (China)" | |
| 1097 | ||
| 1098 | msgctxt "#40065" | |
| 1099 | msgid "Mandarin (Hong Kong)" | |
| 1100 | msgstr "Mandarin (Hong Kong)" | |
| 1101 | ||
| 1102 | msgctxt "#40066" | |
| 1103 | msgid "Mandarin (Taiwan)" | |
| 1104 | msgstr "Mandarin (Taiwan)" | |
| 1105 | ||
| 1106 | msgctxt "#40067" | |
| 1107 | msgid "Zulu (South Africa)" | |
| 1108 | msgstr "Zulú (Sudáfrica)" | |
| 1109 | ||
| 1110 | msgctxt "#42418" | |
| 1111 | msgid "Language and country" | |
| 1112 | msgstr "Idioma y país" | |
| 1113 | ||
| 1114 | msgctxt "#42419" | |
| 1115 | msgid "The language and country settings used when looking up data from TMDb" | |
| 1116 | msgstr "La configuración de idioma y país utilizada al buscar datos de TMDB" | |
| 1117 | ||
| 1118 | msgctxt "#42420" | |
| 1119 | msgid "EN" | |
| 1120 | msgstr "EN" | |
| 1121 | ||
| 1122 | msgctxt "#30801" | |
| 1123 | msgid "Zilean server address (include port if required)." | |
| 1124 | msgstr "Dirección del servidor Zilean (incluye el puerto si es necesario)." | |
| 1125 | ||
| 1126 | msgctxt "#30803" | |
| 1127 | msgid "Display torrents not yet cached on your debrid service." | |
| 1128 | msgstr "Mostrar torrents que aún no están en caché en tu servicio debrid." | |
| 1129 | ||
| 1130 | msgctxt "#30808" | |
| 1131 | msgid "Fanart.tv Client Key (for artwork)." | |
| 1132 | msgstr "Clave de Cliente de Fanart.tv (para imágenes)." | |
| 1133 | ||
| 1134 | msgctxt "#30809" | |
| 1135 | msgid "Improve performance by caching metadata." | |
| 1136 | msgstr "Mejorar el rendimiento almacenando metadatos en caché." | |
| 1137 | ||
| 1138 | msgctxt "#30810" | |
| 1139 | msgid "Seconds before Jackett requests time out (e.g., 30)." | |
| 1140 | msgstr "Segundos antes de que las solicitudes a Jackett expiren (ej. 30)." | |
| 1141 | ||
| 1142 | msgctxt "#30811" | |
| 1143 | msgid "Trakt client secret from OAuth application settings." | |
| 1144 | msgstr "Clave secreta de cliente de Trakt desde la configuración de la aplicación OAuth." | |
| 1145 | ||
| 1146 | msgctxt "#30812" | |
| 1147 | msgid "Limit TV show results to specific seasons/episodes." | |
| 1148 | msgstr "Limitar los resultados de series a temporadas/episodios específicos." | |
| 1149 | ||
| 1150 | msgctxt "#30813" | |
| 1151 | msgid "Hours before cached data is automatically refreshed (e.g., 24)." | |
| 1152 | msgstr "Horas antes de que los datos en caché se actualicen automáticamente (ej. 24)." | |
| 1153 | ||
| 1154 | msgctxt "#30814" | |
| 1155 | msgid "Premiumize Client Token (get from account page)." | |
| 1156 | msgstr "Clave de Cliente de Premiumize (obtener en la página de la cuenta)." | |
| 1157 | ||
| 1158 | msgctxt "#30815" | |
| 1159 | msgid "Jackett server URL (e.g., http://localhost)." | |
| 1160 | msgstr "URL del servidor Jackett (ej. http://localhost)." | |
| 1161 | ||
| 1162 | msgctxt "#30816" | |
| 1163 | msgid "Display Telegram integration options in UI." | |
| 1164 | msgstr "Mostrar opciones de integración de Telegram en la interfaz." | |
| 1165 | ||
| 1166 | msgctxt "#30817" | |
| 1167 | msgid "Your Real-Debrid account username/email." | |
| 1168 | msgstr "Nombre de usuario/correo electrónico de tu cuenta Real-Debrid." | |
| 1169 | ||
| 1170 | msgctxt "#30818" | |
| 1171 | msgid "Trakt client ID from https://trakt.tv/oauth/apps." | |
| 1172 | msgstr "ID de cliente de Trakt desde https://trakt.tv/oauth/apps." | |
| 1173 | ||
| 1174 | msgctxt "#30819" | |
| 1175 | msgid "Connect Trakt.tv account to sync watch progress." | |
| 1176 | msgstr "Conectar cuenta de Trakt.tv para sincronizar el progreso de visualización." | |
| 1177 | ||
| 1178 | msgctxt "#30820" | |
| 1179 | msgid "Seconds before Zilean requests time out (e.g., 30)." | |
| 1180 | msgstr "Segundos antes de que las solicitudes a Zilean expiren (ej. 30)." | |
| 1181 | ||
| 1182 | msgctxt "#30821" | |
| 1183 | msgid "Your Jackett Client Key (found in dashboard)." | |
| 1184 | msgstr "Tu Clave de Cliente de Jackett (se encuentra en el panel)." | |
| 1185 | ||
| 1186 | msgctxt "#30822" | |
| 1187 | msgid "Sort results by criteria (e.g., 'date', 'title', 'size')." | |
| 1188 | msgstr "Ordenar resultados por criterios (ej. 'fecha', 'título', 'tamaño')." | |
| 1189 | ||
| 1190 | msgctxt "#30823" | |
| 1191 | msgid "Configure Burst mode settings and providers." | |
| 1192 | msgstr "Configurar ajustes y proveedores del modo Burst." | |
| 1193 | ||
| 1194 | msgctxt "#30824" | |
| 1195 | msgid "Enable Premiumize.me cloud service." | |
| 1196 | msgstr "Habilitar el servicio en la nube Premiumize.me." | |
| 1197 | ||
| 1198 | msgctxt "#30825" | |
| 1199 | msgid "Truncate descriptions after [X] characters (0 for full text)." | |
| 1200 | msgstr "Truncar descripciones después de [X] caracteres (0 para texto completo)." | |
| 1201 | ||
| 1202 | msgctxt "#30826" | |
| 1203 | msgid "Enable Prowlarr indexer management." | |
| 1204 | msgstr "Habilitar la gestión de indexadores de Prowlarr." | |
| 1205 | ||
| 1206 | msgctxt "#30827" | |
| 1207 | msgid "Jackgram server URL (e.g., http://localhost:9117)." | |
| 1208 | msgstr "URL del servidor Jackgram (ej. http://localhost:9117)." | |
| 1209 | ||
| 1210 | msgctxt "#30828" | |
| 1211 | msgid "Debrider Client Key for authentication." | |
| 1212 | msgstr "Clave de Cliente de Debrider para autenticación." | |
| 1213 | ||
| 1214 | msgctxt "#30829" | |
| 1215 | msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)." | |
| 1216 | msgstr "Retraso (en segundos) antes de que aparezca el diálogo del próximo episodio (ej. 10)." | |
| 1217 | ||
| 1218 | msgctxt "#30830" | |
| 1219 | msgid "Specify the torrent client." | |
| 1220 | msgstr "Especificar el ejecutable del cliente de torrent." | |
| 1221 | ||
| 1222 | msgctxt "#30831" | |
| 1223 | msgid "Enable Debrider cached torrent support." | |
| 1224 | msgstr "Habilitar soporte para torrents en caché de EasyDebrid." | |
| 1225 | ||
| 1226 | msgctxt "#30832" | |
| 1227 | msgid "Torbox Client Token (found in account settings)." | |
| 1228 | msgstr "Clave de Cliente de Torbox (se encuentra en la configuración de la cuenta)." | |
| 1229 | ||
| 1230 | msgctxt "#30833" | |
| 1231 | msgid "Trakt refresh token (auto-managed)." | |
| 1232 | msgstr "Token de actualización de Trakt (gestionado automáticamente)." | |
| 1233 | ||
| 1234 | msgctxt "#30834" | |
| 1235 | msgid "Show confirmation dialog before playing next episode." | |
| 1236 | msgstr "Mostrar diálogo de confirmación antes de reproducir el próximo episodio." | |
| 1237 | ||
| 1238 | msgctxt "#30835" | |
| 1239 | msgid "Authorize add-on to access Real-Debrid account." | |
| 1240 | msgstr "Autorizar el complemento para acceder a la cuenta Real-Debrid." | |
| 1241 | ||
| 1242 | msgctxt "#30836" | |
| 1243 | msgid "Enable torrent-based media content" | |
| 1244 | msgstr "Habilita descargas de contenido multimedia mediante torrents." | |
| 1245 | ||
| 1246 | msgctxt "#30837" | |
| 1247 | msgid "Indicates if Real-Debrid account is linked." | |
| 1248 | msgstr "Indica si la cuenta Real-Debrid está vinculada." | |
| 1249 | ||
| 1250 | msgctxt "#30838" | |
| 1251 | msgid "Automatically play next media item when current playback ends." | |
| 1252 | msgstr "Reproducir automáticamente el siguiente elemento al terminar la reproducción actual." | |
| 1253 | ||
| 1254 | msgctxt "#30839" | |
| 1255 | msgid "Check for and install the latest add-on updates." | |
| 1256 | msgstr "Buscar e instalar las últimas actualizaciones del complemento." | |
| 1257 | ||
| 1258 | msgctxt "#30840" | |
| 1259 | msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)." | |
| 1260 | msgstr "IDs de indexadores de Prowlarr a usar, separados por comas (ej. 1,5,7)." | |
| 1261 | ||
| 1262 | msgctxt "#30841" | |
| 1263 | msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)." | |
| 1264 | msgstr "Tiempo de espera (segundos) para solicitudes de Prowlarr (ej. 20)." | |
| 1265 | ||
| 1266 | msgctxt "#30842" | |
| 1267 | msgid "Trakt authorization expiration date (read-only)." | |
| 1268 | msgstr "Fecha de expiración de la autorización de Trakt (solo lectura)." | |
| 1269 | ||
| 1270 | msgctxt "#30843" | |
| 1271 | msgid "Enable Torbox.de cloud torrent integration." | |
| 1272 | msgstr "Habilitar la integración de torrents en la nube de Torbox.de." | |
| 1273 | ||
| 1274 | msgctxt "#30844" | |
| 1275 | msgid "Trakt Access Token (auto-filled after auth)." | |
| 1276 | msgstr "Token de Acceso de Trakt (se autocompleta después de la autorización)." | |
| 1277 | ||
| 1278 | msgctxt "#30845" | |
| 1279 | msgid "Link Premiumize.me account to add-on." | |
| 1280 | msgstr "Vincular cuenta de Premiumize.me al complemento." | |
| 1281 | ||
| 1282 | msgctxt "#30846" | |
| 1283 | msgid "Clear cached data automatically after updating the add-on." | |
| 1284 | msgstr "Borrar datos en caché automáticamente tras actualizar el complemento." | |
| 1285 | ||
| 1286 | msgctxt "#30847" | |
| 1287 | msgid "Click to delete all cached data immediately." | |
| 1288 | msgstr "Haz clic para eliminar todos los datos en caché inmediatamente." | |
| 1289 | ||
| 1290 | msgctxt "#30848" | |
| 1291 | msgid "Real-Debrid Client Token (auto-filled after auth)." | |
| 1292 | msgstr "Token de Acceso de Real-Debrid (se autocompleta después de la autorización)." | |
| 1293 | ||
| 1294 | msgctxt "#30849" | |
| 1295 | msgid "Enable Jackett torrent proxy support." | |
| 1296 | msgstr "Habilitar soporte de proxy de torrents de Jackett." | |
| 1297 | ||
| 1298 | msgctxt "#30850" | |
| 1299 | msgid "Enable Jackgram integration for enhanced search." | |
| 1300 | msgstr "Habilitar la integración de Jackgram para búsquedas mejoradas." | |
| 1301 | ||
| 1302 | msgctxt "#30851" | |
| 1303 | msgid "Prowlarr instance URL (e.g., http://localhost)." | |
| 1304 | msgstr "URL de la instancia de Prowlarr (ej. http://localhost)." | |
| 1305 | ||
| 1306 | msgctxt "#30852" | |
| 1307 | msgid "Maximum number of search results to display (e.g., 50)." | |
| 1308 | msgstr "Número máximo de resultados de búsqueda a mostrar (ej. 50)." | |
| 1309 | ||
| 1310 | msgctxt "#30853" | |
| 1311 | msgid "Revoke Real-Debrid account access." | |
| 1312 | msgstr "Revocar el acceso a la cuenta Real-Debrid." | |
| 1313 | ||
| 1314 | msgctxt "#30854" | |
| 1315 | msgid "Prowlarr Client Key (found in Settings > General)." | |
| 1316 | msgstr "Clave de Cliente de Prowlarr (se encuentra en Configuración > General)." | |
| 1317 | ||
| 1318 | msgctxt "#30855" | |
| 1319 | msgid "Trakt.tv username (auto-populated after auth)." | |
| 1320 | msgstr "Nombre de usuario de Trakt.tv (se autocompleta después de la autorización)." | |
| 1321 | ||
| 1322 | msgctxt "#30856" | |
| 1323 | msgid "Disconnect Trakt.tv account from add-on." | |
| 1324 | msgstr "Desconectar la cuenta de Trakt.tv del complemento." | |
| 1325 | ||
| 1326 | msgctxt "#30857" | |
| 1327 | msgid "Enable Zilean integration for metadata fetching." | |
| 1328 | msgstr "Habilitar la integración de Zilean para obtener metadatos." | |
| 1329 | ||
| 1330 | msgctxt "#30858" | |
| 1331 | msgid "Your Client Key (required for metadata)" | |
| 1332 | msgstr "Tu Clave de Cliente (necesaria para metadatos)." | |
| 1333 | ||
| 1334 | msgctxt "#30859" | |
| 1335 | msgid "Enable Burst mode for accelerated searches." | |
| 1336 | msgstr "Habilitar modo Burst para búsquedas aceleradas." | |
| 1337 | ||
| 1338 | msgctxt "#30860" | |
| 1339 | msgid "Others" | |
| 1340 | msgstr "Otros" | |
| 1341 | ||
| 1342 | msgctxt "#30861" | |
| 1343 | msgid "Filters" | |
| 1344 | msgstr "Filtros" | |
| 1345 | ||
| 1346 | msgctxt "#30862" | |
| 1347 | msgid "Torrent (P2P) Configuration" | |
| 1348 | msgstr "Configuracion de Torrents (P2P)" | |
| 1349 | ||
| 1350 | msgctxt "#30863" | |
| 1351 | msgid "Specify the torrent client" | |
| 1352 | msgstr "Especificar el cliente de torrent" | |
| 1353 | ||
| 1354 | msgctxt "#30864" | |
| 1355 | msgid "Sources" | |
| 1356 | msgstr "Fuentes" | |
| 1357 | ||
| 1358 | msgctxt "#30865" | |
| 1359 | msgid "General Filters" | |
| 1360 | msgstr "Filtros Generales" | |
| 1361 | ||
| 1362 | msgctxt "#30866" | |
| 1363 | msgid "Sign in to your Stremio account to access add-ons and personalized features." | |
| 1364 | msgstr "Inicia sesión en tu cuenta de Stremio para acceder a complementos y funciones personalizadas." | |
| 1365 | ||
| 1366 | msgctxt "#30867" | |
| 1367 | msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data." | |
| 1368 | msgstr "Cierra sesión en tu cuenta de Stremio. Esto desconectará tu cuenta, pero no eliminará tus datos." | |
| 1369 | ||
| 1370 | msgctxt "#30868" | |
| 1371 | msgid "Log Out of Stremio" | |
| 1372 | msgstr "Cerrar sesión en Stremio" | |
| 1373 | ||
| 1374 | msgctxt "#30869" | |
| 1375 | msgid "Enable Stremio add-ons integration." | |
| 1376 | msgstr "Habilita la integración de complementos de Stremio." | |
| 1377 | ||
| 1378 | msgctxt "#30870" | |
| 1379 | msgid "Select Stream Addons" | |
| 1380 | msgstr "Seleccionar complementos de Stream" | |
| 1381 | ||
| 1382 | msgctxt "#30871" | |
| 1383 | msgid "Log In to Stremio" | |
| 1384 | msgstr "Iniciar sesión en Stremio" | |
| 1385 | ||
| 1386 | msgctxt "#30872" | |
| 1387 | msgid "Enable or disable Stremio add-ons." | |
| 1388 | msgstr "Habilita o deshabilita los complementos de Stremio." | |
| 1389 | ||
| 1390 | msgctxt "#30873" | |
| 1391 | msgid "Select language for player subtitle selection" | |
| 1392 | msgstr "Seleccionar el idioma del subtitlulo al reproducir" | |
| 1393 | ||
| 1394 | msgctxt "#30874" | |
| 1395 | msgid "Update Addons" | |
| 1396 | msgstr "Actualizar Addons" | |
| 1397 | ||
| 1398 | msgctxt "#30875" | |
| 1399 | msgid "Select Catalogs Addons" | |
| 1400 | msgstr "Seleccionar complementos de catalogos" | |
| 1401 | ||
| 1402 | msgctxt "#30876" | |
| 1403 | msgid "Download directory" | |
| 1404 | msgstr "Directorio de Descarga" | |
| 1405 | ||
| 1406 | msgctxt "#30877" | |
| 1407 | msgid "Enable Trakt.tv Scrobbling" | |
| 1408 | msgstr "Habilitar Trakt.tv Scrobbling" | |
| 1409 | ||
| 1410 | msgctxt "#30878" | |
| 1411 | msgid "Auto select subtitle when playing" | |
| 1412 | msgstr "Auto Seleccionar subtitulo al reproducir" | |
| 1413 | ||
| 1414 | msgctxt "#30879" | |
| 1415 | msgid "Auto-play quality" | |
| 1416 | msgstr "Calidad de reproducción automática" | |
| 1417 | ||
| 1418 | msgctxt "#30880" | |
| 1419 | msgid "1080p" | |
| 1420 | msgstr "1080p" | |
| 1421 | ||
| 1422 | msgctxt "#30881" | |
| 1423 | msgid "720p" | |
| 1424 | msgstr "1080p" | |
| 1425 | ||
| 1426 | msgctxt "#30882" | |
| 1427 | msgid "4k" | |
| 1428 | msgstr "4k" | |
| 1429 | ||
| 1430 | msgctxt "#30883" | |
| 1431 | msgid "480p" | |
| 1432 | msgstr "480p" | |
| 1433 | ||
| 1434 | msgctxt "#30884" | |
| 1435 | msgid "Subtitles Configuration" | |
| 1436 | msgstr "Configuracion de Subtitulos" | |
| 1437 | ||
| 1438 | msgctxt "#30885" | |
| 1439 | msgid "Addon Host" | |
| 1440 | msgstr "Host del Addon" | |
| 1441 | ||
| 1442 | msgctxt "#30886" | |
| 1443 | msgid "Subtitles" | |
| 1444 | msgstr "Subtitulos" | |
| 1445 | ||
| 1446 | msgctxt "#30887" | |
| 1447 | msgid "Primary Language" | |
| 1448 | msgstr "Idioma primario" | |
| 1449 | ||
| 1450 | msgctxt "#30888" | |
| 1451 | msgid "Secondary Language" | |
| 1452 | msgstr "Idioma secundario" | |
| 1453 | ||
| 1454 | msgctxt "#30889" | |
| 1455 | msgid "Deepl Enabled" | |
| 1456 | msgstr "Deepl habilitado" | |
| 1457 | ||
| 1458 | msgctxt "#30890" | |
| 1459 | msgid "Deepl Client Key" | |
| 1460 | msgstr "Clave de Cliente de Deepl" | |
| 1461 | ||
| 1462 | msgctxt "#30891" | |
| 1463 | msgid "Translator Configuration" | |
| 1464 | msgstr "Configuración del traductor" | |
| 1465 | ||
| 1466 | msgctxt "#30892" | |
| 1467 | msgid "Deepl Target Language" | |
| 1468 | msgstr "Idioma de destino de Deepl" | |
| 1469 | ||
| 1470 | msgctxt "#30893" | |
| 1471 | msgid "Stremio Subtitle Configuration" | |
| 1472 | msgstr "Configuración de subtítulos de Stremio" | |
| 1473 | ||
| 1474 | msgctxt "#90001" | |
| 1475 | msgid "Add Custom Addon" | |
| 1476 | msgstr "Agregar Complemento Personalizado" | |
| 1477 | ||
| 1478 | msgctxt "#90002" | |
| 1479 | msgid "Add a custom Stremio addon by entering its URL" | |
| 1480 | msgstr "Agrega un complemento personalizado de Stremio ingresando su URL" | |
| 1481 | ||
| 1482 | msgctxt "#90003" | |
| 1483 | msgid "Auto-select audio when playing" | |
| 1484 | msgstr "Auto-seleccionar audio" | |
| 1485 | ||
| 1486 | msgctxt "#90004" | |
| 1487 | msgid "Audio Language" | |
| 1488 | msgstr "Idioma de Audio" | |
| 1489 | ||
| 1490 | msgctxt "#90005" | |
| 1491 | msgid "Select audio language for playing the video" | |
| 1492 | msgstr "Seleccionar el idioma del audio para la reproduccion" | |
| 1493 | ||
| 1494 | msgctxt "#90006" | |
| 1495 | msgid "Search" | |
| 1496 | msgstr "Buscar" | |
| 1497 | ||
| 1498 | msgctxt "#90007" | |
| 1499 | msgid "TV Shows" | |
| 1500 | msgstr "Series" | |
| 1501 | ||
| 1502 | msgctxt "#90008" | |
| 1503 | msgid "Movies" | |
| 1504 | msgstr "Películas" | |
| 1505 | ||
| 1506 | msgctxt "#90009" | |
| 1507 | msgid "Anime" | |
| 1508 | msgstr "Anime" | |
| 1509 | ||
| 1510 | msgctxt "#90010" | |
| 1511 | msgid "Live TV" | |
| 1512 | msgstr "TV en vivo" | |
| 1513 | ||
| 1514 | msgctxt "#90011" | |
| 1515 | msgid "Direct Search" | |
| 1516 | msgstr "Búsqueda Directa" | |
| 1517 | ||
| 1518 | msgctxt "#90012" | |
| 1519 | msgid "Torrents" | |
| 1520 | msgstr "Torrents" | |
| 1521 | ||
| 1522 | msgctxt "#90013" | |
| 1523 | msgid "Telegram" | |
| 1524 | msgstr "Telegram" | |
| 1525 | ||
| 1526 | msgctxt "#90014" | |
| 1527 | msgid "Cloud" | |
| 1528 | msgstr "Nube" | |
| 1529 | ||
| 1530 | msgctxt "#90015" | |
| 1531 | msgid "Downloads" | |
| 1532 | msgstr "Descargas" | |
| 1533 | ||
| 1534 | msgctxt "#90016" | |
| 1535 | msgid "Settings" | |
| 1536 | msgstr "Ajustes" | |
| 1537 | ||
| 1538 | msgctxt "#90017" | |
| 1539 | msgid "History" | |
| 1540 | msgstr "Historial" | |
| 1541 | ||
| 1542 | msgctxt "#90018" | |
| 1543 | msgid "Donate" | |
| 1544 | msgstr "Donar" | |
| 1545 | ||
| 1546 | msgctxt "#90019" | |
| 1547 | msgid "Recently Watched Files" | |
| 1548 | msgstr "Archivos Recientemente Reproducidos" | |
| 1549 | ||
| 1550 | msgctxt "#90020" | |
| 1551 | msgid "Recently Watched Titles" | |
| 1552 | msgstr "Titulos Recientemente Reproducidos" | |
| 1553 | ||
| 1554 | msgctxt "#90021" | |
| 1555 | msgid "TV Calendar" | |
| 1556 | msgstr "Calendario de TV" | |
| 1557 | ||
| 1558 | msgctxt "#90022" | |
| 1559 | msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:" | |
| 1560 | msgstr "Si disfrutas usando Jacktook y quieres apoyar su desarrollo, puedes comprarme un café! Tu apoyo ayuda a mantener el proyecto activo y habilita mejoras futuras:" | |
| 1561 | ||
| 1562 | msgctxt "#90023" | |
| 1563 | msgid "Support Jacktook" | |
| 1564 | msgstr "Apoya a Jacktook" | |
| 1565 | ||
| 1566 | msgctxt "#90024" | |
| 1567 | msgid "Trending Recent" | |
| 1568 | msgstr "Tendencia Reciente" | |
| 1569 | ||
| 1570 | msgctxt "#90025" | |
| 1571 | msgid "Genres" | |
| 1572 | msgstr "Géneros" | |
| 1573 | ||
| 1574 | msgctxt "#90026" | |
| 1575 | msgid "Calendar" | |
| 1576 | msgstr "Calendario" | |
| 1577 | ||
| 1578 | msgctxt "#90027" | |
| 1579 | msgid "Years" | |
| 1580 | msgstr "Años" | |
| 1581 | ||
| 1582 | msgctxt "#90028" | |
| 1583 | msgid "Trending" | |
| 1584 | msgstr "Tendencias" | |
| 1585 | ||
| 1586 | msgctxt "#90029" | |
| 1587 | msgid "Most Watched" | |
| 1588 | msgstr "Más vistos" | |
| 1589 | ||
| 1590 | msgctxt "#90030" | |
| 1591 | msgid "Most Favorited" | |
| 1592 | msgstr "Más favoritos" | |
| 1593 | ||
| 1594 | msgctxt "#90031" | |
| 1595 | msgid "Popular Lists" | |
| 1596 | msgstr "Listas populares" | |
| 1597 | ||
| 1598 | msgctxt "#90032" | |
| 1599 | msgid "Trending Lists" | |
| 1600 | msgstr "Listas en tendencia" | |
| 1601 | ||
| 1602 | msgctxt "#90033" | |
| 1603 | msgid "Recomendations" | |
| 1604 | msgstr "Recomendaciones" | |
| 1605 | ||
| 1606 | msgctxt "#90034" | |
| 1607 | msgid "Watched History" | |
| 1608 | msgstr "Historial de vistos" | |
| 1609 | ||
| 1610 | msgctxt "#90035" | |
| 1611 | msgid "Watchlist" | |
| 1612 | msgstr "Lista para ver" | |
| 1613 | ||
| 1614 | msgctxt "#90036" | |
| 1615 | msgid "Top 10 BoxOffice" | |
| 1616 | msgstr "Top 10 en taquilla" | |
| 1617 | ||
| 1618 | msgctxt "#90037" | |
| 1619 | msgid "Popular" | |
| 1620 | msgstr "Popular" | |
| 1621 | ||
| 1622 | msgctxt "#90038" | |
| 1623 | msgid "Popular Recent" | |
| 1624 | msgstr "Popular Reciente" | |
| 1625 | ||
| 1626 | msgctxt "#90039" | |
| 1627 | msgid "Airing" | |
| 1628 | msgstr "En Emisión" | |
| 1629 | ||
| 1630 | msgctxt "#90040" | |
| 1631 | msgid "Years" | |
| 1632 | msgstr "Años" | |
| 1633 | ||
| 1634 | msgctxt "#90041" | |
| 1635 | msgid "Genres" | |
| 1636 | msgstr "Géneros" | |
| 1637 | ||
| 1638 | msgctxt "#90042" | |
| 1639 | msgid "Top Rated" | |
| 1640 | msgstr "Top Rated" | |
| 1641 | ||
| 1642 | msgctxt "#90043" | |
| 1643 | msgid "Most Watched" | |
| 1644 | msgstr "Más Visto" | |
| 1645 | ||
| 1646 | msgctxt "#90044" | |
| 1647 | msgid "Torrentio Configuration" | |
| 1648 | msgstr "Configuracion de Torrentio " | |
| 1649 | ||
| 1650 | msgctxt "#90045" | |
| 1651 | msgid "Enable Torrentio Settings" | |
| 1652 | msgstr "Habiitar Configuracion de Torrentio" | |
| 1653 | ||
| 1654 | msgctxt "#90046" | |
| 1655 | msgid "Enable or disable the Torrentio Configuration" | |
| 1656 | msgstr "Activa o desactiva la configuracion de Torrentio." | |
| 1657 | ||
| 1658 | msgctxt "#90047" | |
| 1659 | msgid "Select Torrentio Providers" | |
| 1660 | msgstr "Seleccionar proveedores de Torrentio" | |
| 1661 | ||
| 1662 | msgctxt "#90048" | |
| 1663 | msgid "Choose which providers to include in the stream request." | |
| 1664 | msgstr "Elige qué proveedores incluir en las peticiones de reproducción." | |
| 1665 | ||
| 1666 | msgctxt "#90049" | |
| 1667 | msgid "Scrape again" | |
| 1668 | msgstr "Volver a escanear" | |
| 1669 | ||
| 1670 | msgctxt "#90050" | |
| 1671 | msgid "Recommendations" | |
| 1672 | msgstr "Recomendaciones" | |
| 1673 | ||
| 1674 | msgctxt "#90051" | |
| 1675 | msgid "Similar" | |
| 1676 | msgstr "Similares" | |
| 1677 | ||
| 1678 | msgctxt "#90052" | |
| 1679 | msgid "Thread Number" | |
| 1680 | msgstr "Numero de hilos" | |
| 1681 | ||
| 1682 | msgctxt "#90053" | |
| 1683 | msgid "Select the number of threads (1-30)" | |
| 1684 | msgstr "Selecciona el numero de hilos (1-30)" | |
| 1685 | ||
| 1686 | msgctxt "#90054" | |
| 1687 | msgid "Kodi logs" | |
| 1688 | msgstr "Registros de Kodi" | |
| 1689 | ||
| 1690 | msgctxt "#90055" | |
| 1691 | msgid "Kodi old logs" | |
| 1692 | msgstr "Registro antiguos de Kodi" | |
| 1693 | ||
| 1694 | msgctxt "#90056" | |
| 1695 | msgid "Displays Kodi logs to help diagnose issues." | |
| 1696 | msgstr "Muestra los registros de Kodi para ayudar a diagnosticar problemas" | |
| 1697 | ||
| 1698 | msgctxt "#90057" | |
| 1699 | msgid "Logs" | |
| 1700 | msgstr "Registros" | |
| 1701 | ||
| 1702 | msgctxt "#90058" | |
| 1703 | msgid "Filter by Size" | |
| 1704 | msgstr "Filtrar por tamaño" | |
| 1705 | ||
| 1706 | msgctxt "#90059" | |
| 1707 | msgid "Enable Size Filter" | |
| 1708 | msgstr "Habilitar" | |
| 1709 | ||
| 1710 | msgctxt "#90060" | |
| 1711 | msgid "Minimum Size (MB)" | |
| 1712 | msgstr "Tamaño minimo (MB)" | |
| 1713 | ||
| 1714 | msgctxt "#90061" | |
| 1715 | msgid "Maximum Size (MB)" | |
| 1716 | msgstr "Tamaño Maximo (MB)" | |
| 1717 | ||
| 1718 | msgctxt "#90062" | |
| 1719 | msgid "Enable Cached Search" | |
| 1720 | msgstr "Habilitar búsqueda en caché" | |
| 1721 | ||
| 1722 | msgctxt "#90063" | |
| 1723 | msgid "Only show cached streams from external services" | |
| 1724 | msgstr "Mostrar solo streams en caché de servicios externos" | |
| 1725 | ||
| 1726 | msgctxt "#90064" | |
| 1727 | msgid "Popular" | |
| 1728 | msgstr "Popular" | |
| 1729 | ||
| 1730 | msgctxt "#90065" | |
| 1731 | msgid "Languages" | |
| 1732 | msgstr "Idiomas" | |
| 1733 | ||
| 1734 | msgctxt "#90066" | |
| 1735 | msgid "Networks" | |
| 1736 | msgstr "Cadenas" | |
| 1737 | ||
| 1738 | msgctxt "#90067" | |
| 1739 | msgid "Collections" | |
| 1740 | msgstr "Colecciones" | |
| 1741 | ||
| 1742 | msgctxt "#90068" | |
| 1743 | msgid "Search for collections:" | |
| 1744 | msgstr "Buscar colecciones" | |
| 1745 | ||
| 1746 | msgctxt "#90069" | |
| 1747 | msgid "Main Menu" | |
| 1748 | msgstr "Menu Principal" | |
| 1749 | ||
| 1750 | msgctxt "#90070" | |
| 1751 | msgid "Last Titles" | |
| 1752 | msgstr "Ultimos Titulos" | |
| 1753 | ||
| 1754 | msgctxt "#90071" | |
| 1755 | msgid "Last Files" | |
| 1756 | msgstr "Ultimos Videos" | |
| 1757 | ||
| 1758 | msgctxt "#90072" | |
| 1759 | msgid "Trakt Lists" | |
| 1760 | msgstr "Listas de Trakt" | |
| 1761 | ||
| 1762 | msgctxt "#90073" | |
| 1763 | msgid "Metadata" | |
| 1764 | msgstr "Metadatos" | |
| 1765 | ||
| 1766 | msgctxt "#90074" | |
| 1767 | msgid "Top Lists" | |
| 1768 | msgstr "Listas Top" | |
| 1769 | ||
| 1770 | msgctxt "#90075" | |
| 1771 | msgid "User Lists" | |
| 1772 | msgstr "Listas de Usuario" | |
| 1773 | ||
| 1774 | msgctxt "#90076" | |
| 1775 | msgid "Search Lists" | |
| 1776 | msgstr "Buscar Listas" | |
| 1777 | ||
| 1778 | msgctxt "#90077" | |
| 1779 | msgid "MDbList Configuration" | |
| 1780 | msgstr "Configuración de MDbList" | |
| 1781 | ||
| 1782 | msgctxt "#90078" | |
| 1783 | msgid "People" | |
| 1784 | msgstr "Personas" | |
| 1785 | ||
| 1786 | msgctxt "#90079" | |
| 1787 | msgid "Popular People" | |
| 1788 | msgstr "Personas Populares" | |
| 1789 | ||
| 1790 | msgctxt "#90080" | |
| 1791 | msgid "Latest People" | |
| 1792 | msgstr "Ultimas Personas" | |
| 1793 | ||
| 1794 | msgctxt "#90081" | |
| 1795 | msgid "Search People" | |
| 1796 | msgstr "Buscar Personas" | |
| 1797 | ||
| 1798 | msgctxt "#90082" | |
| 1799 | msgid "Download subtitles" | |
| 1800 | msgstr "Descargar Subtitulos" | |
| 1801 | ||
| 1802 | msgctxt "#90083" | |
| 1803 | msgid "Download video" | |
| 1804 | msgstr "Descargar video" | |
| 1805 | ||
| 1806 | msgctxt "#90084" | |
| 1807 | msgid "Browse into" | |
| 1808 | msgstr "Buscar dentro" | |
| 1809 | ||
| 1810 | msgctxt "#90085" | |
| 1811 | msgid "Include TV show Specials" | |
| 1812 | msgstr "Incluir Especiales en shows de TV" | |
| 1813 | ||
| 1814 | msgctxt "#90086" | |
| 1815 | msgid "Airing Today" | |
| 1816 | msgstr "En Emisión Hoy" | |
| 1817 | ||
| 1818 | msgctxt "#90099" | |
| 1819 | msgid "Filter by Source Type" | |
| 1820 | msgstr "Filtrar por tipo de fuente" | |
| 1821 | ||
| 1822 | msgctxt "#90100" | |
| 1823 | msgid "Include BluRay/UHD sources" | |
| 1824 | msgstr "Incluir fuentes BluRay/UHD" | |
| 1825 | ||
| 1826 | msgctxt "#90105" | |
| 1827 | msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1828 | msgstr "Incluye lanzamientos como: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1829 | ||
| 1830 | msgctxt "#90101" | |
| 1831 | msgid "Include WEB/HD sources" | |
| 1832 | msgstr "Incluir fuentes WEB/HD" | |
| 1833 | ||
| 1834 | msgctxt "#90106" | |
| 1835 | msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1836 | msgstr "Incluye lanzamientos como: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1837 | ||
| 1838 | msgctxt "#90102" | |
| 1839 | msgid "Include DVD/TV/SAT sources" | |
| 1840 | msgstr "Incluir fuentes DVD/TV/SAT" | |
| 1841 | ||
| 1842 | msgctxt "#90107" | |
| 1843 | msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1844 | msgstr "Incluye lanzamientos como: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1845 | ||
| 1846 | msgctxt "#90103" | |
| 1847 | msgid "Include CAM/Screener sources" | |
| 1848 | msgstr "Incluir fuentes CAM/Screener" | |
| 1849 | ||
| 1850 | msgctxt "#90108" | |
| 1851 | msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR" | |
| 1852 | msgstr "Incluye lanzamientos como: CAM, TeleSync, TeleCine, SCR" | |
| 1853 | ||
| 1854 | msgctxt "#90104" | |
| 1855 | msgid "Include Unknown sources" | |
| 1856 | msgstr "Incluir fuentes desconocidas" | |
| 1857 | ||
| 1858 | msgctxt "#90109" | |
| 1859 | msgid "Includes uncategorized or unidentified sources" | |
| 1860 | msgstr "Incluye fuentes no categorizadas o no identificadas" | |
| 1861 | ||
| 1862 | msgctxt "#90110" | |
| 1863 | msgid "Do you want to clear your watched history?." | |
| 1864 | msgstr "¿Quieres borrar tu historial de vistos?" | |
| 1865 | ||
| 1866 | msgctxt "#90111" | |
| 1867 | msgid "Do you want to clear your history?." | |
| 1868 | msgstr "¿Quieres borrar tu historial?" | |
| 1869 | ||
| 1870 | msgctxt "#90112" | |
| 1871 | msgid "Clear History" | |
| 1872 | msgstr "Borrar Historial" | |
| 1873 | ||
| 1874 | msgctxt "#90113" | |
| 1875 | msgid "Click to delete all watched history immediately." | |
| 1876 | msgstr "Click para borrar todo tu historial inmediatamente" | |
| 1877 | ||
| 1878 | msgctxt "#90114" | |
| 1879 | msgid "History cleared" | |
| 1880 | msgstr "Historial borrado" | |
| 1881 | ||
| 1882 | msgctxt "#90115" | |
| 1883 | msgid "Source Select" | |
| 1884 | msgstr "Seleccionar Fuentes" | |
| 1885 | ||
| 1886 | msgctxt "#90116" | |
| 1887 | msgid "Open Settings" | |
| 1888 | msgstr "Abrir Configuracion" | |
| 1889 | ||
| 1890 | msgctxt "#90117" | |
| 1891 | msgid "Select Torrentio Providers" | |
| 1892 | msgstr "Seleccionar proveedores de Torrentio" | |
| 1893 | ||
| 1894 | msgctxt "#90118" | |
| 1895 | msgid "Port" | |
| 1896 | msgstr "Puerto" | |
| 1897 | ||
| 1898 | msgctxt "#90119" | |
| 1899 | msgid "Jackett port (default:9117)" | |
| 1900 | msgstr "Puerto de Jackett (por defecto: 9117)" | |
| 1901 | ||
| 1902 | msgctxt "#90120" | |
| 1903 | msgid "Prowlarr port (default:9696)" | |
| 1904 | msgstr "Puerto de Prowlarr (por defecto: 9696)" | |
| 1905 | ||
| 1906 | msgctxt "#90121" | |
| 1907 | msgid "All Debrid Configuration" | |
| 1908 | msgstr "Configuracion de AllDebrid" | |
| 1909 | ||
| 1910 | msgctxt "#90122" | |
| 1911 | msgid "Auto download subtitles" | |
| 1912 | msgstr "Auto Descargar subtitulos" | |
| 1913 | ||
| 1914 | msgctxt "#90123" | |
| 1915 | msgid "Stremio Timeout" | |
| 1916 | msgstr "Tiempo de espera para Stremio" | |
| 1917 | ||
| 1918 | msgctxt "#90124" | |
| 1919 | msgid "Webdav Client Configuration" | |
| 1920 | msgstr "Configuración del Cliente Webdav" | |
| 1921 | ||
| 1922 | msgctxt "#90126" | |
| 1923 | msgid "Host" | |
| 1924 | msgstr "Servidor" | |
| 1925 | ||
| 1926 | msgctxt "#90127" | |
| 1927 | msgid "Username" | |
| 1928 | msgstr "Usuario" | |
| 1929 | ||
| 1930 | msgctxt "#90128" | |
| 1931 | msgid "Password" | |
| 1932 | msgstr "Contraseña" | |
| 1933 | ||
| 1934 | msgctxt "#90129" | |
| 1935 | msgid "Test Connection" | |
| 1936 | msgstr "Probar Conexión" | |
| 1937 | ||
| 1938 | msgctxt "#90131" | |
| 1939 | msgid "Webdav" | |
| 1940 | msgstr "Webdav" | |
| 1941 | ||
| 1942 | msgctxt "#90132" | |
| 1943 | msgid "Remote Path" | |
| 1944 | msgstr "Ruta Remota" | |
| 1945 | ||
| 1946 | msgctxt "#90133" | |
| 1947 | msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)." | |
| 1948 | msgstr "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 | ||
| 1950 | msgctxt "#90134" | |
| 1951 | msgid "The username required for authenticating with your WebDAV server" | |
| 1952 | msgstr "El nombre de usuario requerido para autenticarse con tu servidor WebDAV." | |
| 1953 | ||
| 1954 | msgctxt "#90135" | |
| 1955 | msgid "The password for the specified username to authenticate with your WebDAV server" | |
| 1956 | msgstr "La contraseña del nombre de usuario especificado para autenticarse con tu servidor WebDAV." | |
| 1957 | ||
| 1958 | msgctxt "#90136" | |
| 1959 | msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)." | |
| 1960 | msgstr "La ruta o directorio base en el servidor WebDAV donde se encuentran tus archivos multimedia (por ejemplo, /movies)." | |
| 1961 | ||
| 1962 | msgctxt "#90137" | |
| 1963 | msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials." | |
| 1964 | msgstr "Haz clic para verificar si el cliente WebDAV puede conectarse correctamente al servidor configurado usando las credenciales proporcionadas." | |
| 1965 | ||
| 1966 | msgctxt "#90138" | |
| 1967 | msgid "Remove Custom Addon" | |
| 1968 | msgstr "Eliminar Complemento Personalizado" | |
| 1969 | ||
| 1970 | msgctxt "#90139" | |
| 1971 | msgid "Playback" | |
| 1972 | msgstr "Reproduccion" | |
| 1973 | ||
| 1974 | msgctxt "#90140" | |
| 1975 | msgid "Super quick play" | |
| 1976 | msgstr "Reproducción súper rápida" | |
| 1977 | ||
| 1978 | msgctxt "#90141" | |
| 1979 | msgid "Re-ask to play last played video" | |
| 1980 | msgstr "Volver a preguntar para reproducir el último video" | |
| 1981 | ||
| 1982 | msgctxt "#90142" | |
| 1983 | msgid "Replay Dialog" | |
| 1984 | msgstr "Diálogo de Repetición" | |
| 1985 | ||
| 1986 | msgctxt "#90143" | |
| 1987 | msgid "Do you want to play last watched?" | |
| 1988 | msgstr "¿Quieres reproducir la última visualización?" | |
| 1989 | ||
| 1990 | msgctxt "#90144" | |
| 1991 | msgid "Could not resolve playback URL" | |
| 1992 | msgstr "No se pudo resolver la URL de reproducción" | |
| 1993 | ||
| 1994 | msgctxt "#90145" | |
| 1995 | msgid "Precaching Enabled" | |
| 1996 | msgstr "Precaching Habilitado" | |
| 1997 | ||
| 1998 | msgctxt "#90146" | |
| 1999 | msgid "Enable or disable precaching of next episodes." | |
| 2000 | msgstr "Habilitar o deshabilitar el precaching de los próximos episodios." | |
| 2001 | ||
| 2002 | msgctxt "#90147" | |
| 2003 | msgid "Precaching Episode Count" | |
| 2004 | msgstr "Cantidad de Episodios a Precargar" | |
| 2005 | ||
| 2006 | msgctxt "#90148" | |
| 2007 | msgid "Number of next episodes to precache." | |
| 2008 | msgstr "Número de próximos episodios a precargar." | |
| 2009 | ||
| 2010 | msgctxt "#30901" | |
| 2011 | msgid "Show Only Stremio Catalogs" | |
| 2012 | msgstr "Mostrar solo catálogos de Stremio" | |
| 2013 | ||
| 2014 | msgctxt "#30902" | |
| 2015 | msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus." | |
| 2016 | msgstr "Oculta elementos integrados (TMDb, Trakt, etc.) y muestra solo catálogos de Stremio en los menús." | |
| 2017 | ||
| 2018 | msgctxt "#30903" | |
| 2019 | msgid "Select Live TV Addons" | |
| 2020 | msgstr "Seleccionar complementos de Live TV" | |
| 2021 | ||
| 2022 | msgctxt "#30904" | |
| 2023 | msgid "Choose specific Stremio addons for Live TV content" | |
| 2024 | msgstr "Seleccionar complementos de Stremio para contenido de Live TV" | |
| 2025 | ||
| 2026 | msgctxt "#30920" | |
| 2027 | msgid "Clear Trakt Cache" | |
| 2028 | msgstr "Limpiar caché de Trakt" | |
| 2029 | ||
| 2030 | msgctxt "#30921" | |
| 2031 | msgid "Clear TMDB Cache" | |
| 2032 | msgstr "Limpiar caché de TMDB" | |
| 2033 | ||
| 2034 | msgctxt "#30922" | |
| 2035 | msgid "Clear Providers Cache" | |
| 2036 | msgstr "Limpiar caché de Proveedores" | |
| 2037 | ||
| 2038 | msgctxt "#30923" | |
| 2039 | msgid "Clear Settings Cache" | |
| 2040 | msgstr "Limpiar caché de Configuración" | |
| 2041 | ||
| 2042 | msgctxt "#90149" | |
| 2043 | msgid "Maintenance" | |
| 2044 | msgstr "Mantenimiento" | |
| 2045 | ||
| 2046 | msgctxt "#90150" | |
| 2047 | msgid "Stremio: General" | |
| 2048 | msgstr "Stremio: General" | |
| 2049 | ||
| 2050 | msgctxt "#90151" | |
| 2051 | msgid "Stremio: Account" | |
| 2052 | msgstr "Stremio: Cuenta" | |
| 2053 | ||
| 2054 | msgctxt "#90152" | |
| 2055 | msgid "Stremio: Addons & Catalogs" | |
| 2056 | msgstr "Stremio: Addons y catálogos" | |
| 2057 | ||
| 2058 | msgctxt "#90153" | |
| 2059 | msgid "Stremio: Torrentio" | |
| 2060 | msgstr "Stremio: Torrentio" | |
| 2061 | ||
| 2062 | msgctxt "#90154" | |
| 2063 | msgid "Trending Recent" | |
| 2064 | msgstr "Tendencias Recientes" | |
| 2065 | ||
| 2066 | msgctxt "#90155" | |
| 2067 | msgid "Stremio: Filters" | |
| 2068 | msgstr "Stremio: Filtros" | |
| 2069 | ||
| 2070 | msgctxt "#32300" | |
| 2071 | msgid "Filter by Quality" | |
| 2072 | msgstr "Filtrar por Calidad" | |
| 2073 | ||
| 2074 | msgctxt "#32301" | |
| 2075 | msgid "Filter by Provider" | |
| 2076 | msgstr "Filtrar por Proveedor" | |
| 2077 | ||
| 2078 | msgctxt "#32302" | |
| 2079 | msgid "Filter by Source" | |
| 2080 | msgstr "Filtrar por Fuente" | |
| 2081 | ||
| 2082 | msgctxt "#32303" | |
| 2083 | msgid "Filter by Language" | |
| 2084 | msgstr "Filtrar por Idioma" | |
| 2085 | ||
| 2086 | msgctxt "#32304" | |
| 2087 | msgid "Filter by Torrents" | |
| 2088 | msgstr "Filtrar por Torrents" | |
| 2089 | ||
| 2090 | msgctxt "#32305" | |
| 2091 | msgid "Filter by Debrid" | |
| 2092 | msgstr "Filtrar por Debrid" | |
| 2093 | ||
| 2094 | msgctxt "#32306" | |
| 2095 | msgid "Reset Filter" | |
| 2096 | msgstr "Restablecer Filtro" | |
| 2097 | ||
| 2098 | msgctxt "#30930" | |
| 2099 | msgid "Ping Addons" | |
| 2100 | msgstr "Ping Addons" | |
| 2101 | ||
| 2102 | msgctxt "#30931" | |
| 2103 | msgid "Check availability of addons and show only reachable ones" | |
| 2104 | msgstr "Comprueba la disponibilidad de los addons y muestra solo los accesibles" | |
| 2105 | ||
| 2106 | msgctxt "#90164" | |
| 2107 | msgid "Skip Intro / Recap" | |
| 2108 | msgstr "Saltar Intro / Recap" | |
| 2109 | ||
| 2110 | msgctxt "#90165" | |
| 2111 | msgid "Enable Skip Intro" | |
| 2112 | msgstr "Habilitar Skip Intro" | |
| 2113 | ||
| 2114 | msgctxt "#90166" | |
| 2115 | msgid "Detect intros and recaps using IntroDB and offer to skip them during playback" | |
| 2116 | msgstr "Detectar intros y recaps usando IntroDB y ofrecer la opción de saltarlos durante la reproducción" | |
| 2117 | ||
| 2118 | msgctxt "#90167" | |
| 2119 | msgid "Auto-skip" | |
| 2120 | msgstr "Auto-saltar" | |
| 2121 | ||
| 2122 | msgctxt "#90168" | |
| 2123 | msgid "Automatically skip intros and recaps without showing the button" | |
| 2124 | msgstr "Saltar automáticamente intros y recaps sin mostrar el botón" | |
| 2125 | ||
| 2126 | msgctxt "#90160" | |
| 2127 | msgid "Skip Intro" | |
| 2128 | msgstr "Saltar Intro" | |
| 2129 | ||
| 2130 | msgctxt "#90161" | |
| 2131 | msgid "Skip Recap" | |
| 2132 | msgstr "Saltar Recap" | |
| 2133 | ||
| 2134 | msgctxt "#90162" | |
| 2135 | msgid "Manage from Phone" | |
| 2136 | msgstr "Gestionar desde el teléfono" | |
| 2137 | ||
| 2138 | msgctxt "#90163" | |
| 2139 | msgid "Start a local web server and show a QR code to manage Stremio sources from your phone" | |
| 2140 | msgstr "Iniciar un servidor web local y mostrar un código QR para gestionar las fuentes de Stremio desde tu teléfono" | |
| 2141 | ||
| 2142 | msgctxt "#90200" | |
| 2143 | msgid "Continue Watching" | |
| 2144 | msgstr "Continuar Viendo" | |
| 2145 | ||
| 2146 | msgctxt "#90201" | |
| 2147 | msgid "Library" | |
| 2148 | msgstr "Biblioteca" | |
| 2149 | ||
| 2150 | msgctxt "#90202" | |
| 2151 | msgid "My Shows" | |
| 2152 | msgstr "Mis Series" | |
| 2153 | ||
| 2154 | msgctxt "#90203" | |
| 2155 | msgid "My Movies" | |
| 2156 | msgstr "Mis Películas" | |
| 2157 | ||
| 2158 | msgctxt "#90204" | |
| 2159 | msgid "Remove from Library" | |
| 2160 | msgstr "Quitar de la Biblioteca" | |
| 2161 | ||
| 2162 | msgctxt "#90205" | |
| 2163 | msgid "Add to Library" | |
| 2164 | msgstr "Añadir a la biblioteca" | |
| 2165 | ||
| 2166 | msgctxt "#90206" | |
| 2167 | msgid "Remove" | |
| 2168 | msgstr "Eliminar" | |
| 2169 | ||
| 2170 | msgctxt "#90207" | |
| 2171 | msgid "Search Movies & TV" | |
| 2172 | msgstr "Buscar Películas y Series" | |
| 2173 | ||
| 2174 | msgctxt "#90208" | |
| 2175 | msgid "Recent Searches" | |
| 2176 | msgstr "Búsquedas Recientes" | |
| 2177 | ||
| 2178 | msgctxt "#90209" | |
| 2179 | msgid "Recent Direct Searches" | |
| 2180 | msgstr "Búsquedas Directas Recientes" | |
| 2181 | ||
| 2182 | msgctxt "#90210" | |
| 2183 | msgid "Clear All Search History" | |
| 2184 | msgstr "Borrar Todo el Historial de Búsqueda" | |
| 2185 | ||
| 2186 | msgctxt "#90211" | |
| 2187 | msgid "Bypass Stremio Addons Native Sorting" | |
| 2188 | msgstr "Omitir Filtros de Addons para Ordenado Nativo" | |
| 2189 | ||
| 2190 | msgctxt "#90212" | |
| 2191 | msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds." | |
| 2192 | msgstr "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 | ||
| 2194 | msgctxt "#90213" | |
| 2195 | msgid "Select Bypassed Addons" | |
| 2196 | msgstr "Seleccionar Addons para Omitir Filtros" | |
| 2197 | ||
| 2198 | msgctxt "#90214" | |
| 2199 | msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering." | |
| 2200 | msgstr "Selecciona qué addons de Stremio omitirán el filtrado nativo de tamaño y calidad de Jacktook." | |
| 2201 | ||
| 2202 | msgctxt "#90215" | |
| 2203 | msgid "Downgrade" | |
| 2204 | msgstr "Revertir" | |
| 2205 | ||
| 2206 | msgctxt "#90671" | |
| 2207 | msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails." | |
| 2208 | msgstr "Instala una version publicada anterior de Jacktook. La version actual se respalda y se restaura automaticamente si la reversion falla." | |
| 2209 | ||
| 2210 | msgctxt "#30932" | |
| 2211 | msgid "Update Action" | |
| 2212 | msgstr "Acción de Actualización" | |
| 2213 | ||
| 2214 | msgctxt "#30933" | |
| 2215 | msgid "Choose how to handle background update checks." | |
| 2216 | msgstr "Elige cómo manejar las comprobaciones de actualización en segundo plano." | |
| 2217 | ||
| 2218 | msgctxt "#30934" | |
| 2219 | msgid "Ask" | |
| 2220 | msgstr "Preguntar" | |
| 2221 | ||
| 2222 | msgctxt "#30935" | |
| 2223 | msgid "Notify" | |
| 2224 | msgstr "Notificar" | |
| 2225 | ||
| 2226 | msgctxt "#90216" | |
| 2227 | msgid "Volume Check Enabled" | |
| 2228 | msgstr "Control de Volumen" | |
| 2229 | ||
| 2230 | msgctxt "#90217" | |
| 2231 | msgid "Automatically limits volume if it is above the threshold before playback." | |
| 2232 | msgstr "Limita automáticamente el volumen si está por encima del umbral antes de la reproducción." | |
| 2233 | ||
| 2234 | msgctxt "#90218" | |
| 2235 | msgid "Volume Threshold" | |
| 2236 | msgstr "Umbral de Volumen" | |
| 2237 | ||
| 2238 | msgctxt "#90219" | |
| 2239 | msgid "The volume percentage above which the warning triggers." | |
| 2240 | msgstr "El porcentaje de volumen por encima del cual se activa el ajuste." | |
| 2241 | ||
| 2242 | msgctxt "#90220" | |
| 2243 | msgid "High Volume Warning" | |
| 2244 | msgstr "Aviso de Volumen Alto" | |
| 2245 | ||
| 2246 | msgctxt "#90221" | |
| 2247 | msgid "High volume detected. Adjusting to %s%% for safety." | |
| 2248 | msgstr "Volumen alto detectado. Ajustando al %s%% por seguridad." | |
| 2249 | ||
| 2250 | msgctxt "#90222" | |
| 2251 | msgid "Fanart.tv Configuration" | |
| 2252 | msgstr "Configuración de Fanart.tv" | |
| 2253 | ||
| 2254 | msgctxt "#90223" | |
| 2255 | msgid "Client Key" | |
| 2256 | msgstr "Clave de Cliente" | |
| 2257 | ||
| 2258 | msgctxt "#90224" | |
| 2259 | msgid "Your personal Fanart.tv client key for higher quality assets." | |
| 2260 | msgstr "Introduce tu Clave de Cliente personal de Fanart.tv para obtener mejores resultados y mayor velocidad (opcional)." | |
| 2261 | ||
| 2262 | msgctxt "#90225" | |
| 2263 | msgid "Debrid Notifications" | |
| 2264 | msgstr "Notificaciones de Debrid" | |
| 2265 | ||
| 2266 | msgctxt "#90226" | |
| 2267 | msgid "Debrid Expiration Notification" | |
| 2268 | msgstr "Notificación de Expiración de Debrid" | |
| 2269 | ||
| 2270 | msgctxt "#90227" | |
| 2271 | msgid "Enable or disable notifications when your Debrid subscription is about to expire." | |
| 2272 | msgstr "Activa o desactiva las notificaciones cuando tu suscripción a Debrid esté a punto de expirar." | |
| 2273 | ||
| 2274 | msgctxt "#90228" | |
| 2275 | msgid "Expiration Threshold (Days)" | |
| 2276 | msgstr "Umbral de Expiración (Días)" | |
| 2277 | ||
| 2278 | msgctxt "#90229" | |
| 2279 | msgid "How many days before expiration to start notifying." | |
| 2280 | msgstr "Cuántos días antes de la expiración se debe empezar a notificar." | |
| 2281 | ||
| 2282 | msgctxt "#90230" | |
| 2283 | msgid "%s expires in %d days." | |
| 2284 | msgstr "%s caduca en %d días." | |
| 2285 | ||
| 2286 | msgctxt "#90231" | |
| 2287 | msgid "%s expires today!" | |
| 2288 | msgstr "¡%s caduca hoy!" | |
| 2289 | ||
| 2290 | msgctxt "#90232" | |
| 2291 | msgid "Easynews" | |
| 2292 | msgstr "Easynews" | |
| 2293 | ||
| 2294 | msgctxt "#90233" | |
| 2295 | msgid "Enable EasyNews as a direct stream provider." | |
| 2296 | msgstr "Habilitar EasyNews como proveedor de streaming directo." | |
| 2297 | ||
| 2298 | msgctxt "#90234" | |
| 2299 | msgid "Your EasyNews username." | |
| 2300 | msgstr "Tu nombre de usuario de EasyNews." | |
| 2301 | ||
| 2302 | msgctxt "#90235" | |
| 2303 | msgid "Your EasyNews password." | |
| 2304 | msgstr "Tu contraseña de EasyNews." | |
| 2305 | ||
| 2306 | msgctxt "#90236" | |
| 2307 | msgid "Request timeout in seconds." | |
| 2308 | msgstr "Tiempo de espera de la solicitud en segundos." | |
| 2309 | ||
| 2310 | msgctxt "#90237" | |
| 2311 | msgid "Account Information" | |
| 2312 | msgstr "Información de la Cuenta" | |
| 2313 | ||
| 2314 | msgctxt "#90238" | |
| 2315 | msgid "Search Dialog Style" | |
| 2316 | msgstr "Estilo de Diálogo de Búsqueda" | |
| 2317 | ||
| 2318 | msgctxt "#90239" | |
| 2319 | msgid "Background" | |
| 2320 | msgstr "Segundo Plano" | |
| 2321 | ||
| 2322 | msgctxt "#90240" | |
| 2323 | msgid "Detailed Status" | |
| 2324 | msgstr "Estado Detallado" | |
| 2325 | ||
| 2326 | msgctxt "#90241" | |
| 2327 | msgid "Search Status" | |
| 2328 | msgstr "Estado de Búsqueda" | |
| 2329 | ||
| 2330 | msgctxt "#90242" | |
| 2331 | msgid "Cancel" | |
| 2332 | msgstr "Cancelar" | |
| 2333 | ||
| 2334 | msgctxt "#90243" | |
| 2335 | msgid "Results found: " | |
| 2336 | msgstr "Resultados encontrados: " | |
| 2337 | ||
| 2338 | msgctxt "#90244" | |
| 2339 | msgid "Pending" | |
| 2340 | msgstr "Pendiente" | |
| 2341 | ||
| 2342 | msgctxt "#90245" | |
| 2343 | msgid "In Progress" | |
| 2344 | msgstr "En progreso" | |
| 2345 | ||
| 2346 | msgctxt "#90246" | |
| 2347 | msgid "Completed" | |
| 2348 | msgstr "Completado" | |
| 2349 | ||
| 2350 | msgctxt "#90247" | |
| 2351 | msgid "Failed" | |
| 2352 | msgstr "Fallido" | |
| 2353 | ||
| 2354 | msgctxt "#90248" | |
| 2355 | msgid "Cancelled" | |
| 2356 | msgstr "Cancelado" | |
| 2357 | ||
| 2358 | msgctxt "#90249" | |
| 2359 | msgid "Your personal Jackgram API token for authentication." | |
| 2360 | msgstr "Tu token de API personal de Jackgram para autenticación." | |
| 2361 | ||
| 2362 | msgctxt "#90250" | |
| 2363 | msgid "Subtitles Found" | |
| 2364 | msgstr "Subtitulos encontrados" | |
| 2365 | ||
| 2366 | msgctxt "#90251" | |
| 2367 | msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?" | |
| 2368 | msgstr "Ya hay subtitulos descargados para este video.\nQuieres usar los subtitulos existentes?" | |
| 2369 | ||
| 2370 | msgctxt "#90252" | |
| 2371 | msgid "No subtitles found" | |
| 2372 | msgstr "No se encontraron subtitulos" | |
| 2373 | ||
| 2374 | msgctxt "#90253" | |
| 2375 | msgid "No subtitles selected" | |
| 2376 | msgstr "No se seleccionaron subtitulos" | |
| 2377 | ||
| 2378 | msgctxt "#90254" | |
| 2379 | msgid "Translate Subtitles" | |
| 2380 | msgstr "Traducir subtitulos" | |
| 2381 | ||
| 2382 | msgctxt "#90255" | |
| 2383 | msgid "Do you want to also translate the selected subtitles?" | |
| 2384 | msgstr "Quieres traducir tambien los subtitulos seleccionados?" | |
| 2385 | ||
| 2386 | msgctxt "#90256" | |
| 2387 | msgid "Select Subtitle to Download" | |
| 2388 | msgstr "Selecciona subtitulos para descargar" | |
| 2389 | ||
| 2390 | msgctxt "#90257" | |
| 2391 | msgid "Subtitles Loaded" | |
| 2392 | msgstr "Subtitulos cargados" | |
| 2393 | ||
| 2394 | msgctxt "#90258" | |
| 2395 | msgid "Backup" | |
| 2396 | msgstr "Copia de seguridad" | |
| 2397 | ||
| 2398 | msgctxt "#90259" | |
| 2399 | msgid "Export Backup" | |
| 2400 | msgstr "Exportar copia de seguridad" | |
| 2401 | ||
| 2402 | msgctxt "#90260" | |
| 2403 | msgid "Export addon settings and custom Stremio addons to a JSON backup file." | |
| 2404 | msgstr "Exporta la configuracion del addon y los addons personalizados de Stremio a un archivo JSON de copia de seguridad." | |
| 2405 | ||
| 2406 | msgctxt "#90261" | |
| 2407 | msgid "Restore Backup" | |
| 2408 | msgstr "Restaurar copia de seguridad" | |
| 2409 | ||
| 2410 | msgctxt "#90262" | |
| 2411 | msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file." | |
| 2412 | msgstr "Restaura la configuracion del addon y reemplaza los addons personalizados de Stremio desde un archivo JSON de copia de seguridad." | |
| 2413 | ||
| 2414 | msgctxt "#90263" | |
| 2415 | msgid "Remove Sensitive Data" | |
| 2416 | msgstr "Eliminar datos sensibles" | |
| 2417 | ||
| 2418 | msgctxt "#90264" | |
| 2419 | msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups." | |
| 2420 | msgstr "Elimina claves API, contrasenas, nombres de usuario, correos, estado de autenticacion y URLs de addons personalizados de Stremio de las copias exportadas." | |
| 2421 | ||
| 2422 | msgctxt "#90265" | |
| 2423 | msgid "Settings backup exported" | |
| 2424 | msgstr "Copia de seguridad exportada" | |
| 2425 | ||
| 2426 | msgctxt "#90266" | |
| 2427 | msgid "Settings backup restored" | |
| 2428 | msgstr "Copia de seguridad restaurada" | |
| 2429 | ||
| 2430 | msgctxt "#90267" | |
| 2431 | msgid "Settings Backup Failed" | |
| 2432 | msgstr "Error en la copia de seguridad" | |
| 2433 | ||
| 2434 | msgctxt "#90268" | |
| 2435 | msgid "Select export folder" | |
| 2436 | msgstr "Selecciona la carpeta de exportacion" | |
| 2437 | ||
| 2438 | msgctxt "#90269" | |
| 2439 | msgid "Select backup file" | |
| 2440 | msgstr "Selecciona el archivo de copia" | |
| 2441 | ||
| 2442 | msgctxt "#90270" | |
| 2443 | msgid "Backup file is invalid." | |
| 2444 | msgstr "El archivo de copia no es valido." | |
| 2445 | ||
| 2446 | msgctxt "#90271" | |
| 2447 | msgid "No export folder selected." | |
| 2448 | msgstr "No se selecciono ninguna carpeta de exportacion." | |
| 2449 | ||
| 2450 | msgctxt "#90272" | |
| 2451 | msgid "No backup file selected." | |
| 2452 | msgstr "No se selecciono ningun archivo de copia." | |
| 2453 | ||
| 2454 | msgctxt "#90273" | |
| 2455 | msgid "Unable to export settings backup." | |
| 2456 | msgstr "No se pudo exportar la copia de seguridad." | |
| 2457 | ||
| 2458 | msgctxt "#90274" | |
| 2459 | msgid "Unable to restore settings backup." | |
| 2460 | msgstr "No se pudo restaurar la copia de seguridad." | |
| 2461 | ||
| 2462 | msgctxt "#90275" | |
| 2463 | msgid "Restore Backup" | |
| 2464 | msgstr "Restaurar copia de seguridad" | |
| 2465 | ||
| 2466 | msgctxt "#90276" | |
| 2467 | msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?" | |
| 2468 | msgstr "Esto sobrescribira la configuracion actual del addon y reemplazara tus addons personalizados de Stremio con la copia. Quieres continuar?" | |
| 2469 | ||
| 2470 | msgctxt "#90277" | |
| 2471 | msgid "Restore cancelled" | |
| 2472 | msgstr "Restauracion cancelada" | |
| 2473 | ||
| 2474 | msgctxt "#90278" | |
| 2475 | msgid "Reset All Settings" | |
| 2476 | msgstr "Restablecer toda la configuracion" | |
| 2477 | ||
| 2478 | msgctxt "#90279" | |
| 2479 | msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?" | |
| 2480 | msgstr "Esto restablecera toda la configuracion del addon a sus valores predeterminados y eliminara tus addons personalizados de Stremio. Quieres continuar?" | |
| 2481 | ||
| 2482 | msgctxt "#90280" | |
| 2483 | msgid "Reset cancelled" | |
| 2484 | msgstr "Restablecimiento cancelado" | |
| 2485 | ||
| 2486 | msgctxt "#90281" | |
| 2487 | msgid "All addon settings reset" | |
| 2488 | msgstr "Toda la configuracion del addon fue restablecida" | |
| 2489 | ||
| 2490 | msgctxt "#90282" | |
| 2491 | msgid "Reset Failed" | |
| 2492 | msgstr "Error al restablecer" | |
| 2493 | ||
| 2494 | msgctxt "#90283" | |
| 2495 | msgid "Unable to reset addon settings." | |
| 2496 | msgstr "No se pudo restablecer la configuracion del addon." | |
| 2497 | ||
| 2498 | msgctxt "#90284" | |
| 2499 | msgid "Reset every addon setting back to its default value and clear custom Stremio addons." | |
| 2500 | msgstr "Restablece cada ajuste del addon a su valor predeterminado y borra los addons personalizados de Stremio." | |
| 2501 | ||
| 2502 | msgctxt "#90285" | |
| 2503 | msgid "Factory Reset" | |
| 2504 | msgstr "Restablecimiento de fabrica" | |
| 2505 | ||
| 2506 | msgctxt "#90286" | |
| 2507 | msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?" | |
| 2508 | msgstr "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 | ||
| 2510 | msgctxt "#90287" | |
| 2511 | msgid "Factory reset cancelled" | |
| 2512 | msgstr "Restablecimiento de fabrica cancelado" | |
| 2513 | ||
| 2514 | msgctxt "#90288" | |
| 2515 | msgid "Factory reset completed" | |
| 2516 | msgstr "Restablecimiento de fabrica completado" | |
| 2517 | ||
| 2518 | msgctxt "#90289" | |
| 2519 | msgid "Factory Reset Failed" | |
| 2520 | msgstr "Error en el restablecimiento de fabrica" | |
| 2521 | ||
| 2522 | msgctxt "#90290" | |
| 2523 | msgid "Unable to complete factory reset." | |
| 2524 | msgstr "No se pudo completar el restablecimiento de fabrica." | |
| 2525 | ||
| 2526 | msgctxt "#90291" | |
| 2527 | msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data." | |
| 2528 | msgstr "Restablece la configuracion a los valores predeterminados, limpia las cache y borra el historial y los datos locales del addon." | |
| 2529 | ||
| 2530 | msgctxt "#90292" | |
| 2531 | msgid "Trakt Library" | |
| 2532 | msgstr "Biblioteca de Trakt" | |
| 2533 | ||
| 2534 | msgctxt "#90293" | |
| 2535 | msgid "Trakt Discovery" | |
| 2536 | msgstr "Descubrimiento de Trakt" | |
| 2537 | ||
| 2538 | msgctxt "#90294" | |
| 2539 | msgid "Collection" | |
| 2540 | msgstr "Coleccion" | |
| 2541 | ||
| 2542 | msgctxt "#90295" | |
| 2543 | msgid "Favorites" | |
| 2544 | msgstr "Favoritos" | |
| 2545 | ||
| 2546 | msgctxt "#90296" | |
| 2547 | msgid "Up Next" | |
| 2548 | msgstr "A continuacion" | |
| 2549 | ||
| 2550 | msgctxt "#90297" | |
| 2551 | msgid "My Lists" | |
| 2552 | msgstr "Mis listas" | |
| 2553 | ||
| 2554 | msgctxt "#90298" | |
| 2555 | msgid "Liked Lists" | |
| 2556 | msgstr "Listas con Me gusta" | |
| 2557 | ||
| 2558 | msgctxt "#90331" | |
| 2559 | msgid "Stremio Addons" | |
| 2560 | msgstr "Complementos de Stremio" | |
| 2561 | ||
| 2562 | msgctxt "#90332" | |
| 2563 | msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first." | |
| 2564 | msgstr "Todavia no hay complementos disponibles. Usa Iniciar sesion en Stremio o Agregar Complemento Personalizado primero." | |
| 2565 | ||
| 2566 | msgctxt "#90333" | |
| 2567 | msgid "Stremio Stream Addons" | |
| 2568 | msgstr "Complementos de streams de Stremio" | |
| 2569 | ||
| 2570 | msgctxt "#90334" | |
| 2571 | msgid "Stremio Catalog Addons" | |
| 2572 | msgstr "Complementos de catalogo de Stremio" | |
| 2573 | ||
| 2574 | msgctxt "#90335" | |
| 2575 | msgid "Stremio Live TV Addons" | |
| 2576 | msgstr "Complementos de TV en vivo de Stremio" | |
| 2577 | ||
| 2578 | msgctxt "#90336" | |
| 2579 | msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged." | |
| 2580 | msgstr "Actualiza los complementos importados desde tu cuenta de Stremio. Los complementos personalizados no se modifican." | |
| 2581 | ||
| 2582 | msgctxt "#90337" | |
| 2583 | msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first." | |
| 2584 | msgstr "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 | ||
| 2586 | msgctxt "#90338" | |
| 2587 | msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first." | |
| 2588 | msgstr "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 | ||
| 2590 | msgctxt "#90339" | |
| 2591 | msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first." | |
| 2592 | msgstr "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 | ||
| 2594 | msgctxt "#90340" | |
| 2595 | msgid "Remove custom Stremio addons you previously added manually." | |
| 2596 | msgstr "Elimina los complementos personalizados de Stremio que agregaste manualmente." | |
| 2597 | ||
| 2598 | msgctxt "#90341" | |
| 2599 | msgid "Choose Torrent Client" | |
| 2600 | msgstr "Cliente Torrent" | |
| 2601 | ||
| 2602 | msgctxt "#90342" | |
| 2603 | msgid "Clear Database Cache" | |
| 2604 | msgstr "Limpiar cache de la base de datos" | |
| 2605 | ||
| 2606 | msgctxt "#90343" | |
| 2607 | msgid "Clear Stremio Cache" | |
| 2608 | msgstr "Limpiar cache de Stremio" | |
| 2609 | ||
| 2610 | msgctxt "#90344" | |
| 2611 | msgid "Clear Debrid Cache" | |
| 2612 | msgstr "Limpiar cache de Debrid" | |
| 2613 | ||
| 2614 | msgctxt "#90345" | |
| 2615 | msgid "Clear MDBList Cache" | |
| 2616 | msgstr "Limpiar cache de MDBList" | |
| 2617 | ||
| 2618 | msgctxt "#90346" | |
| 2619 | msgid "Restore backup from" | |
| 2620 | msgstr "Restaurar copia desde" | |
| 2621 | ||
| 2622 | msgctxt "#90347" | |
| 2623 | msgid "File" | |
| 2624 | msgstr "Archivo" | |
| 2625 | ||
| 2626 | msgctxt "#90348" | |
| 2627 | msgid "URL" | |
| 2628 | msgstr "URL" | |
| 2629 | ||
| 2630 | msgctxt "#90349" | |
| 2631 | msgid "Enter backup URL" | |
| 2632 | msgstr "Introduce la URL de la copia" | |
| 2633 | ||
| 2634 | msgctxt "#90350" | |
| 2635 | msgid "No backup URL provided." | |
| 2636 | msgstr "No se proporciono ninguna URL de copia." | |
| 2637 | ||
| 2638 | msgctxt "#90351" | |
| 2639 | msgid "Unable to download settings backup." | |
| 2640 | msgstr "No se pudo descargar la copia de seguridad." | |
| 2641 | ||
| 2642 | msgctxt "#90352" | |
| 2643 | msgid "Source Select Style" | |
| 2644 | msgstr "Estilo de selección de fuente" | |
| 2645 | ||
| 2646 | msgctxt "#90353" | |
| 2647 | msgid "Modern" | |
| 2648 | msgstr "Moderno" | |
| 2649 | ||
| 2650 | msgctxt "#90354" | |
| 2651 | msgid "OLED" | |
| 2652 | msgstr "OLED" | |
| 2653 | ||
| 2654 | msgctxt "#90355" | |
| 2655 | msgid "Cyberpunk" | |
| 2656 | msgstr "Cyberpunk" | |
| 2657 | ||
| 2658 | msgctxt "#90356" | |
| 2659 | msgid "Forest" | |
| 2660 | msgstr "Bosque" | |
| 2661 | ||
| 2662 | msgctxt "#90357" | |
| 2663 | msgid "Gold" | |
| 2664 | msgstr "Oro" | |
| 2665 | ||
| 2666 | msgctxt "#90358" | |
| 2667 | msgid "No playable results found" | |
| 2668 | msgstr "No se encontraron resultados reproducibles" | |
| 2669 | ||
| 2670 | msgctxt "#90359" | |
| 2671 | msgid "Add to TorrServer" | |
| 2672 | msgstr "Agregar a TorrServer" | |
| 2673 | ||
| 2674 | msgctxt "#90360" | |
| 2675 | msgid "Added to TorrServer" | |
| 2676 | msgstr "Agregado a TorrServer" | |
| 2677 | ||
| 2678 | msgctxt "#90361" | |
| 2679 | msgid "Could not resolve torrent details" | |
| 2680 | msgstr "No se pudieron resolver los detalles del torrent" | |
| 2681 | ||
| 2682 | msgctxt "#90362" | |
| 2683 | msgid "Added to %s cloud" | |
| 2684 | msgstr "Agregado a la nube de %s" | |
| 2685 | ||
| 2686 | msgctxt "#90363" | |
| 2687 | msgid "Choose Debrid Service" | |
| 2688 | msgstr "Elegir servicio Debrid" | |
| 2689 | ||
| 2690 | msgctxt "#90364" | |
| 2691 | msgid "No supported debrid service is enabled" | |
| 2692 | msgstr "No hay ningun servicio Debrid compatible habilitado" | |
| 2693 | ||
| 2694 | msgctxt "#90365" | |
| 2695 | msgid "Download to Debrid" | |
| 2696 | msgstr "Descargar a Debrid" | |
| 2697 | ||
| 2698 | msgctxt "#90366" | |
| 2699 | msgid "Include Unknown quality" | |
| 2700 | msgstr "Incluir calidad desconocida" | |
| 2701 | ||
| 2702 | msgctxt "#90367" | |
| 2703 | msgid "Includes releases where video quality could not be identified" | |
| 2704 | msgstr "Incluye releases donde no se pudo identificar la calidad de video" | |
| 2705 | ||
| 2706 | msgctxt "#90368" | |
| 2707 | msgid "Search by Keyword" | |
| 2708 | msgstr "Buscar por palabra clave" | |
| 2709 | ||
| 2710 | msgctxt "#90369" | |
| 2711 | msgid "Latest Movies" | |
| 2712 | msgstr "Últimas películas" | |
| 2713 | ||
| 2714 | msgctxt "#90370" | |
| 2715 | msgid "Latest Series" | |
| 2716 | msgstr "Últimas series" | |
| 2717 | ||
| 2718 | msgctxt "#90371" | |
| 2719 | msgid "Raw Files" | |
| 2720 | msgstr "Archivos originales" | |
| 2721 | ||
| 2722 | msgctxt "#90372" | |
| 2723 | msgid "MDblist" | |
| 2724 | msgstr "MDblist" | |
| 2725 | ||
| 2726 | msgctxt "#90373" | |
| 2727 | msgid "Active Downloads" | |
| 2728 | msgstr "Descargas activas" | |
| 2729 | ||
| 2730 | msgctxt "#90374" | |
| 2731 | msgid "Cancel Download" | |
| 2732 | msgstr "Cancelar descarga" | |
| 2733 | ||
| 2734 | msgctxt "#90375" | |
| 2735 | msgid "Delete File" | |
| 2736 | msgstr "Eliminar archivo" | |
| 2737 | ||
| 2738 | msgctxt "#90376" | |
| 2739 | msgid "Search Collections" | |
| 2740 | msgstr "Buscar colecciones" | |
| 2741 | ||
| 2742 | msgctxt "#90377" | |
| 2743 | msgid "Popular Collections" | |
| 2744 | msgstr "Colecciones populares" | |
| 2745 | ||
| 2746 | msgctxt "#90378" | |
| 2747 | msgid "Top Rated Collections" | |
| 2748 | msgstr "Colecciones mejor valoradas" | |
| 2749 | ||
| 2750 | msgctxt "#90379" | |
| 2751 | msgid "Recommendations" | |
| 2752 | msgstr "Recomendaciones" | |
| 2753 | ||
| 2754 | msgctxt "#90380" | |
| 2755 | msgid "Similar" | |
| 2756 | msgstr "Similares" | |
| 2757 | ||
| 2758 | msgctxt "#90381" | |
| 2759 | msgid "Keyword: %s" | |
| 2760 | msgstr "Palabra clave: %s" | |
| 2761 | ||
| 2762 | msgctxt "#90410" | |
| 2763 | msgid "Error modifying Trakt Watchlist" | |
| 2764 | msgstr "Error al modificar la lista de seguimiento de Trakt" | |
| 2765 | ||
| 2766 | msgctxt "#90411" | |
| 2767 | msgid "Marked as Watched on Trakt" | |
| 2768 | msgstr "Marcado como visto en Trakt" | |
| 2769 | ||
| 2770 | msgctxt "#90412" | |
| 2771 | msgid "Error modifying Trakt Watched status" | |
| 2772 | msgstr "Error al modificar el estado de visto en Trakt" | |
| 2773 | ||
| 2774 | msgctxt "#90413" | |
| 2775 | msgid "Play" | |
| 2776 | msgstr "Reproducir" | |
| 2777 | ||
| 2778 | msgctxt "#90414" | |
| 2779 | msgid "Trailer" | |
| 2780 | msgstr "Tráiler" | |
| 2781 | ||
| 2782 | msgctxt "#90415" | |
| 2783 | msgid "Watched" | |
| 2784 | msgstr "Visto" | |
| 2785 | ||
| 2786 | msgctxt "#90416" | |
| 2787 | msgid "Refresh" | |
| 2788 | msgstr "Refrescar" | |
| 2789 | ||
| 2790 | msgctxt "#90418" | |
| 2791 | msgid "No credits found for this ID" | |
| 2792 | msgstr "No se encontraron créditos para este ID" | |
| 2793 | ||
| 2794 | msgctxt "#90419" | |
| 2795 | msgid "Credited People" | |
| 2796 | msgstr "Personas Acreditadas" | |
| 2797 | ||
| 2798 | msgctxt "#90420" | |
| 2799 | msgid "Person Details" | |
| 2800 | msgstr "Detalles de la Persona" | |
| 2801 | ||
| 2802 | msgctxt "#90421" | |
| 2803 | msgid "Person not found" | |
| 2804 | msgstr "Persona no encontrada" | |
| 2805 | ||
| 2806 | msgctxt "#90422" | |
| 2807 | msgid "Jackgram not activated" | |
| 2808 | msgstr "Jackgram no activado." | |
| 2809 | ||
| 2810 | msgctxt "#90423" | |
| 2811 | msgid "Not yet implemented" | |
| 2812 | msgstr "Aún no implementado" | |
| 2813 | ||
| 2814 | msgctxt "#90424" | |
| 2815 | msgid "Unsupported debrid type" | |
| 2816 | msgstr "Tipo de debrid no compatible" | |
| 2817 | ||
| 2818 | msgctxt "#90425" | |
| 2819 | msgid "Easynews credentials required" | |
| 2820 | msgstr "Se requieren credenciales de Easynews" | |
| 2821 | ||
| 2822 | msgctxt "#90426" | |
| 2823 | msgid "No Corrupt or Missing Databases" | |
| 2824 | msgstr "No hay bases de datos corruptas o faltantes" | |
| 2825 | ||
| 2826 | msgctxt "#90427" | |
| 2827 | msgid "Otaku (script.otaku.mappings) not found" | |
| 2828 | msgstr "No se encontró Otaku (script.otaku.mappings)" | |
| 2829 | ||
| 2830 | msgctxt "#90431" | |
| 2831 | msgid "No items found in this list" | |
| 2832 | msgstr "No se encontraron elementos en esta lista" | |
| 2833 | ||
| 2834 | msgctxt "#90432" | |
| 2835 | msgid "Databases Rebuilt" | |
| 2836 | msgstr "Bases de datos reconstruidas" | |
| 2837 | ||
| 2838 | msgctxt "#90433" | |
| 2839 | msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s" | |
| 2840 | msgstr "[B]Se reconstruyeron las siguientes bases de datos:[/B][CR][CR]%s" | |
| 2841 | ||
| 2842 | msgctxt "#90434" | |
| 2843 | msgid "Added to Trakt watchlist" | |
| 2844 | msgstr "Añadido a la lista de seguimiento de Trakt" | |
| 2845 | ||
| 2846 | msgctxt "#90435" | |
| 2847 | msgid "Failed to add to Trakt watchlist" | |
| 2848 | msgstr "Error al añadir a la lista de seguimiento de Trakt" | |
| 2849 | ||
| 2850 | msgctxt "#90436" | |
| 2851 | msgid "Removed from Trakt watchlist" | |
| 2852 | msgstr "Eliminado de la lista de seguimiento de Trakt" | |
| 2853 | ||
| 2854 | msgctxt "#90437" | |
| 2855 | msgid "Failed to remove from Trakt watchlist" | |
| 2856 | msgstr "Error al eliminar de la lista de seguimiento de Trakt" | |
| 2857 | ||
| 2858 | msgctxt "#90438" | |
| 2859 | msgid "Failed to mark as watched on Trakt" | |
| 2860 | msgstr "Error al marcar como visto en Trakt" | |
| 2861 | ||
| 2862 | msgctxt "#90439" | |
| 2863 | msgid "Marked as unwatched on Trakt" | |
| 2864 | msgstr "Marcado como no visto en Trakt" | |
| 2865 | ||
| 2866 | msgctxt "#90440" | |
| 2867 | msgid "Failed to mark as unwatched on Trakt" | |
| 2868 | msgstr "Error al marcar como no visto en Trakt" | |
| 2869 | ||
| 2870 | msgctxt "#90441" | |
| 2871 | msgid "Added to Trakt collection" | |
| 2872 | msgstr "Añadido a la colección de Trakt" | |
| 2873 | ||
| 2874 | msgctxt "#90442" | |
| 2875 | msgid "Removed from Trakt collection" | |
| 2876 | msgstr "Eliminado de la colección de Trakt" | |
| 2877 | ||
| 2878 | msgctxt "#90443" | |
| 2879 | msgid "Failed to add to Trakt collection" | |
| 2880 | msgstr "Error al añadir a la colección de Trakt" | |
| 2881 | ||
| 2882 | msgctxt "#90444" | |
| 2883 | msgid "Failed to remove from Trakt collection" | |
| 2884 | msgstr "Error al eliminar de la colección de Trakt" | |
| 2885 | ||
| 2886 | msgctxt "#90445" | |
| 2887 | msgid "Added to Trakt favorites" | |
| 2888 | msgstr "Añadido a favoritos de Trakt" | |
| 2889 | ||
| 2890 | msgctxt "#90446" | |
| 2891 | msgid "Removed from Trakt favorites" | |
| 2892 | msgstr "Eliminado de favoritos de Trakt" | |
| 2893 | ||
| 2894 | msgctxt "#90447" | |
| 2895 | msgid "Failed to add to Trakt favorites" | |
| 2896 | msgstr "Error al añadir a favoritos de Trakt" | |
| 2897 | ||
| 2898 | msgctxt "#90448" | |
| 2899 | msgid "Failed to remove from Trakt favorites" | |
| 2900 | msgstr "Error al eliminar de favoritos de Trakt" | |
| 2901 | ||
| 2902 | msgctxt "#90449" | |
| 2903 | msgid "Created Trakt list" | |
| 2904 | msgstr "Lista de Trakt creada" | |
| 2905 | ||
| 2906 | msgctxt "#90450" | |
| 2907 | msgid "Failed to create Trakt list" | |
| 2908 | msgstr "Error al crear la lista de Trakt" | |
| 2909 | ||
| 2910 | msgctxt "#90451" | |
| 2911 | msgid "Deleted Trakt list" | |
| 2912 | msgstr "Lista de Trakt eliminada" | |
| 2913 | ||
| 2914 | msgctxt "#90452" | |
| 2915 | msgid "Failed to delete Trakt list" | |
| 2916 | msgstr "Error al eliminar la lista de Trakt" | |
| 2917 | ||
| 2918 | msgctxt "#90453" | |
| 2919 | msgid "Liked Trakt list" | |
| 2920 | msgstr "Te gusta la lista de Trakt" | |
| 2921 | ||
| 2922 | msgctxt "#90454" | |
| 2923 | msgid "Failed to like Trakt list" | |
| 2924 | msgstr "Error al marcar como me gusta la lista de Trakt" | |
| 2925 | ||
| 2926 | msgctxt "#90455" | |
| 2927 | msgid "Unliked Trakt list" | |
| 2928 | msgstr "Ya no te gusta la lista de Trakt" | |
| 2929 | ||
| 2930 | msgctxt "#90456" | |
| 2931 | msgid "Failed to unlike Trakt list" | |
| 2932 | msgstr "Error al quitar me gusta de la lista de Trakt" | |
| 2933 | ||
| 2934 | msgctxt "#90457" | |
| 2935 | msgid "Added to Trakt list" | |
| 2936 | msgstr "Añadido a la lista de Trakt" | |
| 2937 | ||
| 2938 | msgctxt "#90459" | |
| 2939 | msgid "Failed to add to Trakt list" | |
| 2940 | msgstr "Error al añadir a la lista de Trakt" | |
| 2941 | ||
| 2942 | msgctxt "#90461" | |
| 2943 | msgid "No Trakt lists found" | |
| 2944 | msgstr "No se encontraron listas de Trakt" | |
| 2945 | ||
| 2946 | msgctxt "#90462" | |
| 2947 | msgid "Item not found in Trakt collection" | |
| 2948 | msgstr "Elemento no encontrado en la colección de Trakt" | |
| 2949 | ||
| 2950 | msgctxt "#90463" | |
| 2951 | msgid "Trakt could not match this item for collection" | |
| 2952 | msgstr "Trakt no pudo asociar este elemento a la colección" | |
| 2953 | ||
| 2954 | msgctxt "#90464" | |
| 2955 | msgid "No Trakt collection changes were applied" | |
| 2956 | msgstr "No se aplicaron cambios en la colección de Trakt" | |
| 2957 | ||
| 2958 | msgctxt "#90465" | |
| 2959 | msgid "Could not build Trakt collection payload for show" | |
| 2960 | msgstr "No se pudo construir la carga de colección de Trakt para la serie" | |
| 2961 | ||
| 2962 | msgctxt "#90466" | |
| 2963 | msgid "Delete this Trakt list?" | |
| 2964 | msgstr "¿Eliminar esta lista de Trakt?" | |
| 2965 | ||
| 2966 | ||
| 2967 | msgctxt "#90510" | |
| 2968 | msgid "No meta available" | |
| 2969 | msgstr "Sin metadatos" | |
| 2970 | ||
| 2971 | msgctxt "#90511" | |
| 2972 | msgid "No content available for" | |
| 2973 | msgstr "Sin contenido para" | |
| 2974 | ||
| 2975 | msgctxt "#90512" | |
| 2976 | msgid "Season" | |
| 2977 | msgstr "Temporada" | |
| 2978 | ||
| 2979 | msgctxt "#90513" | |
| 2980 | msgid "No episodes available" | |
| 2981 | msgstr "Sin episodios" | |
| 2982 | ||
| 2983 | msgctxt "#90514" | |
| 2984 | msgid "No videos available" | |
| 2985 | msgstr "Sin videos" | |
| 2986 | ||
| 2987 | msgctxt "#90515" | |
| 2988 | msgid "Next Page" | |
| 2989 | msgstr "Página siguiente" | |
| 2990 | ||
| 2991 | msgctxt "#90516" | |
| 2992 | msgid "No metas available" | |
| 2993 | msgstr "No hay metadatos disponibles" | |
| 2994 | ||
| 2995 | msgctxt "#90517" | |
| 2996 | msgid "No seasons available" | |
| 2997 | msgstr "No hay temporadas disponibles" | |
| 2998 | ||
| 2999 | msgctxt "#90518" | |
| 3000 | msgid "Invalid query" | |
| 3001 | msgstr "Consulta no válida" | |
| 3002 | ||
| 3003 | msgctxt "#90519" | |
| 3004 | msgid "No lists found" | |
| 3005 | msgstr "No se encontraron listas" | |
| 3006 | ||
| 3007 | msgctxt "#90520" | |
| 3008 | msgid "No user lists found" | |
| 3009 | msgstr "No se encontraron listas de usuario" | |
| 3010 | ||
| 3011 | msgctxt "#90521" | |
| 3012 | msgid "No top lists found" | |
| 3013 | msgstr "No se encontraron listas destacadas" | |
| 3014 | ||
| 3015 | msgctxt "#90522" | |
| 3016 | msgid "Custom Addon" | |
| 3017 | msgstr "Addon personalizado" | |
| 3018 | ||
| 3019 | msgctxt "#90523" | |
| 3020 | msgid "Enter the custom Stremio addon URL" | |
| 3021 | msgstr "Introduce la URL del addon personalizado de Stremio" | |
| 3022 | ||
| 3023 | msgctxt "#90524" | |
| 3024 | msgid "No URL provided." | |
| 3025 | msgstr "No se proporcionó ninguna URL." | |
| 3026 | ||
| 3027 | msgctxt "#90525" | |
| 3028 | msgid "Failed to fetch manifest: %s" | |
| 3029 | msgstr "Error al obtener el manifiesto: %s" | |
| 3030 | ||
| 3031 | msgctxt "#90526" | |
| 3032 | msgid "Manifest missing 'id' or 'name'." | |
| 3033 | msgstr "Al manifiesto le falta 'id' o 'name'." | |
| 3034 | ||
| 3035 | msgctxt "#90527" | |
| 3036 | msgid "Custom Stremio addon added successfully!" | |
| 3037 | msgstr "Addon personalizado de Stremio añadido correctamente" | |
| 3038 | ||
| 3039 | msgctxt "#90528" | |
| 3040 | msgid "Addon does not provide 'stream' or 'catalog' resources." | |
| 3041 | msgstr "El addon no proporciona recursos 'stream' o 'catalog'." | |
| 3042 | ||
| 3043 | msgctxt "#90529" | |
| 3044 | msgid "This addon is already added to your list." | |
| 3045 | msgstr "Este addon ya está añadido a tu lista." | |
| 3046 | ||
| 3047 | msgctxt "#90530" | |
| 3048 | msgid "Failed to add custom addon: %s" | |
| 3049 | msgstr "Error al añadir el addon personalizado: %s" | |
| 3050 | ||
| 3051 | msgctxt "#90531" | |
| 3052 | msgid "Remove Addon" | |
| 3053 | msgstr "Eliminar addon" | |
| 3054 | ||
| 3055 | msgctxt "#90532" | |
| 3056 | msgid "No custom Stremio addons to remove." | |
| 3057 | msgstr "No hay addons personalizados de Stremio para eliminar." | |
| 3058 | ||
| 3059 | msgctxt "#90533" | |
| 3060 | msgid "Remove Stremio Addons" | |
| 3061 | msgstr "Eliminar addons de Stremio" | |
| 3062 | ||
| 3063 | msgctxt "#90534" | |
| 3064 | msgid "Selected addon(s) removed." | |
| 3065 | msgstr "Addon(s) seleccionado(s) eliminado(s)." | |
| 3066 | ||
| 3067 | msgctxt "#90535" | |
| 3068 | msgid "Untitled List" | |
| 3069 | msgstr "Lista sin titulo" | |
| 3070 | ||
| 3071 | msgctxt "#90536" | |
| 3072 | msgid "Removed from Trakt list" | |
| 3073 | msgstr "Eliminado de la lista de Trakt" | |
| 3074 | ||
| 3075 | msgctxt "#90537" | |
| 3076 | msgid "Failed to remove from Trakt list" | |
| 3077 | msgstr "Error al eliminar de la lista de Trakt" | |
| 3078 | ||
| 3079 | msgctxt "#90538" | |
| 3080 | msgid "Resume from %.1f%%" | |
| 3081 | msgstr "Reanudar desde %.1f%%" | |
| 3082 | ||
| 3083 | msgctxt "#90539" | |
| 3084 | msgid "Start from Beginning" | |
| 3085 | msgstr "Empezar desde el principio" | |
| 3086 | ||
| 3087 | msgctxt "#90540" | |
| 3088 | msgid "Authorize Debrid Services" | |
| 3089 | msgstr "Autorizar servicios Debrid" | |
| 3090 | ||
| 3091 | msgctxt "#90541" | |
| 3092 | msgid "Navigate to: [B]%s[/B]" | |
| 3093 | msgstr "Ve a: [B]%s[/B]" | |
| 3094 | ||
| 3095 | msgctxt "#90542" | |
| 3096 | msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3097 | msgstr "Introduce el siguiente codigo: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3098 | ||
| 3099 | msgctxt "#90543" | |
| 3100 | msgid "Premiumize Auth" | |
| 3101 | msgstr "Autenticacion de Premiumize" | |
| 3102 | ||
| 3103 | msgctxt "#90544" | |
| 3104 | msgid "Success" | |
| 3105 | msgstr "Exito" | |
| 3106 | ||
| 3107 | msgctxt "#90545" | |
| 3108 | msgid "Authentication completed." | |
| 3109 | msgstr "Autenticacion completada." | |
| 3110 | ||
| 3111 | msgctxt "#90546" | |
| 3112 | msgid "Error:" | |
| 3113 | msgstr "Error:" | |
| 3114 | ||
| 3115 | msgctxt "#90547" | |
| 3116 | msgid "Error: %s" | |
| 3117 | msgstr "Error: %s" | |
| 3118 | ||
| 3119 | msgctxt "#90548" | |
| 3120 | msgid "Auth Error" | |
| 3121 | msgstr "Error de autenticacion" | |
| 3122 | ||
| 3123 | msgctxt "#90549" | |
| 3124 | msgid "Torbox authentication successful." | |
| 3125 | msgstr "Autenticacion de Torbox completada correctamente." | |
| 3126 | ||
| 3127 | msgctxt "#90554" | |
| 3128 | msgid "Please wait..." | |
| 3129 | msgstr "Por favor, espera..." | |
| 3130 | ||
| 3131 | msgctxt "#90555" | |
| 3132 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3133 | msgstr "Ve a:\n[COLOR cyan]%s[/COLOR]\nIntroduce el codigo: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3134 | ||
| 3135 | msgctxt "#90556" | |
| 3136 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3137 | msgstr "Ve a:\n[COLOR cyan]%s[/COLOR]\n\nIntroduce el codigo: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3138 | ||
| 3139 | msgctxt "#90557" | |
| 3140 | msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]" | |
| 3141 | msgstr "Enlace Pastebin:\n[COLOR cyan]%s[/COLOR]" | |
| 3142 | ||
| 3143 | msgctxt "#90670" | |
| 3144 | msgid "Local URL:\n[COLOR cyan]%s[/COLOR]" | |
| 3145 | msgstr "URL local:\n[COLOR cyan]%s[/COLOR]" | |
| 3146 | ||
| 3147 | msgctxt "#90558" | |
| 3148 | msgid "QR Code Authentication" | |
| 3149 | msgstr "Autenticacion por codigo QR" | |
| 3150 | ||
| 3151 | msgctxt "#90559" | |
| 3152 | msgid "Kodi 20 or above required. Please update Kodi to use this addon." | |
| 3153 | msgstr "Se requiere Kodi 20 o superior. Actualiza Kodi para usar este addon." | |
| 3154 | ||
| 3155 | msgctxt "#90560" | |
| 3156 | msgid "Failed to start web server. Port may be in use." | |
| 3157 | msgstr "Error al iniciar el servidor web. Puede que el puerto este en uso." | |
| 3158 | ||
| 3159 | msgctxt "#90561" | |
| 3160 | msgid "Authentification Removed." | |
| 3161 | msgstr "Autenticacion eliminada." | |
| 3162 | ||
| 3163 | msgctxt "#90562" | |
| 3164 | msgid "WebDAV Error" | |
| 3165 | msgstr "Error de WebDAV" | |
| 3166 | ||
| 3167 | msgctxt "#90563" | |
| 3168 | msgid "Trakt Authorization" | |
| 3169 | msgstr "Autorizacion de Trakt" | |
| 3170 | ||
| 3171 | msgctxt "#90564" | |
| 3172 | msgid "Do you want to continue transfer in background?" | |
| 3173 | msgstr "¿Quieres continuar la transferencia en segundo plano?" | |
| 3174 | ||
| 3175 | msgctxt "#90565" | |
| 3176 | msgid "Cloud Transfer" | |
| 3177 | msgstr "Transferencia en la nube" | |
| 3178 | ||
| 3179 | msgctxt "#90566" | |
| 3180 | msgid "Kodi Logs" | |
| 3181 | msgstr "Registros de Kodi" | |
| 3182 | ||
| 3183 | msgctxt "#90567" | |
| 3184 | msgid "Show Logs" | |
| 3185 | msgstr "Mostrar registros" | |
| 3186 | ||
| 3187 | msgctxt "#90568" | |
| 3188 | msgid "Export to paste.kodi.tv" | |
| 3189 | msgstr "Exportar a paste.kodi.tv" | |
| 3190 | ||
| 3191 | msgctxt "#90569" | |
| 3192 | msgid "Kodi Logs Exported" | |
| 3193 | msgstr "Registros de Kodi exportados" | |
| 3194 | ||
| 3195 | msgctxt "#90570" | |
| 3196 | msgid "Providers Selection Dialog" | |
| 3197 | msgstr "Dialogo de seleccion de proveedores" | |
| 3198 | ||
| 3199 | msgctxt "#90571" | |
| 3200 | msgid "Your current Providers are: \n%s\n\nDo you want to change?" | |
| 3201 | msgstr "Tus proveedores actuales son: \n%s\n\n¿Quieres cambiarlos?" | |
| 3202 | ||
| 3203 | msgctxt "#90572" | |
| 3204 | msgid "Select Providers" | |
| 3205 | msgstr "Seleccionar proveedores" | |
| 3206 | ||
| 3207 | msgctxt "#90573" | |
| 3208 | msgid "Selection Dialog" | |
| 3209 | msgstr "Dialogo de seleccion" | |
| 3210 | ||
| 3211 | msgctxt "#90574" | |
| 3212 | msgid "Successfully selected: %s" | |
| 3213 | msgstr "Seleccionado correctamente: %s" | |
| 3214 | ||
| 3215 | msgctxt "#90575" | |
| 3216 | msgid "Selection" | |
| 3217 | msgstr "Seleccion" | |
| 3218 | ||
| 3219 | msgctxt "#90576" | |
| 3220 | msgid "No providers selected" | |
| 3221 | msgstr "No se seleccionaron proveedores" | |
| 3222 | ||
| 3223 | msgctxt "#90577" | |
| 3224 | msgid "Changelog" | |
| 3225 | msgstr "Registro de cambios" | |
| 3226 | ||
| 3227 | msgctxt "#90578" | |
| 3228 | msgid "[B]Unable to check for updates[/B]" | |
| 3229 | msgstr "[B]No se pudieron comprobar las actualizaciones[/B]" | |
| 3230 | ||
| 3231 | msgctxt "#90579" | |
| 3232 | msgid "[B]No update available[/B]" | |
| 3233 | msgstr "[B]No hay actualizaciones disponibles[/B]" | |
| 3234 | ||
| 3235 | msgctxt "#90580" | |
| 3236 | msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3237 | msgstr "Instalado: [B]%s[/B][CR]En linea: [B]%s[/B][CR][CR]" | |
| 3238 | ||
| 3239 | msgctxt "#90581" | |
| 3240 | msgid "[B]Update available. Do you want to update?[/B]" | |
| 3241 | msgstr "[B]Hay una actualizacion disponible. ¿Quieres actualizar?[/B]" | |
| 3242 | ||
| 3243 | msgctxt "#90582" | |
| 3244 | msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]" | |
| 3245 | msgstr "[B]Actualizacion disponible (v%s).[/B] [I]Revisa la configuracion para actualizar.[/I]" | |
| 3246 | ||
| 3247 | msgctxt "#90583" | |
| 3248 | msgid "Error fetching versions: %s" | |
| 3249 | msgstr "Error al obtener las versiones: %s" | |
| 3250 | ||
| 3251 | msgctxt "#90584" | |
| 3252 | msgid "No older/different versions found." | |
| 3253 | msgstr "No se encontraron versiones anteriores o distintas." | |
| 3254 | ||
| 3255 | msgctxt "#90585" | |
| 3256 | msgid "Select Version to Downgrade" | |
| 3257 | msgstr "Selecciona la version a la que bajar" | |
| 3258 | ||
| 3259 | msgctxt "#90586" | |
| 3260 | msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?" | |
| 3261 | msgstr "¿Seguro que quieres bajar de version o reinstalar la version [B]%s[/B]?" | |
| 3262 | ||
| 3263 | msgctxt "#90587" | |
| 3264 | msgid "[B]Downloading update...[/B]" | |
| 3265 | msgstr "[B]Descargando actualizacion...[/B]" | |
| 3266 | ||
| 3267 | msgctxt "#90588" | |
| 3268 | msgid "Error: Unable to download update." | |
| 3269 | msgstr "Error: no se puede descargar la actualizacion." | |
| 3270 | ||
| 3271 | msgctxt "#90589" | |
| 3272 | msgid "Error saving update file: %s" | |
| 3273 | msgstr "Error al guardar el archivo de actualizacion: %s" | |
| 3274 | ||
| 3275 | msgctxt "#90590" | |
| 3276 | msgid "Error extracting update. Please install manually." | |
| 3277 | msgstr "Error al extraer la actualizacion. Instalala manualmente." | |
| 3278 | ||
| 3279 | msgctxt "#90591" | |
| 3280 | msgid "Do you want to view the changelog for the new version?" | |
| 3281 | msgstr "¿Quieres ver el registro de cambios de la nueva version?" | |
| 3282 | ||
| 3283 | msgctxt "#90592" | |
| 3284 | msgid "New Release v%s - Changelog" | |
| 3285 | msgstr "Nueva version v%s - Registro de cambios" | |
| 3286 | ||
| 3287 | msgctxt "#90593" | |
| 3288 | msgid "[B]Update complete.[/B]" | |
| 3289 | msgstr "[B]Actualizacion completada.[/B]" | |
| 3290 | ||
| 3291 | msgctxt "#90594" | |
| 3292 | msgid "Real Debrid Auth" | |
| 3293 | msgstr "Autenticacion de Real Debrid" | |
| 3294 | ||
| 3295 | msgctxt "#90595" | |
| 3296 | msgid "Torbox Auth" | |
| 3297 | msgstr "Autenticacion de Torbox" | |
| 3298 | ||
| 3299 | msgctxt "#90596" | |
| 3300 | msgid "Debrider Auth" | |
| 3301 | msgstr "Autenticacion de Debrider" | |
| 3302 | ||
| 3303 | msgctxt "#90597" | |
| 3304 | msgid "Download Error" | |
| 3305 | msgstr "Error de descarga" | |
| 3306 | ||
| 3307 | msgctxt "#90598" | |
| 3308 | msgid "Failed to download text: %s" | |
| 3309 | msgstr "Error al descargar el texto: %s" | |
| 3310 | ||
| 3311 | msgctxt "#90599" | |
| 3312 | msgid "General Error" | |
| 3313 | msgstr "Error general" | |
| 3314 | ||
| 3315 | msgctxt "#90600" | |
| 3316 | msgid "Could not display text file." | |
| 3317 | msgstr "No se pudo mostrar el archivo de texto." | |
| 3318 | ||
| 3319 | msgctxt "#90601" | |
| 3320 | msgid "Failed to export logs." | |
| 3321 | msgstr "Error al exportar los registros." | |
| 3322 | ||
| 3323 | msgctxt "#90602" | |
| 3324 | msgid "Error reading log: %s" | |
| 3325 | msgstr "Error al leer el registro: %s" | |
| 3326 | ||
| 3327 | msgctxt "#90603" | |
| 3328 | msgid "Kodi log file not found." | |
| 3329 | msgstr "No se encontro el archivo de registro de Kodi." | |
| 3330 | ||
| 3331 | msgctxt "#90604" | |
| 3332 | msgid "%s Auth" | |
| 3333 | msgstr "Autenticacion de %s" | |
| 3334 | ||
| 3335 | msgctxt "#90605" | |
| 3336 | msgid "Ok" | |
| 3337 | msgstr "Aceptar" | |
| 3338 | ||
| 3339 | msgctxt "#90606" | |
| 3340 | msgid "No" | |
| 3341 | msgstr "No" | |
| 3342 | ||
| 3343 | msgctxt "#90607" | |
| 3344 | msgid "Pinging addons for availability..." | |
| 3345 | msgstr "Comprobando disponibilidad de addons..." | |
| 3346 | ||
| 3347 | msgctxt "#90608" | |
| 3348 | msgid "Pinging addons... %d/%d" | |
| 3349 | msgstr "Comprobando addons... %d/%d" | |
| 3350 | ||
| 3351 | msgctxt "#90609" | |
| 3352 | msgid "Ping Results" | |
| 3353 | msgstr "Resultados del ping" | |
| 3354 | ||
| 3355 | msgctxt "#90610" | |
| 3356 | msgid "Reachable: %d\nUnreachable: %d" | |
| 3357 | msgstr "Accesibles: %d\nNo accesibles: %d" | |
| 3358 | ||
| 3359 | msgctxt "#90611" | |
| 3360 | msgid "Select Category to Filter" | |
| 3361 | msgstr "Selecciona la categoria a filtrar" | |
| 3362 | ||
| 3363 | msgctxt "#90612" | |
| 3364 | msgid "Stremio Add-ons Import" | |
| 3365 | msgstr "Importacion de addons de Stremio" | |
| 3366 | ||
| 3367 | msgctxt "#90613" | |
| 3368 | msgid "To import your add-ons, please log in with your Stremio email and password." | |
| 3369 | msgstr "Para importar tus addons, inicia sesion con tu correo y contrasena de Stremio." | |
| 3370 | ||
| 3371 | msgctxt "#90614" | |
| 3372 | msgid "Enter your Email" | |
| 3373 | msgstr "Introduce tu correo" | |
| 3374 | ||
| 3375 | msgctxt "#90615" | |
| 3376 | msgid "Enter your Password" | |
| 3377 | msgstr "Introduce tu contrasena" | |
| 3378 | ||
| 3379 | msgctxt "#90616" | |
| 3380 | msgid "Login Failed" | |
| 3381 | msgstr "Error de inicio de sesion" | |
| 3382 | ||
| 3383 | msgctxt "#90617" | |
| 3384 | msgid "Failed to login: %s" | |
| 3385 | msgstr "Error al iniciar sesion: %s" | |
| 3386 | ||
| 3387 | msgctxt "#90618" | |
| 3388 | msgid "Add-ons Import Failed" | |
| 3389 | msgstr "Error al importar addons" | |
| 3390 | ||
| 3391 | msgctxt "#90619" | |
| 3392 | msgid "Please try again later and report the issue if the problem persists. For more details, check the log file." | |
| 3393 | msgstr "Vuelve a intentarlo mas tarde y reporta el problema si persiste. Para mas detalles, revisa el archivo de log." | |
| 3394 | ||
| 3395 | msgctxt "#90620" | |
| 3396 | msgid "Addons Imported" | |
| 3397 | msgstr "Addons importados" | |
| 3398 | ||
| 3399 | msgctxt "#90621" | |
| 3400 | msgid "Successfully imported addons from your account." | |
| 3401 | msgstr "Se han importado correctamente los addons de tu cuenta." | |
| 3402 | ||
| 3403 | msgctxt "#90622" | |
| 3404 | msgid "Update Stremio Addons" | |
| 3405 | msgstr "Actualizar addons de Stremio" | |
| 3406 | ||
| 3407 | msgctxt "#90623" | |
| 3408 | msgid "Do you want to update the Addons from your account?" | |
| 3409 | msgstr "¿Quieres actualizar los addons de tu cuenta?" | |
| 3410 | ||
| 3411 | msgctxt "#90624" | |
| 3412 | msgid "Log Out from Stremio" | |
| 3413 | msgstr "Cerrar sesion en Stremio" | |
| 3414 | ||
| 3415 | msgctxt "#90625" | |
| 3416 | msgid "Are you sure you want to log out? You can continue using Stremio without logging in, but your settings will be reset to the default configuration." | |
| 3417 | msgstr "¿Seguro que quieres cerrar sesion? Puedes seguir usando Stremio sin iniciar sesion, pero tu configuracion se restablecera a los valores por defecto." | |
| 3418 | ||
| 3419 | msgctxt "#90626" | |
| 3420 | msgid "Log Out" | |
| 3421 | msgstr "Cerrar sesion" | |
| 3422 | ||
| 3423 | msgctxt "#90627" | |
| 3424 | msgid "Use Existing" | |
| 3425 | msgstr "Usar existentes" | |
| 3426 | ||
| 3427 | msgctxt "#90628" | |
| 3428 | msgid "Download New" | |
| 3429 | msgstr "Descargar nuevos" | |
| 3430 | ||
| 3431 | msgctxt "#90629" | |
| 3432 | msgid "Playback Failed" | |
| 3433 | msgstr "Error de reproduccion" | |
| 3434 | ||
| 3435 | msgctxt "#90630" | |
| 3436 | msgid "The video could not be played.\nDo you want to retry?" | |
| 3437 | msgstr "No se pudo reproducir el video.\n¿Quieres reintentar?" | |
| 3438 | ||
| 3439 | msgctxt "#90631" | |
| 3440 | msgid "Retry" | |
| 3441 | msgstr "Reintentar" | |
| 3442 | ||
| 3443 | msgctxt "#90632" | |
| 3444 | msgid "Information" | |
| 3445 | msgstr "Informacion" | |
| 3446 | ||
| 3447 | msgctxt "#90633" | |
| 3448 | msgid "Review" | |
| 3449 | msgstr "Resena" | |
| 3450 | ||
| 3451 | msgctxt "#90634" | |
| 3452 | msgid "Comment" | |
| 3453 | msgstr "Comentario" | |
| 3454 | ||
| 3455 | msgctxt "#90635" | |
| 3456 | msgid "Trivia" | |
| 3457 | msgstr "Trivia" | |
| 3458 | ||
| 3459 | msgctxt "#90636" | |
| 3460 | msgid "Goof" | |
| 3461 | msgstr "Error" | |
| 3462 | ||
| 3463 | msgctxt "#90637" | |
| 3464 | msgid "Parental Guide" | |
| 3465 | msgstr "Guia parental" | |
| 3466 | ||
| 3467 | msgctxt "#90638" | |
| 3468 | msgid "Converting Magnet...\n\n%s" | |
| 3469 | msgstr "Convirtiendo magnet...\n\n%s" | |
| 3470 | ||
| 3471 | msgctxt "#90639" | |
| 3472 | msgid "Real Debrid Error." | |
| 3473 | msgstr "Error de Real Debrid." | |
| 3474 | ||
| 3475 | msgctxt "#90640" | |
| 3476 | msgid "File already cached" | |
| 3477 | msgstr "Archivo ya almacenado en cache" | |
| 3478 | ||
| 3479 | msgctxt "#90641" | |
| 3480 | msgid "Saving file to the Real Debrid Cloud" | |
| 3481 | msgstr "Guardando archivo en la nube de Real Debrid" | |
| 3482 | ||
| 3483 | msgctxt "#90642" | |
| 3484 | msgid "File cached" | |
| 3485 | msgstr "Archivo almacenado en cache" | |
| 3486 | ||
| 3487 | msgctxt "#90643" | |
| 3488 | msgid "Saving File to the Real Debrid Cloud...\n%s\n\n" | |
| 3489 | msgstr "Guardando archivo en la nube de Real Debrid...\n%s\n\n" | |
| 3490 | ||
| 3491 | msgctxt "#90644" | |
| 3492 | msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed" | |
| 3493 | msgstr "Descargando %s GB a %s mbps desde %s pares, %s %% completado" | |
| 3494 | ||
| 3495 | msgctxt "#90645" | |
| 3496 | msgid "Trakt" | |
| 3497 | msgstr "Trakt" | |
| 3498 | ||
| 3499 | msgctxt "#90651" | |
| 3500 | msgid "Easynews Account Info" | |
| 3501 | msgstr "Informacion de cuenta de Easynews" | |
| 3502 | ||
| 3503 | msgctxt "#90652" | |
| 3504 | msgid "DeepL API Error" | |
| 3505 | msgstr "Error de la API de DeepL" | |
| 3506 | ||
| 3507 | msgctxt "#90653" | |
| 3508 | msgid "Download" | |
| 3509 | msgstr "Descarga" | |
| 3510 | ||
| 3511 | msgctxt "#90654" | |
| 3512 | msgid "Failed to start download: %s" | |
| 3513 | msgstr "Error al iniciar la descarga: %s" | |
| 3514 | ||
| 3515 | msgctxt "#90655" | |
| 3516 | msgid "Modify Search" | |
| 3517 | msgstr "Modificar busqueda" | |
| 3518 | ||
| 3519 | msgctxt "#90656" | |
| 3520 | msgid "Torbox" | |
| 3521 | msgstr "Torbox" | |
| 3522 | ||
| 3523 | msgctxt "#90657" | |
| 3524 | msgid "Easy-Debrid" | |
| 3525 | msgstr "Easy-Debrid" | |
| 3526 | ||
| 3527 | msgctxt "#90658" | |
| 3528 | msgid "All-Debrid" | |
| 3529 | msgstr "All-Debrid" | |
| 3530 | ||
| 3531 | msgctxt "#90659" | |
| 3532 | msgid "Real-Debrid" | |
| 3533 | msgstr "Real-Debrid" | |
| 3534 | ||
| 3535 | msgctxt "#90660" | |
| 3536 | msgid "Getting results from providers..." | |
| 3537 | msgstr "Obteniendo resultados de los proveedores..." | |
| 3538 | ||
| 3539 | msgctxt "#90661" | |
| 3540 | msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3541 | msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3542 | ||
| 3543 | msgctxt "#90662" | |
| 3544 | msgid "Downloads Viewer" | |
| 3545 | msgstr "Visor de descargas" | |
| 3546 | ||
| 3547 | msgctxt "#90663" | |
| 3548 | msgid "WebDAV Text Viewer" | |
| 3549 | msgstr "Visor de texto WebDAV" | |
| 3550 | ||
| 3551 | msgctxt "#90664" | |
| 3552 | msgid "Selected Item" | |
| 3553 | msgstr "Elemento seleccionado" | |
| 3554 | ||
| 3555 | msgctxt "#90665" | |
| 3556 | msgid "Subtitle No. %d" | |
| 3557 | msgstr "Subtitulo num. %d" | |
| 3558 | ||
| 3559 | msgctxt "#90666" | |
| 3560 | msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?" | |
| 3561 | msgstr "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 | ||
| 3563 | msgctxt "#90667" | |
| 3564 | msgid "Free DeepL Usage" | |
| 3565 | msgstr "Uso gratuito de DeepL" | |
| 3566 | ||
| 3567 | msgctxt "#90668" | |
| 3568 | msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?" | |
| 3569 | msgstr "Caracteres totales a traducir: %s\nCoste estimado: $%.2f USD\n\n¿Quieres continuar con la traduccion?" | |
| 3570 | ||
| 3571 | msgctxt "#90669" | |
| 3572 | msgid "Translation Cost" | |
| 3573 | msgstr "Coste de traduccion" | |
| 3574 | ||
| 3575 | msgctxt "#90672" | |
| 3576 | msgid "Play Trailer" | |
| 3577 | msgstr "Reproducir trailer" | |
| 3578 | ||
| 3579 | msgctxt "#90673" | |
| 3580 | msgid "Trailer unavailable" | |
| 3581 | msgstr "Trailer no disponible" | |
| 3582 | ||
| 3583 | msgctxt "#90690" | |
| 3584 | msgid "Clear All Items" | |
| 3585 | msgstr "Eliminar todos los elementos" | |
| 3586 | ||
| 3587 | msgctxt "#90691" | |
| 3588 | msgid "Remove all items from this library?" | |
| 3589 | msgstr "¿Eliminar todos los elementos de esta biblioteca?" | |
| 3590 | ||
| 3591 | msgctxt "#90692" | |
| 3592 | msgid "Library cleared" | |
| 3593 | msgstr "Biblioteca vaciada" | |
| 3594 | ||
| 3595 | msgctxt "#90693" | |
| 3596 | msgid "No items to remove" | |
| 3597 | msgstr "No hay elementos para eliminar" | |
| 3598 | ||
| 3599 | msgctxt "#90694" | |
| 3600 | msgid "Added to %s cloud but still downloading. Please wait before playing." | |
| 3601 | msgstr "Agregado a la nube de %s pero aún descargando. Por favor espere antes de reproducir." | |
| 3602 | ||
| 3603 | msgctxt "#90695" | |
| 3604 | msgid "Torrent Not Ready" | |
| 3605 | msgstr "Torrent No Listo" | |
| 3606 | ||
| 3607 | msgctxt "#90696" | |
| 3608 | msgid "This torrent is still downloading in %s and is not ready yet." | |
| 3609 | msgstr "Este torrent aún se está descargando en %s y no está listo todavía." | |
| 3610 | ||
| 3611 | msgctxt "#90697" | |
| 3612 | msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?" | |
| 3613 | msgstr "¿Desea reproducir usando el cliente torrent (puede ser más lento) o cancelar y esperar a que el debrid termine?" | |
| 3614 | ||
| 3615 | msgctxt "#90698" | |
| 3616 | msgid "Play with Torrent" | |
| 3617 | msgstr "Reproducir con Torrent" | |
| 3618 | ||
| 3619 | msgctxt "#90699" | |
| 3620 | msgid "Cancel & Wait" | |
| 3621 | msgstr "Cancelar y Esperar" | |
| 3622 | ||
| 3623 | msgctxt "#90730" | |
| 3624 | msgid "Search with title + year" | |
| 3625 | msgstr "Buscar con título + año" | |
| 3626 | ||
| 3627 | msgctxt "#90731" | |
| 3628 | msgid "Search with original title" | |
| 3629 | msgstr "Buscar con título original" | |
| 3630 | ||
| 3631 | msgctxt "#90732" | |
| 3632 | msgid "Search with original title + year" | |
| 3633 | msgstr "Buscar con título original + año" | |
| 3634 | ||
| 3635 | msgctxt "#90733" | |
| 3636 | msgid "Search modes" | |
| 3637 | msgstr "Modos de búsqueda" | |
| 3638 | ||
| 3639 | msgctxt "#90734" | |
| 3640 | msgid "Enter year" | |
| 3641 | msgstr "Introduce el año" | |
| 3642 | ||
| 3643 | msgctxt "#90735" | |
| 3644 | msgid "Invalid year" | |
| 3645 | msgstr "Año no válido" | |
| 3646 | ||
| 3647 | msgctxt "#90736" | |
| 3648 | msgid "Edit title" | |
| 3649 | msgstr "Editar título" | |
| 3650 | ||
| 3651 | msgctxt "#90512" | |
| 3652 | msgid "Season" | |
| 3653 | msgstr "Temporada" | |
| 3654 | ||
| 3655 | msgctxt "#90737" | |
| 3656 | msgid "Invalid season" | |
| 3657 | msgstr "Temporada no válida" | |
| 3658 | ||
| 3659 | msgctxt "#90738" | |
| 3660 | msgid "Episode" | |
| 3661 | msgstr "Episodio" | |
| 3662 | ||
| 3663 | msgctxt "#90739" | |
| 3664 | msgid "Invalid episode" | |
| 3665 | msgstr "Episodio no válido" | |
| 3666 | ||
| 3667 | msgctxt "#90740" | |
| 3668 | msgid "Edit current title" | |
| 3669 | msgstr "Editar título actual" | |
| 3670 | ||
| 3671 | msgctxt "#90741" | |
| 3672 | msgid "Use original title" | |
| 3673 | msgstr "Usar título original" | |
| 3674 | ||
| 3675 | msgctxt "#90742" | |
| 3676 | msgid "Addon Settings" | |
| 3677 | msgstr "Ajustes del addon" | |
| 3678 | ||
| 3679 | msgctxt "#90743" | |
| 3680 | msgid "Set Views" | |
| 3681 | msgstr "Configurar vistas" | |
| 3682 | ||
| 3683 | msgctxt "#90744" | |
| 3684 | msgid "Upload Subtitle" | |
| 3685 | msgstr "Subir subtítulo" | |
| 3686 | ||
| 3687 | msgctxt "#90745" | |
| 3688 | msgid "Select Subtitle File" | |
| 3689 | msgstr "Seleccionar archivo de subtítulo" | |
| 3690 | ||
| 3691 | msgctxt "#90746" | |
| 3692 | msgid "Local File" | |
| 3693 | msgstr "Archivo local" | |
| 3694 | ||
| 3695 | msgctxt "#90747" | |
| 3696 | msgid "Upload from Browser" | |
| 3697 | msgstr "Subir desde el navegador" | |
| 3698 | ||
| 3699 | msgctxt "#90748" | |
| 3700 | msgid "Upload subtitle from your browser" | |
| 3701 | msgstr "Subir subtítulo desde tu navegador" | |
| 3702 | ||
| 3703 | msgctxt "#90749" | |
| 3704 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" | |
| 3705 | msgstr "Escanea el código QR o abre la URL (%s) en tu navegador para subir un archivo de subtítulo" | |
| 3706 | ||
| 3707 | msgctxt "#90750" | |
| 3708 | msgid "Search Title Language" | |
| 3709 | msgstr "Idioma del título de búsqueda" | |
| 3710 | ||
| 3711 | msgctxt "#90751" | |
| 3712 | msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches." | |
| 3713 | msgstr "Controla cómo se priorizan las búsquedas por título en Easynews/Jackett/Prowlarr en búsquedas de contexto." | |
| 3714 | ||
| 3715 | msgctxt "#90752" | |
| 3716 | msgid "English first" | |
| 3717 | msgstr "Inglés primero" | |
| 3718 | ||
| 3719 | msgctxt "#90753" | |
| 3720 | msgid "English only" | |
| 3721 | msgstr "Solo inglés" | |
| 3722 | ||
| 3723 | msgctxt "#90754" | |
| 3724 | msgid "Localized first" | |
| 3725 | msgstr "Localizado primero" |
| … | … | @@ -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&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&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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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 |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> |
| … | … | @@ -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> | |
| 353 | const API_BASE = ''; | |
| 354 | ||
| 355 | function 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 | ||
| 362 | function clearFeedback() { | |
| 363 | document.getElementById('feedback').className = 'feedback'; | |
| 364 | } | |
| 365 | ||
| 366 | async 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 | ||
| 377 | function 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 | ||
| 422 | async 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 | ||
| 455 | async 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 | ||
| 465 | async 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 | ||
| 478 | function escapeHtml(str) { | |
| 479 | const div = document.createElement('div'); | |
| 480 | div.textContent = str || ''; | |
| 481 | return div.innerHTML; | |
| 482 | } | |
| 483 | ||
| 484 | function escapeAttr(str) { | |
| 485 | return (str || '').replace(/[^a-zA-Z0-9._-]/g, '_'); | |
| 486 | } | |
| 487 | ||
| 488 | // Enter key to add | |
| 489 | document.getElementById('addon-url').addEventListener('keydown', e => { | |
| 490 | if (e.key === 'Enter') addAddon(); | |
| 491 | }); | |
| 492 | ||
| 493 | // Initial load | |
| 494 | loadAddons(); | |
| 495 | </script> | |
| 496 | ||
| 497 | </body> | |
| 498 | </html> |
| … | … | @@ -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 | × | |
| 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> |
| … | … | @@ -0,0 +1,84 @@ |
| 1 | import ast | |
| 2 | import os | |
| 3 | import sys | |
| 4 | ||
| 5 | # Disallowed base names used as generics in Python < 3.9 | |
| 6 | PEP585_TYPES = {"list", "dict", "set", "tuple", "frozenset", "type", "callable"} | |
| 7 | ||
| 8 | # PEP 593, 604, 647, 612, 673 keywords | |
| 9 | UNSUPPORTED_NAMES = { | |
| 10 | "Annotated", "Self", "TypeGuard", "ParamSpec", "Concatenate", "Required", "NotRequired" | |
| 11 | } | |
| 12 | ||
| 13 | def 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 | ||
| 60 | def 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 | ||
| 71 | if __name__ == "__main__": | |
| 72 | if len(sys.argv) < 2: | |
| 73 | print("Usage: python check_py37_compat.py /path/to/project") | |
| 74 | sys.exit(1) | |
| 75 | ||
| 76 | root = sys.argv[1] | |
| 77 | findings = scan_project(root) | |
| 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}") |
| … | … | @@ -0,0 +1,142 @@ |
| 1 | import sys | |
| 2 | import os | |
| 3 | import re | |
| 4 | ||
| 5 | def print_help(): | |
| 6 | print("""Usage: python manage_labels.py [operation] [po_file] | |
| 7 | ||
| 8 | Operations: | |
| 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 | ||
| 12 | Required arguments: | |
| 13 | operation Either 'replace' or 'restore' | |
| 14 | po_file Path to the strings.po file | |
| 15 | ||
| 16 | Features: | |
| 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 | ||
| 24 | def 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 | ||
| 51 | def 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 | ||
| 90 | def 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 | ||
| 97 | def 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 | ||
| 141 | if __name__ == '__main__': | |
| 142 | main() |
| … | … | @@ -0,0 +1,4 @@ |
| 1 | from lib.services.background_service import JacktookMOnitor | |
| 2 | ||
| 3 | ||
| 4 | JacktookMOnitor().waitForAbort() |
| … | … | @@ -0,0 +1,199 @@ |
| 1 | import sys | |
| 2 | import os | |
| 3 | from 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 | ||
| 11 | class _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 --- | |
| 176 | xbmc_mock = MagicMock() | |
| 177 | xbmc_mock.getSupportedMedia.return_value = ".mp4|.mkv|.avi|.ts|.m2ts" | |
| 178 | xbmc_mock.LOGDEBUG = 0 | |
| 179 | xbmc_mock.LOGINFO = 1 | |
| 180 | xbmc_mock.LOGWARNING = 2 | |
| 181 | xbmc_mock.LOGERROR = 3 | |
| 182 | ||
| 183 | sys.modules["xbmc"] = xbmc_mock | |
| 184 | sys.modules["xbmcgui"] = _XbmcguiStub() | |
| 185 | sys.modules["xbmcaddon"] = MagicMock() | |
| 186 | sys.modules["xbmcvfs"] = MagicMock() | |
| 187 | sys.modules["xbmcplugin"] = MagicMock() | |
| 188 | sys.modules["pydevd"] = MagicMock() | |
| 189 | ||
| 190 | # Mock sys.argv to simulate Kodi addon arguments | |
| 191 | sys.argv = ["plugin.video.jacktook", "1", "/", ""] | |
| 192 | ||
| 193 | # Mock sqlite3.connect to avoid side effects during module imports | |
| 194 | import sqlite3 | |
| 195 | ||
| 196 | sqlite3.connect = MagicMock(return_value=MagicMock()) | |
| 197 | ||
| 198 | # Add the project root to sys.path to allow importing lib | |
| 199 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) |
| … | … | @@ -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&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&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> |
| … | … | @@ -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 | } |
| … | … | @@ -0,0 +1,93 @@ |
| 1 | import sys | |
| 2 | import os | |
| 3 | from unittest.mock import MagicMock | |
| 4 | ||
| 5 | if "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 --- | |
| 11 | mock_xbmc = MagicMock() | |
| 12 | mock_xbmc.LOGINFO = 1 | |
| 13 | mock_xbmc.LOGERROR = 3 | |
| 14 | sys.modules["xbmc"] = mock_xbmc | |
| 15 | ||
| 16 | mock_xbmcgui = MagicMock() | |
| 17 | sys.modules["xbmcgui"] = mock_xbmcgui | |
| 18 | ||
| 19 | mock_xbmcaddon = MagicMock() | |
| 20 | sys.modules["xbmcaddon"] = mock_xbmcaddon | |
| 21 | ||
| 22 | # --- Mock sqlite3 to avoid DB initialization errors --- | |
| 23 | import sqlite3 | |
| 24 | ||
| 25 | sqlite3.connect = MagicMock(return_value=MagicMock()) | |
| 26 | ||
| 27 | mock_xbmcplugin = MagicMock() | |
| 28 | sys.modules["xbmcplugin"] = mock_xbmcplugin | |
| 29 | ||
| 30 | mock_xbmcvfs = MagicMock() | |
| 31 | mock_xbmcvfs.translatePath.return_value = ":memory:" | |
| 32 | sys.modules["xbmcvfs"] = mock_xbmcvfs | |
| 33 | ||
| 34 | # --- Mock kodi utils --- | |
| 35 | mock_kodi_utils = MagicMock() | |
| 36 | mock_kodi_utils.dialog_text = lambda title, body: print( | |
| 37 | f"\n[DIALOG: {title}]\n{body}\n" | |
| 38 | ) | |
| 39 | mock_kodi_utils.kodilog = lambda msg, level=1: print(f"[LOG] {msg}") | |
| 40 | sys.modules["lib.utils.kodi.utils"] = mock_kodi_utils | |
| 41 | ||
| 42 | # Add project root to path | |
| 43 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) | |
| 44 | ||
| 45 | ||
| 46 | def mock_notification(msg): | |
| 47 | print(f"[NOTIFICATION] {msg}") | |
| 48 | ||
| 49 | ||
| 50 | # Now we can import our client | |
| 51 | try: | |
| 52 | from lib.clients.easynews import Easynews | |
| 53 | except ImportError as e: | |
| 54 | print(f"Error importing Easynews: {e}") | |
| 55 | sys.exit(1) | |
| 56 | ||
| 57 | ||
| 58 | def 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 | ||
| 92 | if __name__ == "__main__": | |
| 93 | main() |
| … | … | @@ -0,0 +1,148 @@ |
| 1 | import json | |
| 2 | from pathlib import Path | |
| 3 | import xml.etree.ElementTree as ET | |
| 4 | import sys | |
| 5 | ||
| 6 | if "pytest" in sys.modules: | |
| 7 | import pytest | |
| 8 | ||
| 9 | pytest.skip("manual script not intended for pytest collection", allow_module_level=True) | |
| 10 | ||
| 11 | import requests | |
| 12 | ||
| 13 | ||
| 14 | SETTINGS_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 | ] | |
| 18 | BASE_URL = "https://api.torbox.app/v1/api" | |
| 19 | VIDEO_EXTENSIONS = (".mp4", ".mkv", ".avi", ".ts", ".m2ts") | |
| 20 | MAX_LINK_TESTS = 20 | |
| 21 | ||
| 22 | ||
| 23 | def 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 | ||
| 45 | def 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 | ||
| 58 | def 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 | ||
| 71 | def 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 | ||
| 147 | if __name__ == "__main__": | |
| 148 | main() |
| … | … | @@ -0,0 +1,21 @@ |
| 1 | import sys | |
| 2 | from unittest.mock import MagicMock | |
| 3 | ||
| 4 | # Create mock modules for Kodi | |
| 5 | mock_modules = [ | |
| 6 | "xbmc", | |
| 7 | "xbmcgui", | |
| 8 | "xbmcaddon", | |
| 9 | "xbmcvfs", | |
| 10 | "pydevd", # Sometimes used in debugging | |
| 11 | ] | |
| 12 | ||
| 13 | for module in mock_modules: | |
| 14 | sys.modules[module] = MagicMock() | |
| 15 | ||
| 16 | # Mock specific attributes if needed | |
| 17 | import xbmcgui | |
| 18 | ||
| 19 | xbmcgui.ListItem = MagicMock | |
| 20 | xbmcgui.Dialog = MagicMock | |
| 21 | xbmcgui.DialogProgressBG = MagicMock |
| … | … | @@ -0,0 +1,52 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from 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") | |
| 16 | def 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") |
| … | … | @@ -0,0 +1,62 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.gui.base_window import BaseWindow | |
| 4 | ||
| 5 | ||
| 6 | class _DummyWindow(BaseWindow): | |
| 7 | def handle_action(self, action_id, control_id=None): | |
| 8 | return None | |
| 9 | ||
| 10 | ||
| 11 | def 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 | ||
| 30 | def 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 | ||
| 50 | def 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() |
| … | … | @@ -0,0 +1,63 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.domain.torrent import TorrentStream | |
| 4 | from lib.gui.base_window import BaseWindow | |
| 5 | from lib.utils.general.utils import Indexer, IndexerType | |
| 6 | ||
| 7 | ||
| 8 | class _DummyWindow(BaseWindow): | |
| 9 | def handle_action(self, action_id, control_id=None): | |
| 10 | return None | |
| 11 | ||
| 12 | ||
| 13 | def 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 | ||
| 36 | def 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 | ||
| 51 | def 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" |
| … | … | @@ -0,0 +1,70 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.utils.debrid.debrid_utils import get_magnet_from_uri | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 26 | def 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 | ||
| 51 | def 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() |
| … | … | @@ -0,0 +1,68 @@ |
| 1 | import importlib | |
| 2 | import sys | |
| 3 | from unittest.mock import MagicMock, patch | |
| 4 | ||
| 5 | ||
| 6 | def _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 | ||
| 12 | def 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 | ||
| 18 | def 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 | ||
| 30 | def 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 | ||
| 42 | def 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() |
| … | … | @@ -0,0 +1,33 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.gui.extras_window import ExtrasWindow | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ) |
| … | … | @@ -0,0 +1,95 @@ |
| 1 | import pytest | |
| 2 | from 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 | ||
| 16 | def 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 | ||
| 24 | def 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 | ||
| 31 | def 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 | ||
| 38 | def 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 | ||
| 46 | def 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 | ||
| 52 | def 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 | ||
| 59 | def 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 | ||
| 68 | def 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 | ||
| 75 | def 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 | ) | |
| 94 | def test_format_season_episode(season, episode, expected): | |
| 95 | assert format_season_episode(season, episode) == expected |
| … | … | @@ -0,0 +1,165 @@ |
| 1 | import pytest | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | import os | |
| 4 | ||
| 5 | from lib.clients.jackett import Jackett | |
| 6 | from lib.domain.torrent import TorrentStream | |
| 7 | ||
| 8 | # Load fixture data | |
| 9 | FIXTURE_PATH = os.path.join( | |
| 10 | os.path.dirname(__file__), "../fixtures/jackett_response.xml" | |
| 11 | ) | |
| 12 | with open(FIXTURE_PATH, "r") as f: | |
| 13 | XML_RESPONSE = f.read() | |
| 14 | ||
| 15 | ||
| 16 | @pytest.fixture | |
| 17 | def 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 | ||
| 33 | def 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) | |
| 41 | def 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) | |
| 68 | def 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 | ||
| 87 | def 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 | ||
| 95 | def 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 | ||
| 119 | def 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 | ||
| 132 | def 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 | ||
| 144 | def 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 |
| … | … | @@ -0,0 +1,44 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.utils.kodi import utils | |
| 4 | ||
| 5 | ||
| 6 | def test_is_youtube_addon_enabled_returns_false_when_addon_is_absent(): | |
| 7 | with patch.object(utils.xbmc, "getCondVisibility", return_value=False) as get_cond_visibility, patch.object( | |
| 8 | 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 | ||
| 19 | def test_is_youtube_addon_enabled_returns_true_when_addon_is_enabled(): | |
| 20 | with patch.object(utils.xbmc, "getCondVisibility", return_value=True) as get_cond_visibility, patch.object( | |
| 21 | 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 | ||
| 33 | def test_is_youtube_addon_enabled_returns_false_when_addon_is_disabled(): | |
| 34 | with patch.object(utils.xbmc, "getCondVisibility", return_value=True) as get_cond_visibility, patch.object( | |
| 35 | 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) |
| … | … | @@ -0,0 +1,99 @@ |
| 1 | from lib.nav import library_history | |
| 2 | from lib.utils.views import library | |
| 3 | ||
| 4 | ||
| 5 | def 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 | ||
| 49 | def 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 | ||
| 64 | def 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 |
| … | … | @@ -0,0 +1,192 @@ |
| 1 | from unittest.mock import MagicMock | |
| 2 | ||
| 3 | from lib.utils.views import library | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 39 | def 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 | ||
| 59 | def 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 | ||
| 83 | def 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 | ||
| 144 | def 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 | ||
| 176 | def 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 |
| … | … | @@ -0,0 +1,62 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.utils.views import library | |
| 4 | from lib.utils.general.utils import add_to_library, remove_from_library | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 21 | def 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 | ||
| 47 | def 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" |
| … | … | @@ -0,0 +1,26 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.nav import debrid as debrid_navigation | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 20 | def 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") |
| … | … | @@ -0,0 +1,29 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.nav import library_history | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 22 | def 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") |
| … | … | @@ -0,0 +1,15 @@ |
| 1 | from datetime import datetime | |
| 2 | ||
| 3 | from lib.utils.general.utils import parse_time | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 12 | def 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) |
| … | … | @@ -0,0 +1,36 @@ |
| 1 | from pathlib import Path | |
| 2 | import re | |
| 3 | ||
| 4 | ||
| 5 | PLAYER_PATH = Path(__file__).resolve().parents[2] / "lib" / "player.py" | |
| 6 | ||
| 7 | ||
| 8 | def 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 | ||
| 24 | def 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 |
| … | … | @@ -0,0 +1,80 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.domain.torrent import TorrentStream | |
| 4 | from lib.utils.general.utils import pre_process | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 32 | def 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 | ||
| 58 | def 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"] |
| … | … | @@ -0,0 +1,91 @@ |
| 1 | import pytest | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | from lib.clients.prowlarr import Prowlarr | |
| 5 | ||
| 6 | ||
| 7 | @pytest.fixture | |
| 8 | def 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 | ||
| 20 | def 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 | ||
| 25 | def 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 | ||
| 41 | def 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 | ||
| 57 | def 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 | ||
| 76 | def 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"] |
| … | … | @@ -0,0 +1,29 @@ |
| 1 | from unittest.mock import MagicMock | |
| 2 | ||
| 3 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 19 | def 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() |
| … | … | @@ -0,0 +1,50 @@ |
| 1 | import pytest | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | import os | |
| 4 | import json | |
| 5 | ||
| 6 | from lib.api.debrid.realdebrid import RealDebrid | |
| 7 | ||
| 8 | # Load fixture data | |
| 9 | FIXTURE_PATH = os.path.join( | |
| 10 | os.path.dirname(__file__), "../fixtures/realdebrid_response.json" | |
| 11 | ) | |
| 12 | with open(FIXTURE_PATH, "r") as f: | |
| 13 | JSON_DATA = json.load(f) | |
| 14 | ||
| 15 | ||
| 16 | @pytest.fixture | |
| 17 | def 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 | ||
| 27 | def 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" |
| … | … | @@ -0,0 +1,78 @@ |
| 1 | import pytest | |
| 2 | from unittest.mock import MagicMock | |
| 3 | ||
| 4 | from lib.api.debrid.base import ProviderException | |
| 5 | from lib.clients.debrid.realdebrid import RealDebridHelper | |
| 6 | ||
| 7 | ||
| 8 | def 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 | ||
| 32 | def 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 | ||
| 49 | def 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 | ||
| 67 | def 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", {}) |
| … | … | @@ -0,0 +1,90 @@ |
| 1 | import importlib | |
| 2 | import sys | |
| 3 | from unittest.mock import patch | |
| 4 | ||
| 5 | ||
| 6 | def _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 | ||
| 12 | def 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 | ||
| 18 | def 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 | ||
| 24 | def 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 | ||
| 30 | def 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 | ||
| 37 | def 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 | ||
| 46 | def 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 | ||
| 62 | def 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 | ||
| 73 | def 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 | ||
| 83 | def 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) |
| … | … | @@ -0,0 +1,5 @@ |
| 1 | from lib.router import _is_tmdb_action | |
| 2 | ||
| 3 | ||
| 4 | def test_is_tmdb_action_includes_tmdb_search_modes(): | |
| 5 | assert _is_tmdb_action("tmdb_search_modes") is True |
| … | … | @@ -0,0 +1,98 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.api.tmdbv3api.as_obj import AsObj | |
| 4 | from lib.search import SearchVariant, _build_title_fallback_queries, show_source_select | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 14 | def 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 | ||
| 38 | def 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 | ||
| 79 | def 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" |
| … | … | @@ -0,0 +1,323 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.api.tmdbv3api.as_obj import AsObj | |
| 4 | from lib.domain.torrent import TorrentStream | |
| 5 | from 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 | ||
| 15 | def 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 | ||
| 24 | def 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 | ||
| 50 | def 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 | ||
| 62 | def 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 | ||
| 87 | def 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 | ||
| 113 | def 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 | ||
| 125 | def 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 | ||
| 150 | def 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 | ||
| 179 | def 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 | ||
| 208 | def 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 | ||
| 233 | def 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 | ||
| 262 | def 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 | ||
| 292 | def 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 | ) |
| … | … | @@ -0,0 +1,484 @@ |
| 1 | from lib.utils.kodi import settings_backup | |
| 2 | ||
| 3 | ||
| 4 | class 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 | ||
| 18 | def _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 | ||
| 32 | def 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 | ||
| 84 | def 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 | ||
| 152 | def 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 | ||
| 244 | def 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 | ||
| 327 | def 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 | ||
| 398 | def 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 | ||
| 465 | def 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 == [] |
| … | … | @@ -0,0 +1,35 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.gui.skip_intro_window import SkipIntroWindow | |
| 4 | ||
| 5 | ||
| 6 | @patch("lib.gui.skip_intro_window.Thread") | |
| 7 | def 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 | ||
| 25 | def 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() |
| … | … | @@ -0,0 +1,142 @@ |
| 1 | import importlib | |
| 2 | import sys | |
| 3 | from unittest.mock import MagicMock, patch | |
| 4 | ||
| 5 | from lib.domain.torrent import TorrentStream | |
| 6 | from lib.utils.general.utils import DebridType, IndexerType | |
| 7 | ||
| 8 | ||
| 9 | def _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 | ||
| 15 | def 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 | ||
| 51 | def 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 | ||
| 86 | def 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 |
| … | … | @@ -0,0 +1,827 @@ |
| 1 | import json | |
| 2 | from unittest.mock import MagicMock | |
| 3 | ||
| 4 | from lib.api.stremio.addon_manager import AddonManager | |
| 5 | from lib.api.stremio.models import Meta, MetaPreview | |
| 6 | from lib.clients.stremio import helpers | |
| 7 | from lib.clients.stremio.catalog_menus import CATALOG_PAGE_SIZE | |
| 8 | from lib.clients.stremio import catalog_menus | |
| 9 | from lib.domain.torrent import TorrentStream | |
| 10 | from lib import search | |
| 11 | ||
| 12 | ||
| 13 | def 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 | ||
| 30 | def 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 | ||
| 67 | def 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 | ||
| 110 | def 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 | ||
| 134 | def 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 | ||
| 185 | def 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 | ||
| 212 | def 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 | ||
| 255 | def 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 | ||
| 311 | def 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 | ||
| 371 | def 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 | ||
| 412 | def 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 | ||
| 445 | def 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 | ||
| 464 | def 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 | ||
| 569 | def 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 | ||
| 628 | def 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 | ||
| 698 | def 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 | ||
| 771 | def 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 | } |
| … | … | @@ -0,0 +1,16 @@ |
| 1 | import pytest | |
| 2 | from lib.utils.parsers.title_parser import parse_title_info | |
| 3 | ||
| 4 | def 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 | ||
| 12 | def 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" |
| … | … | @@ -0,0 +1,144 @@ |
| 1 | import json | |
| 2 | from types import SimpleNamespace | |
| 3 | from urllib.parse import parse_qs, urlparse | |
| 4 | from unittest.mock import MagicMock, patch | |
| 5 | ||
| 6 | from lib.clients.tmdb.people_client import PeopleClient | |
| 7 | from lib.clients.tmdb.tmdb import TmdbClient | |
| 8 | from lib.clients.tmdb.utils.utils import add_tmdb_movie_context_menu | |
| 9 | ||
| 10 | ||
| 11 | def _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 | ||
| 17 | def _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 | ||
| 24 | def 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 | ||
| 61 | def test_search_tmdb_recommendations_uses_movie_endpoint_for_multi_movie(): | |
| 62 | results = SimpleNamespace(total_results=1, total_pages=2, results=[SimpleNamespace(id=11, title="R")]) | |
| 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 | ||
| 94 | def test_search_tmdb_similar_uses_movie_endpoint_for_multi_movie(): | |
| 95 | results = SimpleNamespace(total_results=1, total_pages=2, results=[SimpleNamespace(id=22, title="S")]) | |
| 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 | ||
| 127 | def 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 |
| … | … | @@ -0,0 +1,340 @@ |
| 1 | import json | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | from lib.clients.tmdb.tmdb import TmdbClient | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 41 | def 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 | ||
| 67 | def 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 | ||
| 89 | def 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 | ||
| 112 | def 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 | ||
| 138 | def 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 | ||
| 163 | def 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 | ||
| 200 | def 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 | ||
| 229 | def 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 | ||
| 252 | def 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 | ||
| 277 | def 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 | ||
| 302 | def 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 | ||
| 328 | def 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() |
| … | … | @@ -0,0 +1,255 @@ |
| 1 | import os | |
| 2 | from unittest.mock import patch | |
| 3 | ||
| 4 | from lib.api.tmdbv3api.as_obj import AsObj | |
| 5 | from lib.clients.tmdb.trailers import ( | |
| 6 | _normalize_media_type, | |
| 7 | choose_tmdb_trailer, | |
| 8 | resolve_tmdb_trailer, | |
| 9 | ) | |
| 10 | ||
| 11 | ||
| 12 | def 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 | ||
| 19 | def 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 | ||
| 24 | def 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 | ||
| 52 | def 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 | ||
| 61 | def 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 | ||
| 73 | def 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 | ||
| 84 | def 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 | ||
| 106 | def 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 | ||
| 129 | def 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 | ||
| 153 | def 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 | ||
| 178 | def test_resolve_tmdb_trailer_returns_none_for_invalid_media_type(): | |
| 179 | assert resolve_tmdb_trailer(1, "person") is None | |
| 180 | ||
| 181 | ||
| 182 | def 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 | ||
| 195 | def 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 | ||
| 228 | def 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"] |
| … | … | @@ -0,0 +1,47 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.clients.tmdb.base import BaseTmdbClient | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 38 | def 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")]) |
| … | … | @@ -0,0 +1,212 @@ |
| 1 | from urllib.parse import parse_qs, urlparse | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | from lib.clients.trakt.trakt import BaseTraktClient | |
| 5 | from lib.clients.tmdb.base import BaseTmdbClient | |
| 6 | from lib.clients.tmdb.utils.utils import ( | |
| 7 | add_tmdb_movie_context_menu, | |
| 8 | add_tmdb_show_context_menu, | |
| 9 | ) | |
| 10 | ||
| 11 | ||
| 12 | TRAILER_LABEL = "Play Trailer" | |
| 13 | ||
| 14 | ||
| 15 | def _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 | ||
| 22 | def _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 | ||
| 35 | def _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 | ||
| 42 | def _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 | ||
| 48 | def test_movie_context_menu_contains_play_trailer_when_tmdb_id_exists(): | |
| 49 | with patch("lib.clients.tmdb.utils.utils.translation", side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}"): | |
| 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 | ||
| 70 | def test_movie_context_menu_hides_play_trailer_without_tmdb_or_youtube_metadata(): | |
| 71 | with patch("lib.clients.tmdb.utils.utils.translation", side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}"): | |
| 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 | ||
| 82 | def 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 | ||
| 108 | def test_tv_context_menu_contains_play_trailer_when_tmdb_id_exists(): | |
| 109 | with patch("lib.clients.tmdb.utils.utils.translation", side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}"): | |
| 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 | ||
| 129 | def 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 | ||
| 150 | def 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 | ||
| 181 | def 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 | } |
| … | … | @@ -0,0 +1,310 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | ||
| 4 | def 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 | ||
| 43 | def 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 | ||
| 76 | def 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 | ||
| 100 | def 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 | ||
| 118 | def 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 | ||
| 135 | def 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 | ||
| 172 | def 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 | ||
| 203 | def 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 | ||
| 242 | def 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 | ||
| 277 | def 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 | ) |
| … | … | @@ -0,0 +1,42 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.clients.trakt.trakt import TraktPresentation | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 30 | def 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() |
| … | … | @@ -0,0 +1,29 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib import navigation | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 19 | def 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 |
| … | … | @@ -0,0 +1,167 @@ |
| 1 | import datetime | |
| 2 | from types import SimpleNamespace | |
| 3 | ||
| 4 | from lib.api.trakt.trakt import TraktTV | |
| 5 | from lib.clients.trakt.trakt import TraktPresentation | |
| 6 | ||
| 7 | ||
| 8 | def 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 | ||
| 37 | def 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 | ||
| 73 | def 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 | ||
| 99 | def 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 | ||
| 138 | def 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 |
| … | … | @@ -0,0 +1,117 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.api.trakt.trakt import TraktLists | |
| 4 | from lib.clients.trakt.trakt import TraktPresentation | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 33 | def 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 | ||
| 49 | def 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 | ||
| 75 | def 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 | ||
| 87 | def 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 | ||
| 104 | def 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 | ) |
| … | … | @@ -0,0 +1,295 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.api.trakt.trakt import TraktLists | |
| 4 | from lib.api.trakt.trakt_utils import ( | |
| 5 | add_trakt_favorites_context_menu, | |
| 6 | add_trakt_watchlist_context_menu, | |
| 7 | ) | |
| 8 | from lib.clients.trakt.trakt import TraktPresentation | |
| 9 | ||
| 10 | ||
| 11 | def 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 | ||
| 28 | def 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 | ||
| 44 | def 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 | ||
| 53 | def 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 | ||
| 62 | def 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 | ||
| 82 | def 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 | ||
| 94 | def 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 | ||
| 109 | def 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 | ||
| 127 | def 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 | ||
| 161 | def 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 | ||
| 201 | def 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 | ||
| 236 | def 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 | ||
| 246 | def 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 | ||
| 255 | def 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 | ||
| 288 | def 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 |
| … | … | @@ -0,0 +1,94 @@ |
| 1 | from copy import deepcopy | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | from lib.api.trakt.trakt_cache import default_activities | |
| 5 | from lib.services.trakt_sync import TraktSyncService | |
| 6 | ||
| 7 | ||
| 8 | def 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 | ||
| 23 | def 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 | ||
| 38 | def 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 | ||
| 56 | def 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 | ||
| 79 | def 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 | ) |
| … | … | @@ -0,0 +1,199 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.clients.debrid.torbox import TorboxHelper | |
| 4 | from lib.nav import debrid as debrid_navigation | |
| 5 | from lib.utils.general.utils import DebridType | |
| 6 | from lib.utils.debrid import debrid_utils | |
| 7 | from lib.utils.torrent import torrserver_utils | |
| 8 | ||
| 9 | ||
| 10 | def 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 | ||
| 28 | def 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 | ||
| 45 | def 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 | ||
| 65 | def 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 | ||
| 85 | def 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 | ||
| 100 | def 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 | ||
| 115 | def 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 | ||
| 145 | def 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 | ||
| 163 | def 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() |
| … | … | @@ -0,0 +1,152 @@ |
| 1 | import io | |
| 2 | import os | |
| 3 | import shutil | |
| 4 | import zipfile | |
| 5 | ||
| 6 | from lib import updater | |
| 7 | ||
| 8 | ||
| 9 | def _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 | ||
| 19 | def _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 | ||
| 33 | def _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 | ||
| 63 | def 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 | ||
| 94 | def 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 | ||
| 108 | def 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 | ||
| 137 | def 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")) |
| … | … | @@ -0,0 +1,39 @@ |
| 1 | from unittest.mock import MagicMock | |
| 2 | ||
| 3 | ||
| 4 | from lib.services.webserver import _addon_capabilities | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 19 | def 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 | ||
| 31 | def 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} |
| … | … | @@ -0,0 +1,697 @@ |
| 1 | import pytest | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | ||
| 5 | from 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 | ||
| 15 | WATCH_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 | ||
| 27 | class 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 | ||
| 41 | class 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) | |
| 60 | def 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 | ) | |
| 80 | def test_extract_video_id_supports_common_youtube_inputs(value): | |
| 81 | assert extract_video_id(value) == "dQw4w9WgXcQ" | |
| 82 | ||
| 83 | ||
| 84 | def test_extract_video_id_rejects_unknown_values(): | |
| 85 | assert extract_video_id("https://example.com/watch?v=invalid") is None | |
| 86 | ||
| 87 | ||
| 88 | def 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 | ||
| 95 | def test_parse_watch_config_requires_api_key(): | |
| 96 | assert parse_watch_config("<html>missing config</html>") is None | |
| 97 | ||
| 98 | ||
| 99 | def 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 | ||
| 113 | def 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 | ||
| 142 | def 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 | ||
| 176 | def 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 | ||
| 210 | def 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 | ||
| 236 | def 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 | ||
| 265 | def 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 | ||
| 293 | def 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 | ||
| 335 | def 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 | ||
| 378 | def 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 | ||
| 409 | def 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 | ||
| 435 | def 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 | ||
| 491 | def 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 | ||
| 526 | def 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 | ||
| 538 | def 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 | ||
| 559 | def 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 | ||
| 604 | def 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 | ||
| 651 | def 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 | ||
| 666 | def 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 | ||
| 690 | def 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() |
| … | … | @@ -0,0 +1,107 @@ |
| 1 | import sys | |
| 2 | import os | |
| 3 | from unittest.mock import MagicMock | |
| 4 | ||
| 5 | if "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 | |
| 11 | mock_xbmc = MagicMock() | |
| 12 | mock_xbmcgui = MagicMock() | |
| 13 | mock_xbmcaddon = MagicMock() | |
| 14 | mock_xbmcvfs = MagicMock() | |
| 15 | mock_xbmcplugin = MagicMock() | |
| 16 | ||
| 17 | sys.modules["xbmc"] = mock_xbmc | |
| 18 | sys.modules["xbmcgui"] = mock_xbmcgui | |
| 19 | sys.modules["xbmcaddon"] = mock_xbmcaddon | |
| 20 | sys.modules["xbmcvfs"] = mock_xbmcvfs | |
| 21 | sys.modules["xbmcplugin"] = mock_xbmcplugin | |
| 22 | ||
| 23 | # 2. Add current directory to path so we can import 'lib' | |
| 24 | sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) | |
| 25 | ||
| 26 | ||
| 27 | # 3. Create a mock for kodi utils and DB | |
| 28 | def mock_kodilog(message, level=None): | |
| 29 | print(f"[LOG] {message}") | |
| 30 | ||
| 31 | ||
| 32 | def mock_notification(message, header="Jacktook", time=5000, icon=""): | |
| 33 | print(f"[NOTIFICATION] {header}: {message}") | |
| 34 | ||
| 35 | ||
| 36 | def mock_translation(id): | |
| 37 | return f"String_{id}" | |
| 38 | ||
| 39 | ||
| 40 | def mock_get_setting(id, default=""): | |
| 41 | return default | |
| 42 | ||
| 43 | ||
| 44 | # Mock the entire utils module | |
| 45 | utils_mock = MagicMock() | |
| 46 | utils_mock.kodilog = mock_kodilog | |
| 47 | utils_mock.notification = mock_notification | |
| 48 | utils_mock.translation = mock_translation | |
| 49 | utils_mock.get_setting = mock_get_setting | |
| 50 | sys.modules["lib.utils.kodi.utils"] = utils_mock | |
| 51 | ||
| 52 | # Mock the cache module to prevent SQL errors | |
| 53 | cache_mock = MagicMock() | |
| 54 | sys.modules["lib.db.cached"] = cache_mock | |
| 55 | ||
| 56 | # Mock TVDBAPI as well since it triggers the imports | |
| 57 | sys.modules["lib.api.tvdbapi.tvdbapi"] = MagicMock() | |
| 58 | sys.modules["lib.api.tvdbapi"] = MagicMock() | |
| 59 | ||
| 60 | # 4. Now import the clients | |
| 61 | from lib.api.debrid.realdebrid import RealDebrid | |
| 62 | from lib.api.debrid.alldebrid import AllDebrid | |
| 63 | from lib.api.debrid.torbox import Torbox | |
| 64 | from lib.api.debrid.premiumize import Premiumize | |
| 65 | from lib.api.debrid.debrider import Debrider | |
| 66 | ||
| 67 | # === CONFIGURATION: ADD YOUR TOKENS HERE === | |
| 68 | TOKENS = { | |
| 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 | ||
| 78 | def 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 | ||
| 99 | if __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"]) |