├── .env ├── tests ├── __init__.py ├── test_clean_none_dict_values.py ├── test_imports.py ├── test_translations.py └── test_helper_utils.py ├── jellyfin_kodi ├── __init__.py ├── dialogs │ ├── __init__.py │ ├── resume.py │ ├── context.py │ ├── usersconnect.py │ ├── serverconnect.py │ ├── loginmanual.py │ └── servermanual.py ├── objects │ ├── kodi │ │ ├── __init__.py │ │ ├── queries_texture.py │ │ ├── musicvideos.py │ │ ├── artwork.py │ │ ├── tvshows.py │ │ └── movies.py │ ├── __init__.py │ ├── utils.py │ └── obj.py ├── entrypoint │ ├── __init__.py │ └── context.py ├── helper │ ├── __init__.py │ ├── exceptions.py │ ├── translate.py │ ├── wrapper.py │ ├── lazylogger.py │ ├── loghandler.py │ └── xmls.py ├── jellyfin │ ├── utils.py │ ├── configuration.py │ ├── client.py │ ├── __init__.py │ ├── ws_client.py │ └── credentials.py ├── client.py └── database │ └── jellyfin_db.py ├── typings └── jellyfin_kodi │ ├── helper │ └── utils.pyi │ └── database │ └── jellyfin_db.pyi ├── mypy.ini ├── requirements-dev.txt ├── resources ├── fanart.png ├── icon.png ├── skins │ └── default │ │ ├── media │ │ ├── white.png │ │ ├── wifi.png │ │ ├── network.png │ │ ├── spinner.gif │ │ ├── logo-white.png │ │ ├── user_image.png │ │ ├── dialogs │ │ │ ├── menu_top.png │ │ │ ├── menu_back.png │ │ │ ├── dialog_back.png │ │ │ └── menu_bottom.png │ │ ├── items │ │ │ ├── focus_square.png │ │ │ ├── logindefault.png │ │ │ ├── mask_square.png │ │ │ └── shadow_square.png │ │ ├── userflyoutdefault.png │ │ └── buttons │ │ │ └── shadow_smallbutton.png │ │ └── 1080i │ │ ├── script-jellyfin-context.xml │ │ ├── script-jellyfin-resume.xml │ │ ├── script-jellyfin-connect-server-manual.xml │ │ └── script-jellyfin-connect-login-manual.xml └── language │ ├── resource.language.oc_fr │ └── strings.po │ ├── resource.language.sw │ └── strings.po │ ├── resource.language.mi_nz │ └── strings.po │ ├── resource.language.nn_no │ └── strings.po │ ├── resource.language.cy_gb │ └── strings.po │ ├── resource.language.af_za │ └── strings.po │ ├── resource.language.mk_mk │ └── strings.po │ └── resource.language.mn_mn │ └── strings.po ├── .vscode ├── extensions.json └── settings.json ├── .github ├── codecov.yaml ├── workflows │ ├── release-drafter.yaml │ ├── build.yaml │ ├── codeql.yaml │ ├── publish.yaml │ ├── test.yaml │ └── create-prepare-release-pr.yaml ├── release-drafter.yml ├── renovate.json ├── ISSUE_TEMPLATE │ ├── issue_template.md │ ├── feature-request.yml │ └── bug_report.yml ├── releasing.md └── tools │ ├── reformat_changelog.py │ └── run_black.sh ├── .git-blame-ignore-revs ├── CONTRIBUTING.md ├── tox.ini ├── requirements-test.txt ├── default.py ├── context.py ├── context_play.py ├── .editorconfig ├── release.yaml ├── .gitignore ├── .devcontainer └── Python │ └── devcontainer.json ├── .pre-commit-config.yaml ├── service.py ├── .build └── template.xml └── README.md /.env: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jellyfin_kodi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typings/jellyfin_kodi/helper/utils.pyi: -------------------------------------------------------------------------------- 1 | def kodi_version(self) -> int: ... 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | warn_unused_configs = True 4 | files = . 5 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements-test.txt 2 | 3 | pre-commit >= 3.7.1 4 | black >= 24.4.2 5 | -------------------------------------------------------------------------------- /resources/fanart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/fanart.png -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/icon.png -------------------------------------------------------------------------------- /resources/skins/default/media/white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/white.png -------------------------------------------------------------------------------- /resources/skins/default/media/wifi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/wifi.png -------------------------------------------------------------------------------- /resources/skins/default/media/network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/network.png -------------------------------------------------------------------------------- /resources/skins/default/media/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/spinner.gif -------------------------------------------------------------------------------- /resources/skins/default/media/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/logo-white.png -------------------------------------------------------------------------------- /resources/skins/default/media/user_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/user_image.png -------------------------------------------------------------------------------- /resources/skins/default/media/dialogs/menu_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/dialogs/menu_top.png -------------------------------------------------------------------------------- /resources/skins/default/media/dialogs/menu_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/dialogs/menu_back.png -------------------------------------------------------------------------------- /resources/skins/default/media/items/focus_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/items/focus_square.png -------------------------------------------------------------------------------- /resources/skins/default/media/items/logindefault.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/items/logindefault.png -------------------------------------------------------------------------------- /resources/skins/default/media/items/mask_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/items/mask_square.png -------------------------------------------------------------------------------- /resources/skins/default/media/userflyoutdefault.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/userflyoutdefault.png -------------------------------------------------------------------------------- /resources/skins/default/media/dialogs/dialog_back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/dialogs/dialog_back.png -------------------------------------------------------------------------------- /resources/skins/default/media/dialogs/menu_bottom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/dialogs/menu_bottom.png -------------------------------------------------------------------------------- /resources/skins/default/media/items/shadow_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/items/shadow_square.png -------------------------------------------------------------------------------- /resources/skins/default/media/buttons/shadow_smallbutton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jellyfin/jellyfin-kodi/HEAD/resources/skins/default/media/buttons/shadow_smallbutton.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-vscode-remote.remote-containers", 4 | "ms-python.black-formatter", 5 | "ms-python.mypy-type-checker" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /resources/language/resource.language.oc_fr/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "X-Generator: Weblate\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit" -------------------------------------------------------------------------------- /resources/language/resource.language.sw/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "X-Generator: Weblate\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit" -------------------------------------------------------------------------------- /jellyfin_kodi/dialogs/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function, unicode_literals 2 | 3 | from .serverconnect import ServerConnect 4 | from .usersconnect import UsersConnect 5 | from .loginmanual import LoginManual 6 | from .servermanual import ServerManual 7 | -------------------------------------------------------------------------------- /.github/codecov.yaml: -------------------------------------------------------------------------------- 1 | 2 | codecov: 3 | notify: 4 | wait_for_ci: false 5 | 6 | github_checks: 7 | annotations: false 8 | 9 | coverage: 10 | status: 11 | project: 12 | default: 13 | informational: true 14 | patch: 15 | default: 16 | informational: true 17 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Tool: black 2 | 77637622125a187c5b9cbe72b78c8bd3b26f754a 3 | # Fix editorconfig lints 4 | be8333a80c2650c75444281a9b720da438b2b6d0 5 | # Change indentation of XML and JSON files 6 | 7c0e986bd283c764cc16f0c756a03a04e4073ad0 7 | # Tool: black 8 | df4994bef23a1f7175d47ff4632084e153d6077f 9 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/kodi/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function, unicode_literals 2 | 3 | from .kodi import Kodi 4 | from .movies import Movies 5 | from .musicvideos import MusicVideos 6 | from .tvshows import TVShows 7 | from .music import Music 8 | from .artwork import Artwork 9 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/kodi/queries_texture.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function, unicode_literals 2 | 3 | get_cache = """ 4 | SELECT cachedurl 5 | FROM texture 6 | WHERE url = ? 7 | """ 8 | 9 | 10 | delete_cache = """ 11 | DELETE FROM texture 12 | WHERE url = ? 13 | """ 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Thank you for contributing to Jellyfin for Kodi! 4 | 5 | * Make pull requests towards the **master** branch; 6 | * Keep the maximum line length shorter than 100 characters to keep things clean and readable; 7 | * Follow pep8 style as closely as possible: https://www.python.org/dev/peps/pep-0008/ 8 | * Add comments if necessary. 9 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | name: Update release draft 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Update Release Draft 14 | uses: release-drafter/release-drafter@v6.1.0 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} 17 | -------------------------------------------------------------------------------- /jellyfin_kodi/entrypoint/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from ..helper import LazyLogger 7 | from ..jellyfin import Jellyfin 8 | 9 | ################################################################################################# 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 9999 3 | import-order-style = pep8 4 | exclude = .git,.vscode,.venv 5 | extend-ignore = 6 | I202 7 | E203 8 | per-file-ignores = 9 | */__init__.py: F401 10 | tests/test_imports.py: F401 11 | 12 | [pytest] 13 | minversion = 4.6 14 | testpaths = 15 | tests 16 | 17 | [coverage:run] 18 | source = . 19 | omit = 20 | tests/* 21 | build.py 22 | branch = True 23 | command_line = -m pytest --junitxml=test.xml 24 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function, unicode_literals 2 | 3 | from .movies import Movies 4 | from .musicvideos import MusicVideos 5 | from .tvshows import TVShows 6 | from .music import Music 7 | from .obj import Objects 8 | from .actions import Actions 9 | from .actions import PlaylistWorker 10 | from .actions import on_play, on_update, special_listener 11 | from . import utils 12 | 13 | Objects().mapping() 14 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | _extends: jellyfin/jellyfin-meta-plugins 2 | 3 | name-template: "Release $RESOLVED_VERSION" 4 | tag-template: "v$RESOLVED_VERSION" 5 | version-template: "$MAJOR.$MINOR.$PATCH" 6 | 7 | version-resolver: 8 | major: 9 | labels: 10 | - 'major' 11 | minor: 12 | labels: 13 | - 'minor' 14 | patch: 15 | labels: 16 | - 'patch' 17 | default: patch 18 | 19 | template: | 20 | ## :sparkles: What's New 21 | 22 | $CHANGES 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "python.testing.pytestEnabled": true, 4 | "python.analysis.diagnosticMode": "workspace", 5 | "files.associations": { 6 | "requirements-*.txt": "pip-requirements" 7 | }, 8 | "sonarlint.connectedMode.project": { 9 | "connectionId": "jellyfin", 10 | "projectKey": "jellyfin_jellyfin-kodi" 11 | }, 12 | "[python]": { 13 | "editor.defaultFormatter": "ms-python.black-formatter" 14 | }, 15 | "flake8.importStrategy": "fromEnvironment", 16 | "black-formatter.importStrategy": "fromEnvironment", 17 | } 18 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | setuptools >= 44.1.1 # Old setuptools causes script.module.addon.signals to fail installing 2 | python-dateutil >= 2.8.1 3 | requests >= 2.22 4 | PyYAML >= 6.0 5 | 6 | backports.zoneinfo; python_version < "3.9" 7 | tzdata; platform_system == "Windows" 8 | 9 | Kodistubs ~=21.0 10 | 11 | git+https://github.com/ruuk/script.module.addon.signals 12 | 13 | pytest >= 4.6.11 14 | coverage >= 5.2 15 | flake8 >= 3.8 16 | flake8-import-order >= 0.18 17 | websocket-client >= 1.6.4 18 | 19 | types-requests >= 2.31 20 | types-PyYAML >= 6.0 21 | types-python-dateutil >= 2.8.1 22 | types-setuptools >= 44.1.1 23 | 24 | types-Pygments 25 | types-colorama 26 | -------------------------------------------------------------------------------- /default.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from jellyfin_kodi.entrypoint.default import Events 7 | from jellyfin_kodi.helper import LazyLogger 8 | 9 | ################################################################################################# 10 | 11 | LOG = LazyLogger(__name__) 12 | 13 | ################################################################################################# 14 | 15 | 16 | if __name__ == "__main__": 17 | 18 | LOG.debug("--->[ default ]") 19 | 20 | try: 21 | Events() 22 | except Exception as error: 23 | LOG.exception(error) 24 | 25 | LOG.info("---<[ default ]") 26 | -------------------------------------------------------------------------------- /context.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from jellyfin_kodi.entrypoint.context import Context 7 | from jellyfin_kodi.helper import LazyLogger 8 | 9 | ################################################################################################# 10 | 11 | LOG = LazyLogger(__name__) 12 | 13 | ################################################################################################# 14 | 15 | 16 | if __name__ == "__main__": 17 | 18 | LOG.debug("--->[ context ]") 19 | 20 | try: 21 | Context() 22 | except Exception as error: 23 | LOG.exception(error) 24 | 25 | LOG.info("---<[ context ]") 26 | -------------------------------------------------------------------------------- /context_play.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from jellyfin_kodi.entrypoint.context import Context 7 | from jellyfin_kodi.helper import LazyLogger 8 | 9 | ################################################################################################# 10 | 11 | LOG = LazyLogger(__name__) 12 | 13 | ################################################################################################# 14 | 15 | 16 | if __name__ == "__main__": 17 | 18 | LOG.debug("--->[ context ]") 19 | 20 | try: 21 | Context(True) 22 | except Exception as error: 23 | LOG.exception(error) 24 | 25 | LOG.info("---<[ context ]") 26 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from ..helper import JSONRPC 7 | from ..helper import LazyLogger 8 | 9 | ################################################################################################# 10 | 11 | LOG = LazyLogger(__name__) 12 | 13 | ################################################################################################# 14 | 15 | 16 | def get_grouped_set(): 17 | """Get if boxsets should be grouped""" 18 | result = JSONRPC("Settings.GetSettingValue").execute( 19 | {"setting": "videolibrary.groupmoviesets"} 20 | ) 21 | return result.get("result", {}).get("value", False) 22 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:recommended", 4 | ":dependencyDashboard", 5 | ":timezone(Etc/UTC)", 6 | ":preserveSemverRanges" 7 | ], 8 | "internalChecksFilter": "strict", 9 | "rebaseWhen": "conflicted", 10 | "packageRules": [ 11 | { 12 | "description": "Add the ci and github-actions GitHub label to GitHub Action bump PRs", 13 | "matchManagers": [ 14 | "github-actions" 15 | ], 16 | "labels": [ 17 | "ci", 18 | "github-actions" 19 | ] 20 | }, 21 | { 22 | "description": "Add the ci and github-actions GitHub label to GitHub Action bump PRs", 23 | "matchManagers": [ 24 | "pip_requirements" 25 | ], 26 | "labels": [ 27 | "pip", 28 | "dependencies" 29 | ] 30 | } 31 | ], 32 | "pip_requirements": { 33 | "managerFilePatterns": [ 34 | "/requirements(-[a-z0-9]+)?\\.txt$/" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build Jellyfin-Kodi 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v5.0.0 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v6.0.0 19 | with: 20 | python-version: 3.14 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install pyyaml 26 | 27 | - name: Build addon 28 | run: python build.py 29 | 30 | - name: Publish Build Artifact 31 | uses: actions/upload-artifact@v5.0.0 32 | with: 33 | retention-days: 14 34 | name: py3-build-artifact 35 | path: | 36 | *.zip 37 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # With more recent updates Visual Studio 2017 supports EditorConfig files out of the box 2 | # Visual Studio Code needs an extension: https://github.com/editorconfig/editorconfig-vscode 3 | # For emacs, vim, np++ and other editors, see here: https://github.com/editorconfig 4 | ############################### 5 | # Core EditorConfig Options # 6 | ############################### 7 | root = true 8 | 9 | # All files 10 | [*] 11 | indent_style = space 12 | indent_size = 4 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | end_of_line = lf 17 | max_line_length = 9999 18 | 19 | # YAML indentation 20 | [*.{yml,yaml}] 21 | indent_size = 2 22 | 23 | # XML indentation 24 | [*.xml] 25 | indent_size = 2 26 | indent_style = tab 27 | 28 | # JSON indentation 29 | [*.json] 30 | indent_size = 2 31 | indent_style = tab 32 | 33 | 34 | [README.md] 35 | indent_size = 2 36 | -------------------------------------------------------------------------------- /jellyfin_kodi/helper/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import, print_function, unicode_literals 2 | 3 | from .lazylogger import LazyLogger 4 | 5 | from .translate import translate 6 | 7 | from .utils import addon_id 8 | from .utils import window 9 | from .utils import settings 10 | from .utils import kodi_version 11 | from .utils import dialog 12 | from .utils import find 13 | from .utils import event 14 | from .utils import validate 15 | from .utils import validate_bluray_dir 16 | from .utils import validate_dvd_dir 17 | from .utils import values 18 | from .utils import JSONRPC 19 | from .utils import unzip 20 | from .utils import create_id 21 | from .utils import convert_to_local as Local 22 | from .utils import has_attribute 23 | from .utils import set_addon_mode 24 | from .utils import get_filesystem_encoding 25 | 26 | from .wrapper import progress 27 | from .wrapper import stop 28 | from .wrapper import jellyfin_item 29 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yaml: -------------------------------------------------------------------------------- 1 | name: CodeQL Analysis 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | schedule: 11 | - cron: '38 8 * * 6' 12 | 13 | jobs: 14 | analyze: 15 | runs-on: ubuntu-latest 16 | if: ${{ github.repository == 'jellyfin/jellyfin-kodi' }} 17 | strategy: 18 | fail-fast: false 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v5.0.0 22 | 23 | - name: Initialize CodeQL 24 | uses: github/codeql-action/init@v4.31.2 25 | with: 26 | languages: 'python' 27 | queries: +security-and-quality 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v6.0.0 31 | with: 32 | python-version: 3.14 33 | 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v4.31.2 36 | 37 | - name: Perform CodeQL Analysis 38 | uses: github/codeql-action/analyze@v4.31.2 39 | -------------------------------------------------------------------------------- /resources/language/resource.language.mi_nz/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "PO-Revision-Date: 2025-12-10 05:21+0000\n" 4 | "Last-Translator: Veldermon-rbg \n" 5 | "Language-Team: Maori \n" 7 | "Language: mi_nz\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=2; plural=n > 1;\n" 12 | "X-Generator: Weblate 5.14\n" 13 | 14 | msgctxt "#29999" 15 | msgid "Jellyfin for Kodi" 16 | msgstr "Jellyfin mo Kodi" 17 | 18 | msgctxt "#30000" 19 | msgid "Server address" 20 | msgstr "Wāhitau tūmau" 21 | 22 | msgctxt "#30001" 23 | msgid "Server name" 24 | msgstr "Ingoa tūmau" 25 | 26 | msgctxt "#30002" 27 | msgid "Force HTTP playback" 28 | msgstr "Whakahohehia te purei HTTP" 29 | 30 | msgctxt "#30003" 31 | msgid "Login method" 32 | msgstr "Tikanga takiuru" 33 | 34 | msgctxt "#30004" 35 | msgid "Log level" 36 | msgstr "Taumata rangitaki" 37 | 38 | msgctxt "#30016" 39 | msgid "Device name" 40 | msgstr "Ingoa pūrere" 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report [old template] 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To Reproduce** 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | 22 | 23 | **Logs** 24 | 25 | 26 | **Screenshots** 27 | 28 | 29 | **System (please complete the following information):** 30 | 31 | - OS: [e.g. Android, Debian, Windows] 32 | - Jellyfin Version: [e.g. 10.0.1] 33 | - Kodi Version: [e.g. 18.3] 34 | - Addon Version: [e.g. 0.2.1] 35 | - Playback Mode: [e.g. Add-On or Native] 36 | 37 | **Additional context** 38 | 39 | -------------------------------------------------------------------------------- /jellyfin_kodi/helper/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | import warnings 5 | 6 | ################################################################################################# 7 | 8 | 9 | class HTTPException(Exception): 10 | # Jellyfin HTTP exception 11 | def __init__(self, status, message): 12 | warnings.warn( 13 | f"{self.__class__.__name__} will be deprecated.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | self.status = status 18 | self.message = message 19 | 20 | 21 | class LibraryException(Exception): 22 | pass 23 | 24 | 25 | class LibraryExitException(LibraryException): 26 | "Exception raised to propagate application exit." 27 | 28 | 29 | class LibrarySyncLaterException(LibraryException): 30 | "Raised when no libraries are selected for sync." 31 | 32 | 33 | class PathValidationException(Exception): 34 | """ 35 | Replacing generic `Exception` 36 | 37 | TODO: Investigate the usage of this to see if it can be done better. 38 | """ 39 | -------------------------------------------------------------------------------- /release.yaml: -------------------------------------------------------------------------------- 1 | version: '1.1.1' 2 | changelog: |- 3 | Bug Fixes 4 | --------- 5 | + Escape null character in log output (#1055) @oddstr13 6 | + Remove setting of (#1051) @bossanova808 7 | 8 | Code or Repo Maintenance 9 | ------------------------ 10 | + Remove angelblue05 from the plugin provider name (#1054) @oddstr13 11 | + Split and index in a way that gracefully falls back to no split (#1053) @oddstr13 12 | + Misc cleanup and refractoring (#1045) @oddstr13 13 | 14 | Documentation updates 15 | --------------------- 16 | + Remove angelblue05 from the plugin provider name (#1054) @oddstr13 17 | 18 | CI & build changes 19 | ------------------ 20 | + Update github/codeql-action action to v4 (#1050) @[renovate[bot]](https://github.com/apps/renovate) 21 | dependencies: 22 | py3: 23 | - addon: 'xbmc.python' 24 | version: '3.0.0' 25 | - addon: 'script.module.requests' 26 | version: '2.22.0+matrix.1' 27 | - addon: 'script.module.dateutil' 28 | version: '2.8.1+matrix.1' 29 | - addon: 'script.module.addon.signals' 30 | version: '0.0.5+matrix.1' 31 | - addon: 'script.module.websocket' 32 | version: '1.6.4' 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | .mypy_cache/ 54 | flake8.output 55 | test.xml 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | 62 | 63 | __local__/ 64 | machine_guid 65 | Thumbs.db 66 | 67 | .idea/ 68 | .DS_Store 69 | .vscode/* 70 | !.vscode/extensions.json 71 | !.vscode/settings.json 72 | pyinstrument/ 73 | .venv/ 74 | 75 | # Now managed by templates 76 | addon.xml 77 | 78 | *.log 79 | -------------------------------------------------------------------------------- /.devcontainer/Python/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Python", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", 7 | // Features to add to the dev container. More info: https://containers.dev/features. 8 | // "features": {}, 9 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 10 | // "forwardPorts": [], 11 | // Use 'postCreateCommand' to run commands after the container is created. 12 | "postCreateCommand": "pip3 install --user -r requirements-dev.txt", 13 | // Configure tool-specific properties. 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "mikestead.dotenv", 18 | "EditorConfig.EditorConfig", 19 | "GitHub.vscode-pull-request-github", 20 | "hbenl.vscode-test-explorer", 21 | "redhat.vscode-xml", 22 | "ninoseki.vscode-mogami", 23 | "ms-python.black-formatter", 24 | "ms-python.flake8" 25 | ] 26 | } 27 | } 28 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 29 | // "remoteUser": "root" 30 | } 31 | -------------------------------------------------------------------------------- /.github/releasing.md: -------------------------------------------------------------------------------- 1 | # Releasing a new Version via GitHub Actions 2 | 3 | 0. (optional) label the PRs you want to include in this release (if you want to group them in the GH release based on topics). \ 4 | Supported labels can be found in the Release Drafter [config-file](https://github.com/jellyfin/jellyfin-meta-plugins/blob/master/.github/release-drafter.yml) (currently inherited from `jellyfin/jellyfin-meta-plugins`) 5 | 1. ensure you have merged the PRs you want to include in the release and that the so far drafted GitHub release has captured them 6 | 2. Create a `release-prep` PR by manually triggering the 'Create Prepare-Release PR' Workflow from the Actions tab on GitHub 7 | 3. check the newly created `Prepare for release vx.y.z` PR if updated the `release.yaml` properly (update it manually if need be) 8 | 4. merge the `Prepare for release vx.y.z` and let the Actions triggered by doing that finis (should just be a couple of seconds) 9 | 5. FINALLY, trigger the `Publish Jellyfin-Kodi` manually from the Actions tab on GitHub. 10 | 1. this will release the up to that point drafted GitHub Release and tag the default branch accordingly 11 | 2. this will package and deploy `Jellyfin-Kodi` in the new version to the deployment server and trigger the 'kodirepo' script on it 12 | 6. Done, assuming everything ran successfully, you have now successfully published a new version! :tada: 13 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.6.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | - id: no-commit-to-branch 11 | 12 | - repo: https://github.com/editorconfig-checker/editorconfig-checker.python 13 | rev: "2.7.3" 14 | hooks: 15 | - id: editorconfig-checker 16 | exclude: '^(LICENSE\.txt|resources/language/.*\.po)$' 17 | 18 | - repo: https://github.com/psf/black 19 | rev: "24.4.2" 20 | hooks: 21 | - id: black 22 | 23 | - repo: https://github.com/pycqa/flake8 24 | rev: 7.0.0 25 | hooks: 26 | - id: flake8 27 | additional_dependencies: 28 | - flake8-import-order 29 | 30 | # - repo: https://github.com/pre-commit/mirrors-mypy 31 | # rev: v1.9.0 32 | # hooks: 33 | # - id: mypy 34 | # exclude: ^(docs/conf.py|scripts/generate_schema.py)$ 35 | # args: [] 36 | # additional_dependencies: &mypy_deps 37 | # - pytest 38 | # - types-requests >= 2.31 39 | # - types-PyYAML >= 6.0 40 | # - types-python-dateutil >= 2.8.1 41 | # - types-setuptools >= 44.1.1 42 | # - types-Pygments 43 | # - types-colorama 44 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/kodi/musicvideos.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | from ...helper import LazyLogger 7 | 8 | from . import queries as QU 9 | from .kodi import Kodi 10 | 11 | ################################################################################################## 12 | 13 | LOG = LazyLogger(__name__) 14 | 15 | ################################################################################################## 16 | 17 | 18 | class MusicVideos(Kodi): 19 | 20 | def __init__(self, cursor): 21 | 22 | self.cursor = cursor 23 | Kodi.__init__(self) 24 | 25 | def create_entry(self): 26 | self.cursor.execute(QU.create_musicvideo) 27 | 28 | return self.cursor.fetchone()[0] + 1 29 | 30 | def get(self, *args): 31 | 32 | try: 33 | self.cursor.execute(QU.get_musicvideo, args) 34 | 35 | return self.cursor.fetchone()[0] 36 | except TypeError: 37 | return 38 | 39 | def add(self, *args): 40 | self.cursor.execute(QU.add_musicvideo, args) 41 | 42 | def update(self, *args): 43 | self.cursor.execute(QU.update_musicvideo, args) 44 | 45 | def delete(self, kodi_id, file_id): 46 | 47 | self.cursor.execute(QU.delete_musicvideo, (kodi_id,)) 48 | self.cursor.execute(QU.delete_file, (file_id,)) 49 | -------------------------------------------------------------------------------- /jellyfin_kodi/helper/translate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import xbmc 7 | import xbmcaddon 8 | 9 | from . import LazyLogger 10 | 11 | ################################################################################################## 12 | 13 | LOG = LazyLogger(__name__) 14 | 15 | ################################################################################################## 16 | 17 | 18 | def translate(string): 19 | """Get add-on string. Returns in unicode.""" 20 | if not isinstance(string, int): 21 | string = STRINGS[string] 22 | 23 | result = xbmcaddon.Addon("plugin.video.jellyfin").getLocalizedString(string) 24 | 25 | if not result: 26 | result = xbmc.getLocalizedString(string) 27 | 28 | return result 29 | 30 | 31 | STRINGS = { 32 | "addon_name": 29999, 33 | "playback_mode": 30511, 34 | "empty_user": 30613, 35 | "empty_user_pass": 30608, 36 | "empty_server": 30617, 37 | "network_credentials": 30517, 38 | "invalid_auth": 33009, 39 | "addon_mode": 33036, 40 | "native_mode": 33037, 41 | "cancel": 30606, 42 | "username": 30024, 43 | "password": 30602, 44 | "gathering": 33021, 45 | "boxsets": 30185, 46 | "movies": 30302, 47 | "tvshows": 30305, 48 | "fav_movies": 30180, 49 | "fav_tvshows": 30181, 50 | "fav_episodes": 30182, 51 | } 52 | -------------------------------------------------------------------------------- /tests/test_clean_none_dict_values.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from jellyfin_kodi.jellyfin.utils import clean_none_dict_values 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "obj,expected", 8 | [ 9 | (None, None), 10 | ([None, 1, 2, 3, None, 4], [None, 1, 2, 3, None, 4]), 11 | ({"foo": None, "bar": 123}, {"bar": 123}), 12 | ( 13 | { 14 | "dict": { 15 | "empty": None, 16 | "string": "Hello, Woorld!", 17 | }, 18 | "number": 123, 19 | "list": [ 20 | None, 21 | 123, 22 | "foo", 23 | { 24 | "empty": None, 25 | "number": 123, 26 | "string": "foo", 27 | "list": [], 28 | "dict": {}, 29 | }, 30 | ], 31 | }, 32 | { 33 | "dict": { 34 | "string": "Hello, Woorld!", 35 | }, 36 | "number": 123, 37 | "list": [ 38 | None, 39 | 123, 40 | "foo", 41 | { 42 | "number": 123, 43 | "string": "foo", 44 | "list": [], 45 | "dict": {}, 46 | }, 47 | ], 48 | }, 49 | ), 50 | ], 51 | ) 52 | def test_clean_none_dict_values(obj, expected): 53 | assert clean_none_dict_values(obj) == expected 54 | -------------------------------------------------------------------------------- /jellyfin_kodi/jellyfin/utils.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from collections.abc import Iterable, Mapping, MutableMapping 3 | 4 | 5 | def clean_none_dict_values(obj): 6 | """ 7 | Recursively remove keys with a value of None 8 | """ 9 | if not isinstance(obj, Iterable) or isinstance(obj, str): 10 | return obj 11 | 12 | queue = [obj] 13 | 14 | while queue: 15 | item = queue.pop() 16 | 17 | if isinstance(item, Mapping): 18 | mutable = isinstance(item, MutableMapping) 19 | remove = [] 20 | 21 | for key, value in item.items(): 22 | if value is None and mutable: 23 | remove.append(key) 24 | 25 | elif isinstance(value, str): 26 | continue 27 | 28 | elif isinstance(value, Iterable): 29 | queue.append(value) 30 | 31 | if mutable: 32 | # Remove keys with None value 33 | for key in remove: 34 | item.pop(key) 35 | 36 | elif isinstance(item, Iterable): 37 | for value in item: 38 | if value is None or isinstance(value, str): 39 | continue 40 | elif isinstance(value, Iterable): 41 | queue.append(value) 42 | 43 | return obj 44 | 45 | 46 | def sqlite_namedtuple_factory(cursor, row): 47 | """ 48 | Usage: 49 | con.row_factory = namedtuple_factory 50 | 51 | http://peter-hoffmann.com/2010/python-sqlite-namedtuple-factory.html 52 | """ 53 | fields = [col[0] for col in cursor.description] 54 | Row = namedtuple("Row", fields) 55 | return Row(*row) 56 | -------------------------------------------------------------------------------- /typings/jellyfin_kodi/database/jellyfin_db.pyi: -------------------------------------------------------------------------------- 1 | from sqlite3 import Cursor 2 | from typing import Any, List, Optional, NamedTuple 3 | 4 | class ViewRow(NamedTuple): 5 | view_id: str 6 | view_name: str 7 | media_type: str 8 | 9 | class JellyfinDatabase: 10 | cursor: Cursor = ... 11 | def __init__(self, cursor: Cursor) -> None: ... 12 | def get_view(self, *args: Any) -> Optional[ViewRow]: ... 13 | def get_views(self) -> List[ViewRow]: ... 14 | 15 | # def get_item_by_id(self, *args: Any): ... 16 | # def add_reference(self, *args: Any) -> None: ... 17 | # def update_reference(self, *args: Any) -> None: ... 18 | # def update_parent_id(self, *args: Any) -> None: ... 19 | # def get_item_id_by_parent_id(self, *args: Any): ... 20 | # def get_item_by_parent_id(self, *args: Any): ... 21 | # def get_item_by_media_folder(self, *args: Any): ... 22 | # def get_item_by_wild_id(self, item_id: Any): ... 23 | # def get_checksum(self, *args: Any): ... 24 | # def get_item_by_kodi_id(self, *args: Any): ... 25 | # def get_full_item_by_kodi_id(self, *args: Any): ... 26 | # def get_media_by_id(self, *args: Any): ... 27 | # def get_media_by_parent_id(self, *args: Any): ... 28 | # def remove_item(self, *args: Any) -> None: ... 29 | # def remove_items_by_parent_id(self, *args: Any) -> None: ... 30 | # def remove_item_by_kodi_id(self, *args: Any) -> None: ... 31 | # def remove_wild_item(self, item_id: Any) -> None: ... 32 | # def get_view_name(self, item_id: Any): ... 33 | # def add_view(self, *args: Any) -> None: ... 34 | # def remove_view(self, *args: Any) -> None: ... 35 | # def get_views_by_media(self, *args: Any): ... 36 | # def get_items_by_media(self, *args: Any): ... 37 | # def remove_media_by_parent_id(self, *args: Any) -> None: ... 38 | -------------------------------------------------------------------------------- /jellyfin_kodi/dialogs/resume.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import xbmc 7 | import xbmcgui 8 | 9 | from ..helper import LazyLogger 10 | 11 | ################################################################################################## 12 | 13 | LOG = LazyLogger(__name__) 14 | ACTION_PARENT_DIR = 9 15 | ACTION_PREVIOUS_MENU = 10 16 | ACTION_BACK = 92 17 | RESUME = 3010 18 | START_BEGINNING = 3011 19 | 20 | ################################################################################################## 21 | 22 | 23 | class ResumeDialog(xbmcgui.WindowXMLDialog): 24 | 25 | _resume_point = None 26 | selected_option = None 27 | 28 | def __init__(self, *args, **kwargs): 29 | xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) 30 | 31 | def set_resume_point(self, time): 32 | self._resume_point = time 33 | 34 | def is_selected(self): 35 | return self.selected_option is not None 36 | 37 | def get_selected(self): 38 | return self.selected_option 39 | 40 | def onInit(self): 41 | 42 | self.getControl(RESUME).setLabel(self._resume_point) 43 | self.getControl(START_BEGINNING).setLabel(xbmc.getLocalizedString(12021)) 44 | 45 | def onAction(self, action): 46 | 47 | if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU): 48 | self.close() 49 | 50 | def onClick(self, control_id): 51 | 52 | if control_id == RESUME: 53 | self.selected_option = 1 54 | self.close() 55 | 56 | if control_id == START_BEGINNING: 57 | self.selected_option = 0 58 | self.close() 59 | -------------------------------------------------------------------------------- /resources/language/resource.language.nn_no/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "PO-Revision-Date: 2021-11-30 06:05+0000\n" 4 | "Last-Translator: WWWesten \n" 5 | "Language-Team: Norwegian Nynorsk \n" 7 | "Language: nn_no\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=2; plural=n != 1;\n" 12 | "X-Generator: Weblate 4.5.2\n" 13 | 14 | msgctxt "#33167" 15 | msgid "Recently added" 16 | msgstr "Nyleg lagt til" 17 | 18 | msgctxt "#33134" 19 | msgid "Add server" 20 | msgstr "Legg til tenar" 21 | 22 | msgctxt "#33062" 23 | msgid "Add user" 24 | msgstr "Legg til brukar" 25 | 26 | msgctxt "#30605" 27 | msgid "Sign in" 28 | msgstr "Logg inn" 29 | 30 | msgctxt "#30540" 31 | msgid "Manual login" 32 | msgstr "Manuell innlogging" 33 | 34 | msgctxt "#30305" 35 | msgid "TV Shows" 36 | msgstr "TV-seriar" 37 | 38 | msgctxt "#30000" 39 | msgid "Server address" 40 | msgstr "Tenaradresse" 41 | 42 | msgctxt "#33121" 43 | msgid "All" 44 | msgstr "Alle" 45 | 46 | msgctxt "#33109" 47 | msgid "Plugin" 48 | msgstr "Programvaretillegg" 49 | 50 | msgctxt "#33049" 51 | msgid "New" 52 | msgstr "Ny" 53 | 54 | msgctxt "#30616" 55 | msgid "Connect" 56 | msgstr "Kople til" 57 | 58 | msgctxt "#30606" 59 | msgid "Cancel" 60 | msgstr "Avbryt" 61 | 62 | msgctxt "#30602" 63 | msgid "Password" 64 | msgstr "Passord" 65 | 66 | msgctxt "#30539" 67 | msgid "Login" 68 | msgstr "Logg inn" 69 | 70 | msgctxt "#30516" 71 | msgid "Playback" 72 | msgstr "Avspeling" 73 | 74 | msgctxt "#30506" 75 | msgid "Sync" 76 | msgstr "Synkroniser" 77 | 78 | msgctxt "#30408" 79 | msgid "Settings" 80 | msgstr "Innstillingar" 81 | 82 | msgctxt "#30302" 83 | msgid "Movies" 84 | msgstr "Filmar" 85 | 86 | msgctxt "#30022" 87 | msgid "Advanced" 88 | msgstr "Avansert" 89 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Jellyfin-Kodi 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Update Draft 11 | uses: release-drafter/release-drafter@v6.1.0 12 | with: 13 | publish: true 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} 16 | 17 | - name: Checkout repository 18 | uses: actions/checkout@v5.0.0 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v6.0.0 22 | with: 23 | python-version: 3.14 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | python -m pip install pyyaml 29 | 30 | - name: Build addon 31 | run: python build.py 32 | 33 | - name: Publish Build Artifact 34 | uses: actions/upload-artifact@v5.0.0 35 | with: 36 | retention-days: 14 37 | name: py3-build-artifact 38 | path: | 39 | *.zip 40 | 41 | - name: Upload to repo server 42 | uses: burnett01/rsync-deployments@7.1.0 43 | with: 44 | switches: -vrptz 45 | path: '*.zip' 46 | remote_path: /srv/incoming/kodi 47 | remote_host: ${{ secrets.REPO_HOST }} 48 | remote_user: ${{ secrets.REPO_USER }} 49 | remote_key: ${{ secrets.REPO_KEY }} 50 | 51 | - name: Add to Kodi repo and clean up 52 | uses: appleboy/ssh-action@v1.2.3 53 | with: 54 | host: ${{ secrets.REPO_HOST }} 55 | username: ${{ secrets.REPO_USER }} 56 | key: ${{ secrets.REPO_KEY }} 57 | script_stop: true 58 | script: | 59 | sudo /usr/local/bin/kodirepo add /srv/incoming/kodi/plugin.video.jellyfin+py3.zip --datadir /srv/repository/main/client/kodi/py3 60 | rm /srv/incoming/kodi/plugin.video.jellyfin+py3.zip 61 | -------------------------------------------------------------------------------- /jellyfin_kodi/jellyfin/configuration.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | """ This will hold all configs from the client. 5 | Configuration set here will be used for the HTTP client. 6 | """ 7 | 8 | ################################################################################################# 9 | 10 | from ..helper import LazyLogger 11 | 12 | ################################################################################################# 13 | 14 | LOG = LazyLogger(__name__) 15 | DEFAULT_HTTP_MAX_RETRIES = 3 16 | DEFAULT_HTTP_TIMEOUT = 30 17 | 18 | ################################################################################################# 19 | 20 | 21 | class Config(object): 22 | 23 | def __init__(self): 24 | 25 | LOG.debug("Configuration initializing...") 26 | self.data = {} 27 | self.http() 28 | 29 | def app( 30 | self, 31 | name, 32 | version, 33 | device_name, 34 | device_id, 35 | capabilities=None, 36 | device_pixel_ratio=None, 37 | ): 38 | 39 | LOG.debug("Begin app constructor.") 40 | self.data["app.name"] = name 41 | self.data["app.version"] = version 42 | self.data["app.device_name"] = device_name 43 | self.data["app.device_id"] = device_id 44 | self.data["app.capabilities"] = capabilities 45 | self.data["app.device_pixel_ratio"] = device_pixel_ratio 46 | self.data["app.default"] = False 47 | 48 | def auth(self, server, user_id, token=None, ssl=None): 49 | 50 | LOG.debug("Begin auth constructor.") 51 | self.data["auth.server"] = server 52 | self.data["auth.user_id"] = user_id 53 | self.data["auth.token"] = token 54 | self.data["auth.ssl"] = ssl 55 | 56 | def http( 57 | self, 58 | user_agent=None, 59 | max_retries=DEFAULT_HTTP_MAX_RETRIES, 60 | timeout=DEFAULT_HTTP_TIMEOUT, 61 | ): 62 | 63 | LOG.debug("Begin http constructor.") 64 | self.data["http.max_retries"] = max_retries 65 | self.data["http.timeout"] = timeout 66 | self.data["http.user_agent"] = user_agent 67 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a feature or enhancement for the project 3 | labels: enhancement 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for suggesting a new feature! Please take a moment to fill out the following details to help us understand your idea better. 9 | 10 | - type: input 11 | id: short-description 12 | attributes: 13 | label: Short Description 14 | description: A brief summary of the feature you are requesting. 15 | placeholder: "Add a short, descriptive title for your feature request." 16 | validations: 17 | required: true 18 | 19 | - type: textarea 20 | id: user-story 21 | attributes: 22 | label: User Story 23 | description: | 24 | Please provide a user story for the feature. 25 | placeholder: | 26 | "As a [type of user], I want [a goal] so that [benefit]." 27 | 28 | Acceptance Criteria: 29 | [List of measurable criteria to ensure the feature works as intended.] 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | id: why-needed 35 | attributes: 36 | label: Why is this feature needed? 37 | description: Explain why this feature would be beneficial and how it improves the project. 38 | placeholder: "Describe why this feature would be useful." 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: additional-context 44 | attributes: 45 | label: Additional Context 46 | description: Provide any other details or resources (like screenshots, designs, or links) that might help us understand your request. 47 | placeholder: "Add any additional information here." 48 | validations: 49 | required: false 50 | 51 | - type: checkboxes 52 | id: agreement 53 | attributes: 54 | label: Agreement 55 | description: Please confirm the following before submitting. 56 | options: 57 | - label: I have searched the existing issues to ensure this feature has not been requested before. 58 | required: true 59 | - label: I have provided enough detail to explain the value of this feature. 60 | required: true 61 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test Jellyfin-Kodi 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | env: 12 | PR_TRIGGERED: ${{ github.event_name == 'pull_request' && github.repository == 'jellyfin/jellyfin-kodi' }} 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | py_version: ['3.9', '3.11', '3.12', '3.13', '3.14'] 20 | os: [ubuntu-latest, windows-latest] 21 | include: 22 | - py_version: '3.8' 23 | os: windows-latest 24 | 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v5.0.0 29 | 30 | - name: Set up Python ${{ matrix.py_version }} 31 | uses: actions/setup-python@v6.0.0 32 | with: 33 | python-version: ${{ matrix.py_version }} 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | python -m pip install -r requirements-test.txt 39 | 40 | - name: Lint with flake8 41 | run: | 42 | # stop the build if there are Python syntax errors or undefined names 43 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 44 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 45 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --output-file=flake8.output 46 | cat flake8.output 47 | 48 | - name: Run tests and generate coverage 49 | run: | 50 | coverage run 51 | 52 | - name: Generate coverage report 53 | run: | 54 | coverage xml 55 | coverage report 56 | 57 | - name: Upload coverage 58 | uses: codecov/codecov-action@v5.5.1 59 | if: ${{ matrix.py_version == '3.11' }} 60 | 61 | - name: Publish Test Artifact 62 | uses: actions/upload-artifact@v5.0.0 63 | with: 64 | retention-days: 14 65 | name: ${{ matrix.py_version }}-${{ matrix.os }}-test-results 66 | path: | 67 | flake8.output 68 | test.xml 69 | coverage.xml 70 | 71 | lint: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - uses: actions/checkout@v5.0.0 75 | - uses: psf/black@stable 76 | -------------------------------------------------------------------------------- /service.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | import threading 7 | 8 | import xbmc 9 | 10 | from jellyfin_kodi.entrypoint.service import Service 11 | from jellyfin_kodi.helper.utils import settings 12 | from jellyfin_kodi.helper import LazyLogger 13 | 14 | ################################################################################################# 15 | 16 | LOG = LazyLogger(__name__) 17 | DELAY = int(settings("startupDelay") if settings("SyncInstallRunDone.bool") else 4) 18 | 19 | ################################################################################################# 20 | 21 | 22 | class ServiceManager(threading.Thread): 23 | """Service thread. 24 | To allow to restart and reload modules internally. 25 | """ 26 | 27 | exception = None 28 | 29 | def __init__(self): 30 | threading.Thread.__init__(self) 31 | 32 | def run(self): 33 | service = None 34 | 35 | try: 36 | service = Service() 37 | 38 | if DELAY and xbmc.Monitor().waitForAbort(DELAY): 39 | raise Exception("Aborted during startup delay") 40 | 41 | service.service() 42 | except Exception as error: 43 | LOG.exception(error) 44 | 45 | if service is not None: 46 | # TODO: fix this properly as to not match on str() 47 | if "ExitService" not in str(error): 48 | service.shutdown() 49 | 50 | if "RestartService" in str(error): 51 | service.reload_objects() 52 | 53 | self.exception = error 54 | 55 | 56 | if __name__ == "__main__": 57 | LOG.info("-->[ service ]") 58 | LOG.info("Delay startup by %s seconds.", DELAY) 59 | 60 | while True: 61 | if not settings("enableAddon.bool"): 62 | LOG.warning("Jellyfin for Kodi is not enabled.") 63 | 64 | break 65 | 66 | try: 67 | session = ServiceManager() 68 | session.start() 69 | session.join() # Block until the thread exits. 70 | 71 | if "RestartService" in str(session.exception): 72 | continue 73 | 74 | except Exception as error: 75 | """Issue initializing the service.""" 76 | LOG.exception(error) 77 | 78 | break 79 | 80 | LOG.info("--<[ service ]") 81 | -------------------------------------------------------------------------------- /.github/tools/reformat_changelog.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.8 2 | 3 | import argparse 4 | import sys 5 | import re 6 | from typing import Dict, List, Pattern, Union, TypedDict 7 | 8 | from emoji.core import emojize, demojize, replace_emoji 9 | 10 | 11 | ITEM_FORMAT = "+ {title} (#{issue}) @{username}" 12 | OUTPUT_EMOJI = False 13 | 14 | ITEM_PATTERN: Pattern = re.compile( 15 | r"^\s*(?P[-*+])\s*(?P.*?)\s*\(#(?P<issue>[0-9]+)\)\s*@(?P<username>[^\s]*)$" 16 | ) 17 | 18 | 19 | class SectionType(TypedDict): 20 | title: str 21 | items: List[Dict[str, str]] 22 | 23 | 24 | def reformat(item_format: str, output_emoji: bool) -> None: 25 | data = [ 26 | emojize(x.strip(), variant="emoji_type") 27 | for x in sys.stdin.readlines() 28 | if x.strip() 29 | ] 30 | 31 | sections = [] 32 | 33 | section: Union[SectionType, Dict] = {} 34 | for line in data: 35 | if line.startswith("## "): 36 | pass 37 | if line.startswith("### "): 38 | if section: 39 | sections.append(section) 40 | _section: SectionType = { 41 | "title": line.strip("# "), 42 | "items": [], 43 | } 44 | section = _section 45 | 46 | m = ITEM_PATTERN.match(line) 47 | if m: 48 | gd = m.groupdict() 49 | section["items"].append(gd) 50 | 51 | sections.append(section) 52 | 53 | first = True 54 | 55 | for section in sections: 56 | if not section: 57 | continue 58 | if first: 59 | first = False 60 | else: 61 | print() 62 | 63 | title = section["title"] 64 | if not output_emoji: 65 | title = replace_emoji(title).strip() 66 | 67 | print(title) 68 | print("-" * len(title)) 69 | 70 | for item in section["items"]: 71 | formatted_item = item_format.format(**item) 72 | if not output_emoji: 73 | formatted_item = demojize(formatted_item) 74 | print(formatted_item) 75 | 76 | 77 | if __name__ == "__main__": 78 | parser = argparse.ArgumentParser() 79 | parser.add_argument("--format", type=str, default=ITEM_FORMAT) 80 | 81 | parser.add_argument("--no-emoji", dest="emoji", action="store_false") 82 | parser.add_argument("--emoji", dest="emoji", action="store_true") 83 | parser.set_defaults(emoji=OUTPUT_EMOJI) 84 | 85 | args = parser.parse_args() 86 | 87 | reformat(args.format, args.emoji) 88 | -------------------------------------------------------------------------------- /.build/template.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="yes"?> 2 | <addon id="plugin.video.jellyfin" 3 | name="Jellyfin" 4 | version="" 5 | provider-name="Jellyfin Contributors"> 6 | <requires> 7 | </requires> 8 | <extension point="xbmc.python.pluginsource" 9 | library="default.py"> 10 | <provides>video audio image</provides> 11 | </extension> 12 | <extension point="xbmc.service" library="service.py" start="login"> 13 | </extension> 14 | <extension point="kodi.context.item"> 15 | <menu id="kodi.core.main"> 16 | <item library="context.py"> 17 | <label>30401</label> 18 | <visible>[!String.IsEmpty(ListItem.DBID) + !String.IsEqual(ListItem.DBID,-1) | 19 | !String.IsEmpty(ListItem.Property(jellyfinid))] + 20 | !String.IsEmpty(Window(10000).Property(jellyfin_context))</visible> 21 | </item> 22 | <item library="context_play.py"> 23 | <label>30402</label> 24 | <visible>[[!String.IsEmpty(ListItem.DBID) + !String.IsEqual(ListItem.DBID,-1) | 25 | !String.IsEmpty(ListItem.Property(jellyfinid))] + [String.IsEqual(ListItem.DBTYPE,movie) | 26 | String.IsEqual(ListItem.DBTYPE,episode)]] + 27 | !String.IsEmpty(Window(10000).Property(jellyfin_context_transcode))</visible> 28 | </item> 29 | </menu> 30 | </extension> 31 | <extension point="xbmc.addon.metadata"> 32 | <platform>all</platform> 33 | <language>en</language> 34 | <license>GNU GENERAL PUBLIC LICENSE. Version 3, 29 June 2007</license> 35 | <forum>https://forum.jellyfin.org</forum> 36 | <website>https://jellyfin.org/</website> 37 | <source>https://github.com/jellyfin/jellyfin-kodi</source> 38 | <summary lang="en"></summary> 39 | <description lang="en">Welcome to Jellyfin for Kodi! A whole new way to manage and view your media library. The Jellyfin addon for Kodi combines the best of Kodi - ultra smooth navigation, beautiful UIs and playback of any file under the sun, and Jellyfin - the most powerful fully open source multi-client media metadata indexer and server. Jellyfin for Kodi is the absolute best way to enjoy the incredible Kodi playback engine combined with the power of Jellyfin's centralized database. Features: * Direct integration with the Kodi library for native Kodi speed * Instant synchronization with the Jellyfin server * Full support for Movie, TV and Music collections * Jellyfin Server direct stream and transcoding support - use Kodi when you are away from home!</description> 40 | <news> 41 | </news> 42 | <assets> 43 | <icon>resources/icon.png</icon> 44 | <fanart>resources/fanart.png</fanart> 45 | </assets> 46 | </extension> 47 | </addon> 48 | -------------------------------------------------------------------------------- /jellyfin_kodi/jellyfin/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from ..helper import LazyLogger 7 | 8 | from . import api 9 | from .configuration import Config 10 | from .http import HTTP 11 | from .ws_client import WSClient 12 | from .connection_manager import ConnectionManager, CONNECTION_STATE 13 | 14 | ################################################################################################# 15 | 16 | LOG = LazyLogger(__name__) 17 | 18 | ################################################################################################# 19 | 20 | 21 | def callback(message, data): 22 | """Callback function should receive message, data 23 | message: string 24 | data: json dictionary 25 | """ 26 | pass 27 | 28 | 29 | class JellyfinClient(object): 30 | 31 | logged_in = False 32 | 33 | def __init__(self): 34 | LOG.debug("JellyfinClient initializing...") 35 | 36 | self.config = Config() 37 | self.http = HTTP(self) 38 | self.wsc = WSClient(self) 39 | self.auth = ConnectionManager(self) 40 | self.jellyfin = api.API(self.http) 41 | self.callback_ws = callback 42 | self.callback = callback 43 | 44 | def set_credentials(self, credentials=None): 45 | self.auth.credentials.set_credentials(credentials or {}) 46 | 47 | def get_credentials(self): 48 | return self.auth.credentials.get_credentials() 49 | 50 | def authenticate(self, credentials=None, options=None): 51 | 52 | self.set_credentials(credentials or {}) 53 | state = self.auth.connect(options or {}) 54 | 55 | if state["State"] == CONNECTION_STATE["SignedIn"]: 56 | 57 | LOG.info("User is authenticated.") 58 | self.logged_in = True 59 | self.callback("ServerOnline", {"Id": self.auth.server_id}) 60 | 61 | state["Credentials"] = self.get_credentials() 62 | 63 | return state 64 | 65 | def start(self, websocket=False, keep_alive=True): 66 | 67 | if not self.logged_in: 68 | raise ValueError("User is not authenticated.") 69 | 70 | self.http.start_session() 71 | 72 | if keep_alive: 73 | self.http.keep_alive = True 74 | 75 | if websocket: 76 | self.start_wsc() 77 | 78 | def start_wsc(self): 79 | self.wsc.start() 80 | 81 | def stop(self): 82 | 83 | self.wsc.stop_client() 84 | self.http.stop_session() 85 | -------------------------------------------------------------------------------- /.github/workflows/create-prepare-release-pr.yaml: -------------------------------------------------------------------------------- 1 | name: Create Prepare-Release PR 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | create_pr: 8 | name: "Create Pump Version PR" 9 | runs-on: ubuntu-latest 10 | steps: 11 | 12 | - name: Update Draft 13 | uses: release-drafter/release-drafter@v6.1.0 14 | id: draft 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} 17 | 18 | - name: Setup YQ 19 | uses: chrisdickinson/setup-yq@latest 20 | with: 21 | yq-version: v4.9.1 22 | 23 | - name: Checkout repository 24 | uses: actions/checkout@v5.0.0 25 | 26 | - name: Parse Changelog 27 | run: | 28 | pip install emoji 29 | cat << EOF >> cl.md 30 | ${{ steps.draft.outputs.body }} 31 | EOF 32 | TAG="${{ steps.draft.outputs.tag_name }}" 33 | echo "VERSION=${TAG#v}" >> $GITHUB_ENV 34 | echo "YAML_CHANGELOG<<EOF" >> $GITHUB_ENV 35 | cat cl.md | python .github/tools/reformat_changelog.py --no-emoji >> $GITHUB_ENV 36 | echo "EOF" >> $GITHUB_ENV 37 | echo "CHANGELOG<<EOF" >> $GITHUB_ENV 38 | cat cl.md | python .github/tools/reformat_changelog.py --emoji --format='+ #{issue} by @{username}' >> $GITHUB_ENV 39 | echo "EOF" >> $GITHUB_ENV 40 | rm cl.md 41 | 42 | - name: Update release.yaml 43 | run: | 44 | yq eval '.version = env(VERSION) | .changelog = strenv(YAML_CHANGELOG) | .changelog style="literal"' -i release.yaml 45 | 46 | - name: Commit Changes 47 | run: | 48 | git config user.name "jellyfin-bot" 49 | git config user.email "team@jellyfin.org" 50 | 51 | git checkout -b prepare-${{ env.VERSION }} 52 | git commit -am "bump version to ${{ env.VERSION }}" 53 | 54 | if [[ -z "$(git ls-remote --heads origin prepare-${{ env.VERSION }})" ]]; then 55 | git push origin prepare-${{ env.VERSION }} 56 | else 57 | git push -f origin prepare-${{ env.VERSION }} 58 | fi 59 | 60 | - name: Create or Update PR 61 | uses: k3rnels-actions/pr-update@v2.1.0 62 | with: 63 | token: ${{ secrets.JF_BOT_TOKEN }} 64 | pr_title: Prepare for release ${{ steps.draft.outputs.tag_name }} 65 | pr_source: prepare-${{ env.VERSION }} 66 | pr_labels: 'release-prep,skip-changelog' 67 | pr_body: | 68 | :robot: This is a generated PR to bump the `release.yaml` version and update the changelog. 69 | 70 | --- 71 | 72 | ${{ env.CHANGELOG }} 73 | -------------------------------------------------------------------------------- /.github/tools/run_black.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Exit on error, print commands 3 | set -o errexit 4 | set -o xtrace 5 | 6 | # 7 | # Copyright (c) Odd Stråbø, 2024 8 | # File-License: the Unlicense 9 | # 10 | # This is free and unencumbered software released into the public domain. 11 | # 12 | # Anyone is free to copy, modify, publish, use, compile, sell, or 13 | # distribute this software, either in source code form or as a compiled 14 | # binary, for any purpose, commercial or non-commercial, and by any 15 | # means. 16 | # 17 | # In jurisdictions that recognize copyright laws, the author or authors 18 | # of this software dedicate any and all copyright interest in the 19 | # software to the public domain. We make this dedication for the benefit 20 | # of the public at large and to the detriment of our heirs and 21 | # successors. We intend this dedication to be an overt act of 22 | # relinquishment in perpetuity of all present and future rights to this 23 | # software under copyright law. 24 | # 25 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 26 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 27 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 28 | # IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 29 | # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 30 | # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 31 | # OTHER DEALINGS IN THE SOFTWARE. 32 | # 33 | # For more information, please refer to <http://unlicense.org/> 34 | # 35 | 36 | # Ensure we are in the repository root 37 | pushd "$(realpath $(dirname $0)/../..)" 38 | 39 | # Make sure black is installed and up to date 40 | pipx upgrade black 41 | 42 | # Make sure we are on latest master 43 | git checkout master 44 | git fetch upstream master 45 | git pull upstream master --ff-only 46 | 47 | # Create new branch, deleting if existing 48 | git checkout -B black 49 | 50 | # Ensure workdir is clean 51 | git stash --include-untracked 52 | git reset --hard 53 | 54 | # Run black and git add changed files 55 | black . -t py38 -t py39 -t py310 -t py311 -t py312 -t py313 2>&1 | sed -nr 's/^reformatted\s(.*)$/\1/p' | tr '\n' '\0' | xargs -0 git add 56 | 57 | # Commit changes 58 | git commit -m 'Tool black: auto-format Python code' 59 | 60 | # Hide previous commit from blame to avoid clutter 61 | echo "# Tool: black" >>.git-blame-ignore-revs 62 | git rev-parse HEAD >>.git-blame-ignore-revs 63 | git add .git-blame-ignore-revs 64 | git commit -m 'Tool black: ignore blame' 65 | 66 | # Push branch to fork 67 | git push --set-upstream origin black --force 68 | 69 | # Go back to previous directory 70 | popd 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: "Bug Report" 2 | description: "Report an issue with the software." 3 | labels: ["bug"] 4 | body: 5 | - type: textarea 6 | id: summary 7 | attributes: 8 | label: "Summary" 9 | description: "Provide a short description of the issue." 10 | placeholder: "When playing a movie via the «Resume from» context menu, I get the error message «xyz»" 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: steps_to_reproduce 16 | attributes: 17 | label: "Steps to Reproduce" 18 | description: "Provide detailed steps to reproduce the issue." 19 | placeholder: | 20 | 1. Open the app. 21 | 2. Click on a movie. 22 | 3. The app crashes. 23 | validations: 24 | required: true 25 | 26 | - type: input 27 | id: os 28 | attributes: 29 | label: "Operating System" 30 | description: "Specify the operating system you are using." 31 | placeholder: "e.g., Ubuntu 24.04" 32 | 33 | - type: input 34 | id: jellyfin_server_version 35 | attributes: 36 | label: "Jellyfin Server Version" 37 | description: "Specify the version of the Jellyfin server you're using. If you're using an unstable build, please include the commit hash." 38 | placeholder: "e.g., 10.9 or 10.10" 39 | 40 | - type: input 41 | id: kodi_version 42 | attributes: 43 | label: "Kodi Version" 44 | description: "Specify the Kodi version you're using." 45 | placeholder: "e.g., 20.0" 46 | 47 | - type: dropdown 48 | id: addon_mode 49 | attributes: 50 | label: "Addon Mode" 51 | description: "Select how you're using the addon." 52 | options: 53 | - "Please Select..." 54 | - "Addon-mode" 55 | - "Direct-path mode ('native')" 56 | validations: 57 | required: true 58 | 59 | - type: textarea 60 | id: logs 61 | attributes: 62 | label: "Logs" 63 | description: "Please provide the logs." 64 | placeholder: "Paste your Kodi logs here..." 65 | validations: 66 | required: true 67 | 68 | - type: textarea 69 | id: server_logs 70 | attributes: 71 | label: "Server Logs" 72 | description: "Please provide server logs (if relevant)." 73 | placeholder: "Paste your server logs here..." 74 | validations: 75 | required: false 76 | 77 | - type: textarea 78 | id: additional_info 79 | attributes: 80 | label: "Additional Information" 81 | description: "Provide any other relevant information." 82 | placeholder: "Additional context, setup details or screenshots" 83 | validations: 84 | required: false 85 | -------------------------------------------------------------------------------- /jellyfin_kodi/helper/wrapper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | from functools import wraps 5 | 6 | ################################################################################################# 7 | 8 | import xbmcgui 9 | import xbmc 10 | 11 | from . import LazyLogger 12 | 13 | from .utils import window 14 | from .exceptions import LibraryExitException 15 | from .translate import translate 16 | 17 | ################################################################################################# 18 | 19 | LOG = LazyLogger(__name__) 20 | 21 | ################################################################################################# 22 | 23 | 24 | def progress(message=None): 25 | """Will start and close the progress dialog.""" 26 | 27 | def decorator(func): 28 | @wraps(func) 29 | def wrapper(self, item=None, *args, **kwargs): 30 | 31 | dialog = xbmcgui.DialogProgressBG() 32 | 33 | if item and isinstance(item, dict): 34 | 35 | dialog.create( 36 | translate("addon_name"), 37 | "%s %s" % (translate("gathering"), item["Name"]), 38 | ) 39 | LOG.info("Processing %s: %s", item["Name"], item["Id"]) 40 | else: 41 | dialog.create(translate("addon_name"), message) 42 | LOG.info("Processing %s", message) 43 | 44 | if item: 45 | args = (item,) + args 46 | 47 | result = func(self, dialog=dialog, *args, **kwargs) 48 | dialog.close() 49 | 50 | return result 51 | 52 | return wrapper 53 | 54 | return decorator 55 | 56 | 57 | def stop(func): 58 | """Wrapper to catch exceptions and return using catch""" 59 | 60 | @wraps(func) 61 | def wrapper(*args, **kwargs): 62 | 63 | if xbmc.Monitor().waitForAbort(0.00001): 64 | raise LibraryExitException("Kodi aborted, exiting...") 65 | 66 | if window("jellyfin_should_stop.bool"): 67 | LOG.info("exiiiiitttinggg") 68 | raise LibraryExitException("Should stop flag raised, exiting...") 69 | 70 | if not window("jellyfin_online.bool"): 71 | raise LibraryExitException("Jellyfin not online, exiting...") 72 | 73 | return func(*args, **kwargs) 74 | 75 | return wrapper 76 | 77 | 78 | def jellyfin_item(func): 79 | """Wrapper to retrieve the jellyfin_db item.""" 80 | 81 | @wraps(func) 82 | def wrapper(self, item, *args, **kwargs): 83 | e_item = self.jellyfin_db.get_item_by_id( 84 | item["Id"] if isinstance(item, dict) else item 85 | ) 86 | 87 | return func(self, item, e_item=e_item, *args, **kwargs) 88 | 89 | return wrapper 90 | -------------------------------------------------------------------------------- /jellyfin_kodi/jellyfin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from ..helper import has_attribute, LazyLogger 7 | 8 | from .client import JellyfinClient 9 | 10 | ################################################################################################# 11 | 12 | LOG = LazyLogger() 13 | 14 | ################################################################################################# 15 | 16 | 17 | def ensure_client(): 18 | 19 | def decorator(func): 20 | def wrapper(self, *args, **kwargs): 21 | 22 | if self.client.get(self.server_id) is None: 23 | self.construct() 24 | 25 | return func(self, *args, **kwargs) 26 | 27 | return wrapper 28 | 29 | return decorator 30 | 31 | 32 | class Jellyfin(object): 33 | """This is your Jellyfinclient, you can create more than one. The server_id is only a temporary thing 34 | to communicate with the JellyfinClient(). 35 | 36 | from jellyfin_kodi.jellyfin import Jellyfin 37 | 38 | Jellyfin('123456').config.data['app'] 39 | 40 | # Permanent client reference 41 | client = Jellyfin('123456').get_client() 42 | client.config.data['app'] 43 | """ 44 | 45 | # Borg - multiple instances, shared state 46 | _shared_state = {} 47 | client = {} 48 | server_id = "default" 49 | 50 | def __init__(self, server_id=None): 51 | self.__dict__ = self._shared_state 52 | self.server_id = server_id or "default" 53 | 54 | def get_client(self): 55 | # type: () -> JellyfinClient 56 | return self.client[self.server_id] 57 | 58 | def close(self): 59 | 60 | if self.server_id not in self.client: 61 | return 62 | 63 | self.client[self.server_id].stop() 64 | self.client.pop(self.server_id, None) 65 | 66 | LOG.info("---[ STOPPED JELLYFINCLIENT: %s ]---", self.server_id) 67 | 68 | @classmethod 69 | def close_all(cls): 70 | 71 | for client in cls.client: 72 | cls.client[client].stop() 73 | 74 | cls.client = {} 75 | LOG.info("---[ STOPPED ALL JELLYFINCLIENTS ]---") 76 | 77 | @classmethod 78 | def get_active_clients(cls): 79 | return cls.client 80 | 81 | @ensure_client() 82 | def __setattr__(self, name, value): 83 | 84 | if has_attribute(self, name): 85 | return super(Jellyfin, self).__setattr__(name, value) 86 | 87 | setattr(self.client[self.server_id], name, value) 88 | 89 | @ensure_client() 90 | def __getattr__(self, name): 91 | return getattr(self.client[self.server_id], name) 92 | 93 | def construct(self): 94 | 95 | self.client[self.server_id] = JellyfinClient() 96 | 97 | if self.server_id == "default": 98 | LOG.info("---[ START JELLYFINCLIENT ]---") 99 | else: 100 | LOG.info("---[ START JELLYFINCLIENT: %s ]---", self.server_id) 101 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/kodi/artwork.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from ...helper import LazyLogger 7 | 8 | from . import queries as QU 9 | 10 | ################################################################################################## 11 | 12 | LOG = LazyLogger(__name__) 13 | 14 | ################################################################################################## 15 | 16 | 17 | class Artwork(object): 18 | 19 | def __init__(self, cursor): 20 | 21 | self.cursor = cursor 22 | 23 | def update(self, image_url, kodi_id, media, image): 24 | """Update artwork in the video database. 25 | Delete current entry before updating with the new one. 26 | """ 27 | if not image_url or image == "poster" and media in ("song", "artist", "album"): 28 | return 29 | 30 | try: 31 | self.cursor.execute( 32 | QU.get_art, 33 | ( 34 | kodi_id, 35 | media, 36 | image, 37 | ), 38 | ) 39 | url = self.cursor.fetchone()[0] 40 | except TypeError: 41 | 42 | LOG.debug("ADD to kodi_id %s art: %s", kodi_id, image_url) 43 | self.cursor.execute(QU.add_art, (kodi_id, media, image, image_url)) 44 | else: 45 | if url != image_url: 46 | LOG.info("UPDATE to kodi_id %s art: %s", kodi_id, image_url) 47 | self.cursor.execute(QU.update_art, (image_url, kodi_id, media, image)) 48 | 49 | def add(self, artwork, *args): 50 | """Add all artworks.""" 51 | KODI = { 52 | "Primary": ["thumb", "poster"], 53 | "Banner": "banner", 54 | "Logo": "clearlogo", 55 | "Art": "clearart", 56 | "Thumb": "landscape", 57 | "Disc": "discart", 58 | "Backdrop": "fanart", 59 | } 60 | 61 | for art in KODI: 62 | 63 | if art == "Backdrop": 64 | self.cursor.execute(QU.get_backdrops, args + ("fanart%",)) 65 | 66 | if len(self.cursor.fetchall()) > len(artwork["Backdrop"]): 67 | self.cursor.execute(QU.delete_backdrops, args + ("fanart_",)) 68 | 69 | for index, backdrop in enumerate(artwork["Backdrop"]): 70 | 71 | if index: 72 | self.update(*(backdrop,) + args + ("%s%s" % ("fanart", index),)) 73 | else: 74 | self.update(*(backdrop,) + args + ("fanart",)) 75 | 76 | elif art == "Primary": 77 | for kodi_image in KODI["Primary"]: 78 | self.update(*(artwork["Primary"],) + args + (kodi_image,)) 79 | 80 | elif artwork.get(art): 81 | self.update(*(artwork[art],) + args + (KODI[art],)) 82 | 83 | def delete(self, *args): 84 | """Delete artwork from kodi database""" 85 | self.cursor.execute(QU.delete_art, args) 86 | -------------------------------------------------------------------------------- /jellyfin_kodi/dialogs/context.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import os 7 | 8 | import xbmcgui 9 | import xbmcaddon 10 | 11 | from ..helper import window, addon_id 12 | from ..helper import LazyLogger 13 | 14 | ################################################################################################## 15 | 16 | LOG = LazyLogger(__name__) 17 | ACTION_PARENT_DIR = 9 18 | ACTION_PREVIOUS_MENU = 10 19 | ACTION_BACK = 92 20 | ACTION_SELECT_ITEM = 7 21 | ACTION_MOUSE_LEFT_CLICK = 100 22 | LIST = 155 23 | USER_IMAGE = 150 24 | 25 | ################################################################################################## 26 | 27 | 28 | class ContextMenu(xbmcgui.WindowXMLDialog): 29 | 30 | _options = [] 31 | selected_option = None 32 | 33 | def __init__(self, *args, **kwargs): 34 | 35 | xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) 36 | 37 | def set_options(self, options=None): 38 | self._options = options 39 | if options is None: 40 | self._options = [] 41 | 42 | def is_selected(self): 43 | return bool(self.selected_option) 44 | 45 | def get_selected(self): 46 | return self.selected_option 47 | 48 | def onInit(self): 49 | 50 | if window("JellyfinUserImage"): 51 | self.getControl(USER_IMAGE).setImage(window("JellyfinUserImage")) 52 | 53 | LOG.info("options: %s", self._options) 54 | self.list_ = self.getControl(LIST) 55 | 56 | for option in self._options: 57 | self.list_.addItem(self._add_listitem(option)) 58 | 59 | self.setFocus(self.list_) 60 | 61 | def onAction(self, action): 62 | 63 | if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU): 64 | self.close() 65 | 66 | if ( 67 | action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK) 68 | and self.getFocusId() == LIST 69 | ): 70 | 71 | option = self.list_.getSelectedItem() 72 | self.selected_option = option.getLabel() 73 | LOG.info("option selected: %s", self.selected_option) 74 | 75 | self.close() 76 | 77 | def _add_editcontrol(self, x, y, height, width, password=0): 78 | 79 | media = os.path.join( 80 | xbmcaddon.Addon(addon_id()).getAddonInfo("path"), 81 | "resources", 82 | "skins", 83 | "default", 84 | "media", 85 | ) 86 | control = xbmcgui.ControlImage( 87 | 0, 88 | 0, 89 | 0, 90 | 0, 91 | filename=os.path.join(media, "white.png"), 92 | aspectRatio=0, 93 | colorDiffuse="ff111111", 94 | ) 95 | control.setPosition(x, y) 96 | control.setHeight(height) 97 | control.setWidth(width) 98 | 99 | self.addControl(control) 100 | return control 101 | 102 | @classmethod 103 | def _add_listitem(cls, label): 104 | return xbmcgui.ListItem(label) 105 | -------------------------------------------------------------------------------- /jellyfin_kodi/dialogs/usersconnect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import xbmcgui 7 | 8 | from ..helper import LazyLogger 9 | from ..helper.utils import kodi_version 10 | 11 | ################################################################################################## 12 | 13 | LOG = LazyLogger(__name__) 14 | ACTION_PARENT_DIR = 9 15 | ACTION_PREVIOUS_MENU = 10 16 | ACTION_BACK = 92 17 | ACTION_SELECT_ITEM = 7 18 | ACTION_MOUSE_LEFT_CLICK = 100 19 | LIST = 155 20 | MANUAL = 200 21 | CANCEL = 201 22 | 23 | ################################################################################################## 24 | 25 | 26 | class UsersConnect(xbmcgui.WindowXMLDialog): 27 | 28 | _user = None 29 | _manual_login = False 30 | 31 | def __init__(self, *args, **kwargs): 32 | 33 | self.kodi_version = kodi_version() 34 | xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) 35 | 36 | def set_args(self, **kwargs): 37 | # connect_manager, user_image, servers 38 | for key, value in kwargs.items(): 39 | setattr(self, key, value) 40 | 41 | def is_user_selected(self): 42 | return bool(self._user) 43 | 44 | def get_user(self): 45 | return self._user 46 | 47 | def is_manual_login(self): 48 | return self._manual_login 49 | 50 | def onInit(self): 51 | 52 | self.list_ = self.getControl(LIST) 53 | for user in self.users: 54 | user_image = ( 55 | "items/logindefault.png" 56 | if "PrimaryImageTag" not in user 57 | else self._get_user_artwork(user["Id"], "Primary") 58 | ) 59 | self.list_.addItem(self._add_listitem(user["Name"], user["Id"], user_image)) 60 | 61 | self.setFocus(self.list_) 62 | 63 | def _add_listitem(self, label, user_id, user_image): 64 | 65 | item = xbmcgui.ListItem(label) 66 | item.setProperty("id", user_id) 67 | item.setArt({"icon": user_image}) 68 | 69 | return item 70 | 71 | def onAction(self, action): 72 | 73 | if action in (ACTION_BACK, ACTION_PREVIOUS_MENU, ACTION_PARENT_DIR): 74 | self.close() 75 | 76 | if ( 77 | action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK) 78 | and self.getFocusId() == LIST 79 | ): 80 | 81 | user = self.list_.getSelectedItem() 82 | selected_id = user.getProperty("id") 83 | LOG.info("User Id selected: %s", selected_id) 84 | 85 | for user in self.users: 86 | if user["Id"] == selected_id: 87 | self._user = user 88 | break 89 | 90 | self.close() 91 | 92 | def onClick(self, control): 93 | 94 | if control == MANUAL: 95 | self._manual_login = True 96 | self.close() 97 | 98 | elif control == CANCEL: 99 | self.close() 100 | 101 | def _get_user_artwork(self, user_id, item_type): 102 | # Load user information set by UserClient 103 | return "%s/Users/%s/Images/%s?Format=original" % ( 104 | self.server, 105 | user_id, 106 | item_type, 107 | ) 108 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | 5 | def test_import_main_module(): 6 | import jellyfin_kodi # noqa: F401 7 | 8 | 9 | def test_import_client(): 10 | import jellyfin_kodi.client # noqa: F401 11 | 12 | 13 | def test_import_connect(): 14 | import jellyfin_kodi.connect # noqa: F401 15 | 16 | 17 | def test_import_database(): 18 | import jellyfin_kodi.database 19 | import jellyfin_kodi.database.jellyfin_db 20 | import jellyfin_kodi.database.queries # noqa: F401 21 | 22 | 23 | def test_import_dialogs(): 24 | import jellyfin_kodi.dialogs 25 | import jellyfin_kodi.dialogs.context 26 | import jellyfin_kodi.dialogs.loginmanual 27 | import jellyfin_kodi.dialogs.resume 28 | import jellyfin_kodi.dialogs.serverconnect 29 | import jellyfin_kodi.dialogs.servermanual 30 | import jellyfin_kodi.dialogs.usersconnect # noqa: F401 31 | 32 | 33 | def test_import_downloader(): 34 | import jellyfin_kodi.downloader # noqa: F401 35 | 36 | 37 | def test_import_entrypoint(): 38 | import jellyfin_kodi.entrypoint 39 | import jellyfin_kodi.entrypoint.context 40 | 41 | # import jellyfin_kodi.entrypoint.default # FIXME: Messes with sys.argv 42 | import jellyfin_kodi.entrypoint.service # noqa: F401 43 | 44 | 45 | def test_import_full_sync(): 46 | import jellyfin_kodi.full_sync # noqa: F401 47 | 48 | 49 | def test_import_helper(): 50 | import jellyfin_kodi.helper 51 | import jellyfin_kodi.helper.api 52 | import jellyfin_kodi.helper.exceptions 53 | import jellyfin_kodi.helper.lazylogger 54 | import jellyfin_kodi.helper.loghandler 55 | import jellyfin_kodi.helper.playutils 56 | import jellyfin_kodi.helper.translate 57 | import jellyfin_kodi.helper.utils 58 | import jellyfin_kodi.helper.wrapper 59 | import jellyfin_kodi.helper.xmls # noqa: F401 60 | 61 | 62 | def test_import_jellyfin(): 63 | import jellyfin_kodi.jellyfin 64 | import jellyfin_kodi.jellyfin.api 65 | import jellyfin_kodi.jellyfin.client 66 | import jellyfin_kodi.jellyfin.configuration 67 | import jellyfin_kodi.jellyfin.connection_manager 68 | import jellyfin_kodi.jellyfin.credentials 69 | import jellyfin_kodi.jellyfin.http 70 | import jellyfin_kodi.jellyfin.utils 71 | import jellyfin_kodi.jellyfin.ws_client # noqa: F401 72 | 73 | 74 | def test_import_library(): 75 | import jellyfin_kodi.library # noqa: F401 76 | 77 | 78 | def test_import_monitor(): 79 | import jellyfin_kodi.monitor # noqa: F401 80 | 81 | 82 | def test_import_objects(): 83 | import jellyfin_kodi.objects 84 | import jellyfin_kodi.objects.actions 85 | import jellyfin_kodi.objects.kodi 86 | import jellyfin_kodi.objects.kodi.artwork 87 | import jellyfin_kodi.objects.kodi.kodi 88 | import jellyfin_kodi.objects.kodi.movies 89 | import jellyfin_kodi.objects.kodi.music 90 | import jellyfin_kodi.objects.kodi.musicvideos 91 | import jellyfin_kodi.objects.kodi.queries 92 | import jellyfin_kodi.objects.kodi.queries_music 93 | import jellyfin_kodi.objects.kodi.queries_texture 94 | import jellyfin_kodi.objects.kodi.tvshows 95 | import jellyfin_kodi.objects.movies 96 | import jellyfin_kodi.objects.music 97 | import jellyfin_kodi.objects.musicvideos 98 | import jellyfin_kodi.objects.obj 99 | import jellyfin_kodi.objects.tvshows 100 | import jellyfin_kodi.objects.utils # noqa: F401 101 | 102 | 103 | def test_import_player(): 104 | import jellyfin_kodi.player # noqa: F401 105 | 106 | 107 | def test_import_views(): 108 | import jellyfin_kodi.views # noqa: F401 109 | -------------------------------------------------------------------------------- /resources/skins/default/1080i/script-jellyfin-context.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <window> 3 | <defaultcontrol always="true">155</defaultcontrol> 4 | <controls> 5 | <control type="group"> 6 | <control type="image"> 7 | <top>0</top> 8 | <bottom>0</bottom> 9 | <left>0</left> 10 | <right>0</right> 11 | <texture colordiffuse="CC000000">white.png</texture> 12 | <aspectratio>stretch</aspectratio> 13 | <animation effect="fade" end="100" time="200">WindowOpen</animation> 14 | <animation effect="fade" start="100" end="0" time="200">WindowClose</animation> 15 | </control> 16 | <control type="group"> 17 | <animation type="WindowOpen" reversible="false"> 18 | <effect type="zoom" start="80" end="100" center="960,540" delay="160" tween="circle" 19 | easin="out" time="240" /> 20 | <effect type="fade" delay="160" end="100" time="240" /> 21 | </animation> 22 | <animation type="WindowClose" reversible="false"> 23 | <effect type="zoom" start="100" end="80" center="960,540" easing="in" tween="circle" 24 | easin="out" time="240" /> 25 | <effect type="fade" start="100" end="0" time="240" /> 26 | </animation> 27 | <centerleft>50%</centerleft> 28 | <centertop>50%</centertop> 29 | <width>500</width> 30 | <height>280</height> 31 | <control type="group"> 32 | <top>-30</top> 33 | <control type="image"> 34 | <left>20</left> 35 | <width>100%</width> 36 | <height>25</height> 37 | <texture>logo-white.png</texture> 38 | <aspectratio align="left">keep</aspectratio> 39 | </control> 40 | <control type="image" id="150"> 41 | <right>20</right> 42 | <width>100%</width> 43 | <height>25</height> 44 | <aspectratio align="right">keep</aspectratio> 45 | <texture diffuse="user_image.png">userflyoutdefault.png</texture> 46 | </control> 47 | </control> 48 | <control type="image"> 49 | <width>100%</width> 50 | <height>280</height> 51 | <texture colordiffuse="ff222326" border="10">dialogs/dialog_back.png</texture> 52 | </control> 53 | <control type="list" id="155"> 54 | <centerleft>50%</centerleft> 55 | <top>10</top> 56 | <width>490</width> 57 | <height>260</height> 58 | <onup>noop</onup> 59 | <onleft>close</onleft> 60 | <onright>close</onright> 61 | <ondown>noop</ondown> 62 | <itemlayout width="490" height="65"> 63 | <control type="label"> 64 | <width>100%</width> 65 | <height>65</height> 66 | <aligny>center</aligny> 67 | <textoffsetx>20</textoffsetx> 68 | <font>font13</font> 69 | <textcolor>ffe1e1e1</textcolor> 70 | <shadowcolor>66000000</shadowcolor> 71 | <label>$INFO[ListItem.Label]</label> 72 | </control> 73 | </itemlayout> 74 | <focusedlayout width="490" height="65"> 75 | <control type="image"> 76 | <width>100%</width> 77 | <height>65</height> 78 | <texture colordiffuse="ff222326">white.png</texture> 79 | <visible>!Control.HasFocus(155)</visible> 80 | </control> 81 | <control type="image"> 82 | <width>100%</width> 83 | <height>65</height> 84 | <texture colordiffuse="ff303034">white.png</texture> 85 | <visible>Control.HasFocus(155)</visible> 86 | </control> 87 | <control type="label"> 88 | <width>100%</width> 89 | <height>65</height> 90 | <aligny>center</aligny> 91 | <textoffsetx>20</textoffsetx> 92 | <font>font13</font> 93 | <scroll>true</scroll> 94 | <textcolor>ffe1e1e1</textcolor> 95 | <shadowcolor>66000000</shadowcolor> 96 | <label>$INFO[ListItem.Label]</label> 97 | </control> 98 | </focusedlayout> 99 | </control> 100 | </control> 101 | </control> 102 | </controls> 103 | </window> 104 | -------------------------------------------------------------------------------- /jellyfin_kodi/helper/lazylogger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | from typing import TYPE_CHECKING 5 | 6 | 7 | class LazyLogger(object): 8 | """ 9 | `helper.loghandler.getLogger()` is used everywhere. 10 | This class helps to avoid import errors. 11 | """ 12 | 13 | __logger = None 14 | __logger_name = None 15 | 16 | def __init__(self, logger_name=None): 17 | self.__logger_name = logger_name 18 | 19 | def __getattr__(self, name): 20 | if self.__logger is None: 21 | from .loghandler import getLogger 22 | 23 | self.__logger = getLogger(self.__logger_name) 24 | return getattr(self.__logger, name) 25 | 26 | ##################################################################### 27 | # Following are stubs of methods provided by `logging.Logger`. # 28 | # Please ensure any actually functional code is above this comment. # 29 | ##################################################################### 30 | 31 | if TYPE_CHECKING: 32 | 33 | def setLevel(self, level): 34 | """ 35 | Set the logging level of this logger. level must be an int or a str. 36 | """ 37 | ... 38 | 39 | def debug(self, msg, *args, **kwargs): 40 | """ 41 | Log 'msg % args' with severity 'DEBUG'. 42 | 43 | To pass exception information, use the keyword argument exc_info with 44 | a true value, e.g. 45 | 46 | logger.debug("Houston, we have a %s", "thorny problem", exc_info=1) 47 | """ 48 | ... 49 | 50 | def info(self, msg, *args, **kwargs): 51 | """ 52 | Log 'msg % args' with severity 'INFO'. 53 | 54 | To pass exception information, use the keyword argument exc_info with 55 | a true value, e.g. 56 | 57 | logger.info("Houston, we have a %s", "interesting problem", exc_info=1) 58 | """ 59 | ... 60 | 61 | def warning(self, msg, *args, **kwargs): 62 | """ 63 | Log 'msg % args' with severity 'WARNING'. 64 | 65 | To pass exception information, use the keyword argument exc_info with 66 | a true value, e.g. 67 | 68 | logger.warning("Houston, we have a %s", "bit of a problem", exc_info=1) 69 | """ 70 | ... 71 | 72 | def error(self, msg, *args, **kwargs): 73 | """ 74 | Log 'msg % args' with severity 'ERROR'. 75 | 76 | To pass exception information, use the keyword argument exc_info with 77 | a true value, e.g. 78 | 79 | logger.error("Houston, we have a %s", "major problem", exc_info=1) 80 | """ 81 | ... 82 | 83 | def exception(self, msg, *args, exc_info=True, **kwargs): 84 | """ 85 | Convenience method for logging an ERROR with exception information. 86 | """ 87 | ... 88 | 89 | def critical(self, msg, *args, **kwargs): 90 | """ 91 | Log 'msg % args' with severity 'CRITICAL'. 92 | 93 | To pass exception information, use the keyword argument exc_info with 94 | a true value, e.g. 95 | 96 | logger.critical("Houston, we have a %s", "major disaster", exc_info=1) 97 | """ 98 | ... 99 | 100 | def log(self, level, msg, *args, **kwargs): 101 | """ 102 | Log 'msg % args' with the integer severity 'level'. 103 | 104 | To pass exception information, use the keyword argument exc_info with 105 | a true value, e.g. 106 | 107 | logger.log(level, "We have a %s", "mysterious problem", exc_info=1) 108 | """ 109 | ... 110 | -------------------------------------------------------------------------------- /resources/language/resource.language.cy_gb/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "PO-Revision-Date: 2023-05-07 21:39+0000\n" 4 | "Last-Translator: Brett Healey <Bearach.goll@gmail.com>\n" 5 | "Language-Team: Welsh <https://translate.jellyfin.org/projects/jellyfin/" 6 | "jellyfin-kodi/cy/>\n" 7 | "Language: cy_gb\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=6; plural=(n==0) ? 0 : (n==1) ? 1 : (n==2) ? 2 : " 12 | "(n==3) ? 3 :(n==6) ? 4 : 5;\n" 13 | "X-Generator: Weblate 4.14.1\n" 14 | 15 | msgctxt "#30002" 16 | msgid "Force HTTP playback" 17 | msgstr "Gorfodi chwarae HTTP" 18 | 19 | msgctxt "#30016" 20 | msgid "Device name" 21 | msgstr "Enw dyfais" 22 | 23 | msgctxt "#30114" 24 | msgid "Offer delete after playback" 25 | msgstr "Cynnig dileu ar ôl chwarae" 26 | 27 | msgctxt "#30000" 28 | msgid "Server address" 29 | msgstr "Cyfeiriad gweinydd" 30 | 31 | msgctxt "#30001" 32 | msgid "Server name" 33 | msgstr "Enw gweinydd" 34 | 35 | msgctxt "#30003" 36 | msgid "Login method" 37 | msgstr "Dull mewngofnodi" 38 | 39 | msgctxt "#30004" 40 | msgid "Log level" 41 | msgstr "Lefel logio" 42 | 43 | msgctxt "#30022" 44 | msgid "Advanced" 45 | msgstr "Uwch" 46 | 47 | msgctxt "#30024" 48 | msgid "Username" 49 | msgstr "Enw defnyddiwr" 50 | 51 | msgctxt "#30091" 52 | msgid "Confirm file deletion" 53 | msgstr "Cadarnhau dileu ffeil" 54 | 55 | msgctxt "#30115" 56 | msgid "For Episodes" 57 | msgstr "Am pennoddau" 58 | 59 | msgctxt "#30116" 60 | msgid "For Movies" 61 | msgstr "Am ffilmiau" 62 | 63 | msgctxt "#30157" 64 | msgid "Enable enhanced artwork (i.e. cover art)" 65 | msgstr "Galluogi gwaith celf gwell (h.y. celf glawr)" 66 | 67 | msgctxt "#30160" 68 | msgid "Max stream bitrate" 69 | msgstr "Cyfradd didau ffrwd uchaf" 70 | 71 | msgctxt "#30170" 72 | msgid "Recently Added TV Shows" 73 | msgstr "Sioeau Teledu a Ychwanegwyd yn Ddiweddar" 74 | 75 | msgctxt "#30177" 76 | msgid "In Progress Movies" 77 | msgstr "Ffilmiau ar y Gweill" 78 | 79 | msgctxt "#30179" 80 | msgid "Next Episodes" 81 | msgstr "Penodau nesaf" 82 | 83 | msgctxt "#30185" 84 | msgid "Boxsets" 85 | msgstr "Bocsys" 86 | 87 | msgctxt "#30229" 88 | msgid "Random Items" 89 | msgstr "Eitemau ar Hap" 90 | 91 | msgctxt "#29999" 92 | msgid "Jellyfin for Kodi" 93 | msgstr "Jellyfin ar gyfer Kodi" 94 | 95 | msgctxt "#30161" 96 | msgid "Preferred video codec" 97 | msgstr "Codec fideo a ffefrir" 98 | 99 | msgctxt "#30162" 100 | msgid "Preferred audio codec" 101 | msgstr "Y codec sain a ffefrir" 102 | 103 | msgctxt "#30163" 104 | msgid "Audio bitrate" 105 | msgstr "Cyfradd did sain" 106 | 107 | msgctxt "#30164" 108 | msgid "Audio max channels" 109 | msgstr "Sianeli mwyaf sain" 110 | 111 | msgctxt "#30165" 112 | msgid "Allow burned subtitles" 113 | msgstr "Caniatáu isdeitlau wedi'u llosgi" 114 | 115 | msgctxt "#30171" 116 | msgid "In Progress TV Shows" 117 | msgstr "Sioeau Teledu ar y Gweill" 118 | 119 | msgctxt "#30174" 120 | msgid "Recently Added Movies" 121 | msgstr "Ffilmiau a Ychwanegwyd yn Ddiweddar" 122 | 123 | msgctxt "#30175" 124 | msgid "Recently Added Episodes" 125 | msgstr "Pennodau a Ychwanegwyd yn Ddiweddar" 126 | 127 | msgctxt "#30178" 128 | msgid "In Progress Episodes" 129 | msgstr "Penodau ar Gynnydd" 130 | 131 | msgctxt "#30180" 132 | msgid "Favorite Movies" 133 | msgstr "Hoff Ffilmiau" 134 | 135 | msgctxt "#30181" 136 | msgid "Favorite Shows" 137 | msgstr "Hoff Sioeau" 138 | 139 | msgctxt "#30182" 140 | msgid "Favorite Episodes" 141 | msgstr "Hoff Benodau" 142 | 143 | msgctxt "#30189" 144 | msgid "Unwatched Movies" 145 | msgstr "Ffilmiau Heb eu Gwylio" 146 | 147 | msgctxt "#30230" 148 | msgid "Recommended Items" 149 | msgstr "Eitemau a Argymhellir" 150 | 151 | msgctxt "#30235" 152 | msgid "Interface" 153 | msgstr "Rhyngwyneb" 154 | 155 | msgctxt "#30239" 156 | msgid "Reset local Kodi database" 157 | msgstr "Ailosod cronfa ddata Kodi lleol" 158 | -------------------------------------------------------------------------------- /jellyfin_kodi/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | import os 7 | 8 | import xbmc 9 | import xbmcaddon 10 | import xbmcvfs 11 | 12 | from .helper import translate, window, settings, addon_id, dialog, LazyLogger 13 | from .helper.utils import create_id, translate_path 14 | 15 | ################################################################################################## 16 | 17 | LOG = LazyLogger(__name__) 18 | 19 | ################################################################################################## 20 | 21 | 22 | def get_addon_name(): 23 | """Used for logging.""" 24 | return xbmcaddon.Addon(addon_id()).getAddonInfo("name").upper() 25 | 26 | 27 | def get_version(): 28 | return xbmcaddon.Addon(addon_id()).getAddonInfo("version") 29 | 30 | 31 | def get_platform(): 32 | 33 | if xbmc.getCondVisibility("system.platform.osx"): 34 | return "OSX" 35 | elif xbmc.getCondVisibility("System.HasAddon(service.coreelec.settings)"): 36 | return "CoreElec" 37 | elif xbmc.getCondVisibility("System.HasAddon(service.libreelec.settings)"): 38 | return "LibreElec" 39 | elif xbmc.getCondVisibility("System.HasAddon(service.osmc.settings)"): 40 | return "OSMC" 41 | elif xbmc.getCondVisibility("system.platform.atv2"): 42 | return "ATV2" 43 | elif xbmc.getCondVisibility("system.platform.ios"): 44 | return "iOS" 45 | elif xbmc.getCondVisibility("system.platform.windows"): 46 | return "Windows" 47 | elif xbmc.getCondVisibility("system.platform.android"): 48 | return "Linux/Android" 49 | elif xbmc.getCondVisibility("system.platform.linux.raspberrypi"): 50 | return "Linux/RPi" 51 | elif xbmc.getCondVisibility("system.platform.linux"): 52 | return "Linux" 53 | else: 54 | return "Unknown" 55 | 56 | 57 | def get_device_name(): 58 | """Detect the device name. If deviceNameOpt, then 59 | use the device name in the add-on settings. 60 | Otherwise, fallback to the Kodi device name. 61 | """ 62 | if not settings("deviceNameOpt.bool"): 63 | device_name = xbmc.getInfoLabel("System.FriendlyName") 64 | else: 65 | device_name = settings("deviceName") 66 | 67 | return device_name 68 | 69 | 70 | def get_device_id(reset=False): 71 | """Return the device_id if already loaded. 72 | It will load from jellyfin_guid file. If it's a fresh 73 | setup, it will generate a new GUID to uniquely 74 | identify the setup for all users. 75 | 76 | window prop: jellyfin_deviceId 77 | """ 78 | client_id = window("jellyfin_deviceId") 79 | 80 | if client_id: 81 | return client_id 82 | 83 | directory = translate_path("special://profile/addon_data/plugin.video.jellyfin/") 84 | 85 | if not xbmcvfs.exists(directory): 86 | xbmcvfs.mkdir(directory) 87 | 88 | jellyfin_guid = os.path.join(directory, "jellyfin_guid") 89 | file_guid = xbmcvfs.File(jellyfin_guid) 90 | client_id = file_guid.read() 91 | 92 | if not client_id or reset: 93 | LOG.debug("Generating a new GUID.") 94 | 95 | client_id = str(create_id()) 96 | file_guid = xbmcvfs.File(jellyfin_guid, "w") 97 | file_guid.write(client_id) 98 | 99 | file_guid.close() 100 | LOG.debug("DeviceId loaded: %s", client_id) 101 | window("jellyfin_deviceId", value=client_id) 102 | 103 | return client_id 104 | 105 | 106 | def reset_device_id(): 107 | 108 | window("jellyfin_deviceId", clear=True) 109 | get_device_id(True) 110 | dialog("ok", "{jellyfin}", translate(33033)) 111 | xbmc.executebuiltin("RestartApp") 112 | 113 | 114 | def get_info(): 115 | return { 116 | "DeviceName": get_device_name(), 117 | "Version": get_version(), 118 | "DeviceId": get_device_id(), 119 | } 120 | -------------------------------------------------------------------------------- /tests/test_translations.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | 7 | BASEDIR = Path(__file__).parent.parent 8 | TRANSLATIONS_BASE = BASEDIR / "resources" / "language" 9 | 10 | TRANSLATION_DIRS = os.listdir(TRANSLATIONS_BASE) 11 | 12 | KNOWN_KODI_LANGUAGE_CODES = { 13 | "en_gb", # Base language 14 | "es_es", 15 | "ta_in", 16 | "fr_ca", 17 | "en_nz", 18 | "eo", 19 | "fa_ir", 20 | "szl", 21 | "nb_no", 22 | "vi_vn", 23 | "sk_sk", 24 | "mn_mn", 25 | "ru_ru", 26 | "fi_fi", 27 | "nl_nl", 28 | "lv_lv", 29 | "hi_in", 30 | "pt_pt", 31 | "de_de", 32 | "it_it", 33 | "ja_jp", 34 | "ms_my", 35 | "sr_rs@latin", 36 | "is_is", 37 | "pl_pl", 38 | "fo_fo", 39 | "si_lk", 40 | "es_mx", 41 | "hu_hu", 42 | "pt_br", 43 | "en_us", 44 | "ar_sa", 45 | "tg_tj", 46 | "el_gr", 47 | "ast_es", 48 | "es_ar", 49 | "be_by", 50 | "af_za", 51 | "sv_se", 52 | "am_et", 53 | "my_mm", 54 | "fr_fr", 55 | "sq_al", 56 | "et_ee", 57 | "ga_ie", 58 | "ko_kr", 59 | "id_id", 60 | "gl_es", 61 | "bg_bg", 62 | "cs_cz", 63 | "eu_es", 64 | "fa_af", 65 | "ro_ro", 66 | "th_th", 67 | "zh_tw", 68 | "mi", 69 | "mt_mt", 70 | "ml_in", 71 | "uk_ua", 72 | "da_dk", 73 | "cy_gb", 74 | "az_az", 75 | "he_il", 76 | "kn_in", 77 | "zh_cn", 78 | "hy_am", 79 | "te_in", 80 | "hr_hr", 81 | "ca_es", 82 | "tr_tr", 83 | "bs_ba", 84 | "sr_rs", 85 | "uz_uz", 86 | "lt_lt", 87 | "os_os", 88 | "fil", 89 | "sl_si", 90 | "en_au", 91 | "mk_mk", 92 | } 93 | 94 | ADDITIONAL_LANGUAGE_EXCEPTIONS = { 95 | "es_419", # Spanish, Latin America 96 | } 97 | 98 | 99 | @pytest.mark.parametrize( 100 | "dir", 101 | TRANSLATION_DIRS, 102 | ) 103 | def test_langcode_lower(dir: str): 104 | _, code = dir.rsplit(".", 1) 105 | assert code.islower() 106 | 107 | 108 | @pytest.mark.parametrize( 109 | "dir", 110 | TRANSLATION_DIRS, 111 | ) 112 | def test_langcode_country(dir: str): 113 | _, code = dir.rsplit(".", 1) 114 | 115 | if ( 116 | code not in KNOWN_KODI_LANGUAGE_CODES 117 | and code not in ADDITIONAL_LANGUAGE_EXCEPTIONS 118 | ): 119 | for c in KNOWN_KODI_LANGUAGE_CODES: 120 | if code in c: 121 | print(f"Maybe {code} should be {c}?") 122 | 123 | assert len(code) == 5 124 | assert "_" in code 125 | lang, country = code.split("_", 1) 126 | assert lang.isalpha() 127 | assert country.isalpha() 128 | 129 | 130 | def parse_language_headers(file): 131 | with open(file, "rt", encoding="utf-8") as fh: 132 | sections = {} 133 | section = None 134 | for line in fh: 135 | line = line.strip() 136 | if not line: 137 | break 138 | 139 | if line[0] == "#": 140 | continue 141 | elif line[0] == '"': 142 | sections[section].append(line) 143 | else: 144 | section, value = line.split(None, 1) 145 | sections[section] = [value] 146 | 147 | for section in sections.keys(): 148 | sections[section] = "".join( 149 | [ 150 | x[1:-1].replace('\\"', '"').replace("\\n", "\n") 151 | for x in sections[section] 152 | ] 153 | ) 154 | 155 | d = {"file": file} 156 | for line in sections["msgstr"].split("\n"): 157 | line = line.strip() 158 | if not line: 159 | continue 160 | key, value = line.split(":", 1) 161 | d[key] = value.strip() 162 | 163 | return d 164 | 165 | 166 | @pytest.mark.parametrize( 167 | "dir", 168 | TRANSLATION_DIRS, 169 | ) 170 | def test_langcode_matches(dir: str): 171 | _, code = dir.rsplit(".", 1) 172 | 173 | headers = parse_language_headers(TRANSLATIONS_BASE / dir / "strings.po") 174 | 175 | assert "Language" in headers 176 | assert headers["Language"] == code 177 | -------------------------------------------------------------------------------- /jellyfin_kodi/dialogs/serverconnect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import xbmc 7 | import xbmcgui 8 | 9 | from ..helper import translate 10 | from ..jellyfin.connection_manager import CONNECTION_STATE 11 | from ..helper import LazyLogger 12 | 13 | ################################################################################################## 14 | 15 | LOG = LazyLogger(__name__) 16 | ACTION_PARENT_DIR = 9 17 | ACTION_PREVIOUS_MENU = 10 18 | ACTION_BACK = 92 19 | ACTION_SELECT_ITEM = 7 20 | ACTION_MOUSE_LEFT_CLICK = 100 21 | USER_IMAGE = 150 22 | LIST = 155 23 | CANCEL = 201 24 | MESSAGE_BOX = 202 25 | MESSAGE = 203 26 | BUSY = 204 27 | MANUAL_SERVER = 206 28 | 29 | ################################################################################################## 30 | 31 | 32 | class ServerConnect(xbmcgui.WindowXMLDialog): 33 | 34 | user_image = None 35 | servers = [] 36 | 37 | _selected_server = None 38 | _connect_login = False 39 | _manual_server = False 40 | 41 | def __init__(self, *args, **kwargs): 42 | 43 | xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) 44 | 45 | def set_args(self, **kwargs): 46 | # connect_manager, user_image, servers 47 | for key, value in kwargs.items(): 48 | setattr(self, key, value) 49 | 50 | def is_server_selected(self): 51 | return bool(self._selected_server) 52 | 53 | def get_server(self): 54 | return self._selected_server 55 | 56 | def is_manual_server(self): 57 | return self._manual_server 58 | 59 | def onInit(self): 60 | 61 | self.message = self.getControl(MESSAGE) 62 | self.message_box = self.getControl(MESSAGE_BOX) 63 | self.busy = self.getControl(BUSY) 64 | self.list_ = self.getControl(LIST) 65 | 66 | for server in self.servers: 67 | server_type = "wifi" if server.get("ExchangeToken") else "network" 68 | self.list_.addItem( 69 | self._add_listitem(server["Name"], server["Id"], server_type) 70 | ) 71 | 72 | if self.user_image is not None: 73 | self.getControl(USER_IMAGE).setImage(self.user_image) 74 | 75 | if self.servers: 76 | self.setFocus(self.list_) 77 | 78 | @classmethod 79 | def _add_listitem(cls, label, server_id, server_type): 80 | 81 | item = xbmcgui.ListItem(label) 82 | item.setProperty("id", server_id) 83 | item.setProperty("server_type", server_type) 84 | 85 | return item 86 | 87 | def onAction(self, action): 88 | 89 | if action in (ACTION_BACK, ACTION_PREVIOUS_MENU, ACTION_PARENT_DIR): 90 | self.close() 91 | 92 | if ( 93 | action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK) 94 | and self.getFocusId() == LIST 95 | ): 96 | 97 | server = self.list_.getSelectedItem() 98 | selected_id = server.getProperty("id") 99 | LOG.info("Server Id selected: %s", selected_id) 100 | 101 | if self._connect_server(selected_id): 102 | self.message_box.setVisibleCondition("false") 103 | self.close() 104 | 105 | def onClick(self, control): 106 | 107 | if control == MANUAL_SERVER: 108 | self._manual_server = True 109 | self.close() 110 | 111 | elif control == CANCEL: 112 | self.close() 113 | 114 | def _connect_server(self, server_id): 115 | 116 | server = self.connect_manager.get_server_info(server_id) 117 | self.message.setLabel("%s %s..." % (translate(30610), server["Name"])) 118 | 119 | self.message_box.setVisibleCondition("true") 120 | self.busy.setVisibleCondition("true") 121 | 122 | result = self.connect_manager.connect_to_server(server) 123 | 124 | if result["State"] == CONNECTION_STATE["Unavailable"]: 125 | self.busy.setVisibleCondition("false") 126 | 127 | self.message.setLabel(translate(30609)) 128 | return False 129 | else: 130 | xbmc.sleep(1000) 131 | self._selected_server = result["Servers"][0] 132 | return True 133 | -------------------------------------------------------------------------------- /jellyfin_kodi/helper/loghandler.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import os 7 | import logging 8 | import traceback 9 | 10 | import xbmc 11 | import xbmcaddon 12 | 13 | from .. import database 14 | from . import settings, kodi_version 15 | from .utils import translate_path 16 | 17 | ################################################################################################## 18 | 19 | __addon__ = xbmcaddon.Addon(id="plugin.video.jellyfin") 20 | __pluginpath__ = translate_path(__addon__.getAddonInfo("path")) 21 | 22 | ################################################################################################## 23 | 24 | 25 | def getLogger(name=None): 26 | if name is None: 27 | return __LOGGER 28 | 29 | return __LOGGER.getChild(name) 30 | 31 | 32 | class LogHandler(logging.StreamHandler): 33 | 34 | def __init__(self): 35 | 36 | logging.StreamHandler.__init__(self) 37 | self.setFormatter(MyFormatter()) 38 | 39 | self.sensitive = {"Token": [], "Server": []} 40 | 41 | for server in database.get_credentials()["Servers"]: 42 | 43 | if server.get("AccessToken"): 44 | self.sensitive["Token"].append(server["AccessToken"]) 45 | 46 | if server.get("address"): 47 | self.sensitive["Server"].append(server["address"].split("://", 1)[-1]) 48 | 49 | self.mask_info = settings("maskInfo.bool") 50 | 51 | if kodi_version() > 18: 52 | self.level = xbmc.LOGINFO 53 | else: 54 | self.level = xbmc.LOGNOTICE 55 | 56 | def emit(self, record): 57 | 58 | if self._get_log_level(record.levelno): 59 | string = self.format(record) 60 | 61 | if self.mask_info: 62 | for server in self.sensitive["Server"]: 63 | string = string.replace(server or "{server}", "{jellyfin-server}") 64 | 65 | for token in self.sensitive["Token"]: 66 | string = string.replace(token or "{token}", "{jellyfin-token}") 67 | 68 | # Kodi chokes on null-characters in log output, escape it. 69 | if "\x00" in string: 70 | string = string.replace("\x00", "\ufffdx00\ufffd") 71 | 72 | xbmc.log(string, level=self.level) 73 | 74 | @classmethod 75 | def _get_log_level(cls, level): 76 | 77 | levels = { 78 | logging.ERROR: 0, 79 | logging.WARNING: 0, 80 | logging.INFO: 1, 81 | logging.DEBUG: 2, 82 | } 83 | try: 84 | log_level = int(settings("logLevel")) 85 | except ValueError: 86 | log_level = 2 # If getting settings fail, we probably want debug logging. 87 | 88 | return log_level >= levels[level] 89 | 90 | 91 | class MyFormatter(logging.Formatter): 92 | 93 | def __init__( 94 | self, fmt="%(name)s -> %(levelname)s::%(relpath)s:%(lineno)s %(message)s" 95 | ): 96 | logging.Formatter.__init__(self, fmt) 97 | 98 | def format(self, record): 99 | self._gen_rel_path(record) 100 | 101 | # Call the original formatter class to do the grunt work 102 | result = logging.Formatter.format(self, record) 103 | 104 | return result 105 | 106 | def formatException(self, exc_info): 107 | _pluginpath_real = os.path.realpath(__pluginpath__) 108 | res = [] 109 | 110 | for o in traceback.format_exception(*exc_info): 111 | if o.startswith(' File "'): 112 | # If this split can't handle your file names, you should seriously consider renaming your files. 113 | fn = o.split(' File "', 2)[1].split('", line ', 1)[0] 114 | rfn = os.path.realpath(fn) 115 | if rfn.startswith(_pluginpath_real): 116 | o = o.replace(fn, os.path.relpath(rfn, _pluginpath_real)) 117 | 118 | res.append(o) 119 | 120 | return "".join(res) 121 | 122 | def _gen_rel_path(self, record): 123 | if record.pathname: 124 | record.relpath = os.path.relpath(record.pathname, __pluginpath__) 125 | 126 | 127 | __LOGGER = logging.getLogger("JELLYFIN") 128 | for handler in __LOGGER.handlers: 129 | __LOGGER.removeHandler(handler) 130 | 131 | __LOGGER.addHandler(LogHandler()) 132 | __LOGGER.setLevel(logging.DEBUG) 133 | -------------------------------------------------------------------------------- /jellyfin_kodi/jellyfin/ws_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | import json 7 | import threading 8 | import time 9 | 10 | import xbmc 11 | 12 | from ..helper import LazyLogger, settings 13 | 14 | # If numpy is installed, the websockets library tries to use it, and then 15 | # kodi hard crashes for reasons I don't even want to pretend to understand 16 | import sys # noqa: E402,I100 17 | 18 | sys.modules["numpy"] = None 19 | import websocket # noqa: E402,I201 20 | 21 | ################################################################################################## 22 | 23 | LOG = LazyLogger(__name__) 24 | 25 | ################################################################################################## 26 | 27 | 28 | class WSClient(threading.Thread): 29 | 30 | wsc = None 31 | stop = False 32 | 33 | def __init__(self, client): 34 | 35 | LOG.debug("WSClient initializing...") 36 | 37 | self.client = client 38 | threading.Thread.__init__(self) 39 | 40 | def send(self, message, data=""): 41 | 42 | if self.wsc is None: 43 | raise ValueError("The websocket client is not started.") 44 | 45 | self.wsc.send(json.dumps({"MessageType": message, "Data": data})) 46 | 47 | def run(self): 48 | 49 | monitor = xbmc.Monitor() 50 | token = self.client.config.data["auth.token"] 51 | device_id = self.client.config.data["app.device_id"] 52 | server = self.client.config.data["auth.server"] 53 | server = ( 54 | server.replace("https://", "wss://") 55 | if server.startswith("https") 56 | else server.replace("http://", "ws://") 57 | ) 58 | wsc_url = "%s/socket?api_key=%s&device_id=%s" % (server, token, device_id) 59 | 60 | LOG.info("Websocket url: %s", wsc_url) 61 | 62 | self.wsc = websocket.WebSocketApp( 63 | wsc_url, 64 | on_open=lambda ws: self.on_open(ws), 65 | on_message=lambda ws, message: self.on_message(ws, message), 66 | on_error=lambda ws, error: self.on_error(ws, error), 67 | ) 68 | 69 | while not self.stop: 70 | 71 | self.wsc.run_forever(ping_interval=10, reconnect=10) 72 | 73 | if not self.stop and monitor.waitForAbort(5): 74 | break 75 | 76 | def on_error(self, ws, error): 77 | LOG.error(error) 78 | 79 | def on_open(self, ws): 80 | LOG.info("--->[ websocket opened ]") 81 | # Avoid a timing issue where the capabilities are not correctly registered 82 | time.sleep(5) 83 | if settings("remoteControl.bool"): 84 | self.client.jellyfin.post_capabilities( 85 | { 86 | "PlayableMediaTypes": "Audio,Video", 87 | "SupportsMediaControl": True, 88 | "SupportedCommands": ( 89 | "MoveUp,MoveDown,MoveLeft,MoveRight,Select," 90 | "Back,ToggleContextMenu,ToggleFullscreen,ToggleOsdMenu," 91 | "GoHome,PageUp,NextLetter,GoToSearch," 92 | "GoToSettings,PageDown,PreviousLetter,TakeScreenshot," 93 | "VolumeUp,VolumeDown,ToggleMute,SendString,DisplayMessage," 94 | "SetAudioStreamIndex,SetSubtitleStreamIndex," 95 | "SetRepeatMode,Mute,Unmute,SetVolume," 96 | "Play,Playstate,PlayNext,PlayMediaSource" 97 | ), 98 | } 99 | ) 100 | else: 101 | self.client.jellyfin.post_capabilities( 102 | {"PlayableMediaTypes": "Audio, Video", "SupportsMediaControl": False} 103 | ) 104 | 105 | def on_message(self, ws, message): 106 | 107 | message = json.loads(message) 108 | data = message.get("Data", {}) 109 | 110 | if message["MessageType"] in ("RefreshProgress",): 111 | LOG.debug("Ignoring %s", message) 112 | 113 | return 114 | 115 | if not self.client.config.data["app.default"]: 116 | data["ServerId"] = self.client.auth.server_id 117 | 118 | self.client.callback(message["MessageType"], data) 119 | 120 | def stop_client(self): 121 | 122 | self.stop = True 123 | 124 | if self.wsc is not None: 125 | self.wsc.close() 126 | -------------------------------------------------------------------------------- /resources/language/resource.language.af_za/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "PO-Revision-Date: 2025-12-15 05:07+0000\n" 4 | "Last-Translator: Francois Pienaar <fpienaar@pm.me>\n" 5 | "Language-Team: Afrikaans <https://translate.jellyfin.org/projects/jellyfin/" 6 | "jellyfin-kodi/af/>\n" 7 | "Language: af_za\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=2; plural=n != 1;\n" 12 | "X-Generator: Weblate 5.14\n" 13 | 14 | msgctxt "#30091" 15 | msgid "Confirm file deletion" 16 | msgstr "Bevestig lêeruitvee" 17 | 18 | msgctxt "#30024" 19 | msgid "Username" 20 | msgstr "Gebruikernaam" 21 | 22 | msgctxt "#30022" 23 | msgid "Advanced" 24 | msgstr "Gevorderde" 25 | 26 | msgctxt "#30016" 27 | msgid "Device name" 28 | msgstr "Toestel naam" 29 | 30 | msgctxt "#30004" 31 | msgid "Log level" 32 | msgstr "Log vlak" 33 | 34 | msgctxt "#30003" 35 | msgid "Login method" 36 | msgstr "Teken aan metode" 37 | 38 | msgctxt "#30002" 39 | msgid "Force HTTP playback" 40 | msgstr "Forseer HTTP afspeel" 41 | 42 | msgctxt "#30001" 43 | msgid "Server name" 44 | msgstr "Server naam" 45 | 46 | msgctxt "#30000" 47 | msgid "Server address" 48 | msgstr "Server adres" 49 | 50 | msgctxt "#29999" 51 | msgid "Jellyfin for Kodi" 52 | msgstr "Jellyfin vir Kodi" 53 | 54 | msgctxt "#30115" 55 | msgid "For Episodes" 56 | msgstr "Vir Episodes" 57 | 58 | msgctxt "#30116" 59 | msgid "For Movies" 60 | msgstr "Vir Films" 61 | 62 | #, fuzzy 63 | msgctxt "#30157" 64 | msgid "Enable enhanced artwork (i.e. cover art)" 65 | msgstr "Aktiveer verbetering in kunswerk (i.e. omslagkuns)" 66 | 67 | msgctxt "#30160" 68 | msgid "Max stream bitrate" 69 | msgstr "Maksimum stroom bitsnelheid" 70 | 71 | msgctxt "#30161" 72 | msgid "Preferred video codec" 73 | msgstr "Voorkeur video kodek" 74 | 75 | msgctxt "#30162" 76 | msgid "Preferred audio codec" 77 | msgstr "Voorkeur oudio kodek" 78 | 79 | msgctxt "#30163" 80 | msgid "Audio bitrate" 81 | msgstr "Oudio bitsnelheid" 82 | 83 | msgctxt "#30164" 84 | msgid "Audio max channels" 85 | msgstr "Oudio maksimum kanale" 86 | 87 | msgctxt "#30165" 88 | msgid "Allow burned subtitles" 89 | msgstr "Laat gebrande byskifte toe" 90 | 91 | msgctxt "#30170" 92 | msgid "Recently Added TV Shows" 93 | msgstr "Onlangs bygevoegde TV-programme" 94 | 95 | msgctxt "#30171" 96 | msgid "In Progress TV Shows" 97 | msgstr "TV-Programme aan die gang" 98 | 99 | msgctxt "#30174" 100 | msgid "Recently Added Movies" 101 | msgstr "Onlangs Bygevoegde Films" 102 | 103 | msgctxt "#30175" 104 | msgid "Recently Added Episodes" 105 | msgstr "Onlangs Bygevoegde Episodes" 106 | 107 | msgctxt "#30177" 108 | msgid "In Progress Movies" 109 | msgstr "Films aan die gang" 110 | 111 | msgctxt "#30178" 112 | msgid "In Progress Episodes" 113 | msgstr "Episodes aan die gang" 114 | 115 | msgctxt "#30179" 116 | msgid "Next Episodes" 117 | msgstr "Volgende Episodes" 118 | 119 | msgctxt "#30180" 120 | msgid "Favorite Movies" 121 | msgstr "Gunsteling Flieks" 122 | 123 | msgctxt "#30181" 124 | msgid "Favorite Shows" 125 | msgstr "Gunsteling Programme" 126 | 127 | msgctxt "#30182" 128 | msgid "Favorite Episodes" 129 | msgstr "Gunsteling Episodes" 130 | 131 | msgctxt "#30185" 132 | msgid "Boxsets" 133 | msgstr "Boksstelle" 134 | 135 | msgctxt "#30189" 136 | msgid "Unwatched Movies" 137 | msgstr "Ongekykte Films" 138 | 139 | msgctxt "#30229" 140 | msgid "Random Items" 141 | msgstr "Willekeurige Items" 142 | 143 | msgctxt "#30235" 144 | msgid "Interface" 145 | msgstr "Koppelvlak" 146 | 147 | msgctxt "#30249" 148 | msgid "Enable welcome message" 149 | msgstr "Aktiveer welkom boodskap" 150 | 151 | msgctxt "#30251" 152 | msgid "Recently added Home Videos" 153 | msgstr "Onlangs bygevoegde Tuisvideo's" 154 | 155 | msgctxt "#30252" 156 | msgid "Recently added Photos" 157 | msgstr "Onlangs bygevoegde Foto's" 158 | 159 | msgctxt "#30253" 160 | msgid "Favourite Home Videos" 161 | msgstr "Gunsteling Tuisvideo's" 162 | 163 | msgctxt "#30254" 164 | msgid "Favourite Photos" 165 | msgstr "Gunsteling Foto's" 166 | 167 | msgctxt "#30255" 168 | msgid "Favourite Albums" 169 | msgstr "Gunsteling Albums" 170 | 171 | msgctxt "#30256" 172 | msgid "Recently added Music videos" 173 | msgstr "Onlangs bygevoegde Musiekvideo's" 174 | 175 | msgctxt "#30258" 176 | msgid "Unwatched Music videos" 177 | msgstr "Ongekykte Musiekvideo's" 178 | 179 | msgctxt "#30302" 180 | msgid "Movies" 181 | msgstr "Films" 182 | 183 | msgctxt "#30305" 184 | msgid "TV Shows" 185 | msgstr "TV-Programme" 186 | 187 | msgctxt "#30401" 188 | msgid "Jellyfin options" 189 | msgstr "Jellyfin opsies" 190 | -------------------------------------------------------------------------------- /jellyfin_kodi/jellyfin/credentials.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | import time 7 | from datetime import datetime 8 | 9 | from ..helper import LazyLogger 10 | 11 | ################################################################################################# 12 | 13 | LOG = LazyLogger(__name__) 14 | 15 | ################################################################################################# 16 | 17 | 18 | class Credentials(object): 19 | 20 | credentials = None 21 | 22 | def __init__(self): 23 | LOG.debug("Credentials initializing...") 24 | 25 | def set_credentials(self, credentials): 26 | self.credentials = credentials 27 | 28 | def get_credentials(self): 29 | return self.get() 30 | 31 | def _ensure(self): 32 | 33 | if not self.credentials: 34 | try: 35 | LOG.info(self.credentials) 36 | if not isinstance(self.credentials, dict): 37 | raise ValueError("invalid credentials format") 38 | 39 | except Exception as e: # File is either empty or missing 40 | LOG.warning(e) 41 | self.credentials = {} 42 | 43 | LOG.debug("credentials initialized with: %s", self.credentials) 44 | self.credentials["Servers"] = self.credentials.setdefault("Servers", []) 45 | 46 | def get(self): 47 | self._ensure() 48 | 49 | return self.credentials 50 | 51 | def set(self, data): 52 | 53 | if data: 54 | self.credentials.update(data) 55 | else: 56 | self._clear() 57 | 58 | LOG.debug("credentialsupdated") 59 | 60 | def _clear(self): 61 | self.credentials.clear() 62 | 63 | def add_update_user(self, server, user): 64 | 65 | for existing in server.setdefault("Users", []): 66 | if existing["Id"] == user["Id"]: 67 | # Merge the data 68 | existing["IsSignedInOffline"] = True 69 | break 70 | else: 71 | server["Users"].append(user) 72 | 73 | def add_update_server(self, servers, server): 74 | 75 | if server.get("Id") is None: 76 | raise KeyError("Server['Id'] cannot be null or empty") 77 | 78 | # Add default DateLastAccessed if doesn't exist. 79 | server.setdefault("DateLastAccessed", "1970-01-01T00:00:00Z") 80 | 81 | for existing in servers: 82 | if existing["Id"] == server["Id"]: 83 | 84 | # Merge the data 85 | if server.get("DateLastAccessed") and self._date_object( 86 | server["DateLastAccessed"] 87 | ) > self._date_object(existing["DateLastAccessed"]): 88 | existing["DateLastAccessed"] = server["DateLastAccessed"] 89 | 90 | if server.get("UserLinkType"): 91 | existing["UserLinkType"] = server["UserLinkType"] 92 | 93 | if server.get("AccessToken"): 94 | existing["AccessToken"] = server["AccessToken"] 95 | existing["UserId"] = server["UserId"] 96 | 97 | if server.get("ExchangeToken"): 98 | existing["ExchangeToken"] = server["ExchangeToken"] 99 | 100 | if server.get("ManualAddress"): 101 | existing["ManualAddress"] = server["ManualAddress"] 102 | 103 | if server.get("LocalAddress"): 104 | existing["LocalAddress"] = server["LocalAddress"] 105 | 106 | if server.get("Name"): 107 | existing["Name"] = server["Name"] 108 | 109 | if server.get("LastConnectionMode") is not None: 110 | existing["LastConnectionMode"] = server["LastConnectionMode"] 111 | 112 | if server.get("ConnectServerId"): 113 | existing["ConnectServerId"] = server["ConnectServerId"] 114 | 115 | return existing 116 | 117 | servers.append(server) 118 | return server 119 | 120 | def _date_object(self, date): 121 | # Convert string to date 122 | try: 123 | date_obj = time.strptime(date, "%Y-%m-%dT%H:%M:%SZ") 124 | except (ImportError, TypeError): 125 | # TypeError: attribute of type 'NoneType' is not callable 126 | # Known Kodi/python error 127 | date_obj = datetime(*(time.strptime(date, "%Y-%m-%dT%H:%M:%SZ")[0:6])) 128 | 129 | return date_obj 130 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/kodi/tvshows.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | from ...helper import LazyLogger 7 | 8 | from . import queries as QU 9 | from .kodi import Kodi 10 | 11 | ################################################################################################## 12 | 13 | LOG = LazyLogger(__name__) 14 | 15 | ################################################################################################## 16 | 17 | 18 | class TVShows(Kodi): 19 | 20 | def __init__(self, cursor): 21 | 22 | self.cursor = cursor 23 | Kodi.__init__(self) 24 | 25 | def create_entry_unique_id(self): 26 | self.cursor.execute(QU.create_unique_id) 27 | 28 | return self.cursor.fetchone()[0] + 1 29 | 30 | def create_entry_rating(self): 31 | self.cursor.execute(QU.create_rating) 32 | 33 | return self.cursor.fetchone()[0] + 1 34 | 35 | def create_entry(self): 36 | self.cursor.execute(QU.create_tvshow) 37 | 38 | return self.cursor.fetchone()[0] + 1 39 | 40 | def create_entry_season(self): 41 | self.cursor.execute(QU.create_season) 42 | 43 | return self.cursor.fetchone()[0] + 1 44 | 45 | def create_entry_episode(self): 46 | self.cursor.execute(QU.create_episode) 47 | 48 | return self.cursor.fetchone()[0] + 1 49 | 50 | def get(self, *args): 51 | 52 | try: 53 | self.cursor.execute(QU.get_tvshow, args) 54 | 55 | return self.cursor.fetchone()[0] 56 | except TypeError: 57 | return 58 | 59 | def get_episode(self, *args): 60 | 61 | try: 62 | self.cursor.execute(QU.get_episode, args) 63 | 64 | return self.cursor.fetchone()[0] 65 | except TypeError: 66 | return 67 | 68 | def get_rating_id(self, *args): 69 | 70 | try: 71 | self.cursor.execute(QU.get_rating, args) 72 | 73 | return self.cursor.fetchone()[0] 74 | except TypeError: 75 | return 76 | 77 | def add_ratings(self, *args): 78 | self.cursor.execute(QU.add_rating, args) 79 | 80 | def update_ratings(self, *args): 81 | self.cursor.execute(QU.update_rating, args) 82 | 83 | def get_total_episodes(self, *args): 84 | 85 | try: 86 | self.cursor.execute(QU.get_total_episodes, args) 87 | 88 | return self.cursor.fetchone()[0] 89 | except TypeError: 90 | return 91 | 92 | def get_unique_id(self, *args): 93 | 94 | try: 95 | self.cursor.execute(QU.get_unique_id, args) 96 | 97 | return self.cursor.fetchone()[0] 98 | except TypeError: 99 | return 100 | 101 | def add_unique_id(self, *args): 102 | self.cursor.execute(QU.add_unique_id, args) 103 | 104 | def update_unique_id(self, *args): 105 | self.cursor.execute(QU.update_unique_id, args) 106 | 107 | def add(self, *args): 108 | self.cursor.execute(QU.add_tvshow, args) 109 | 110 | def update(self, *args): 111 | self.cursor.execute(QU.update_tvshow, args) 112 | 113 | def link(self, *args): 114 | self.cursor.execute(QU.update_tvshow_link, args) 115 | 116 | def get_season(self, name, *args): 117 | 118 | self.cursor.execute(QU.get_season, args) 119 | try: 120 | season_id = self.cursor.fetchone()[0] 121 | except TypeError: 122 | season_id = self.add_season(*args) 123 | 124 | if name: 125 | self.cursor.execute(QU.update_season, (name, season_id)) 126 | 127 | return season_id 128 | 129 | def add_season(self, *args): 130 | 131 | season_id = self.create_entry_season() 132 | self.cursor.execute(QU.add_season, (season_id,) + args) 133 | 134 | return season_id 135 | 136 | def get_by_unique_id(self, *args): 137 | self.cursor.execute(QU.get_show_by_unique_id, args) 138 | 139 | return self.cursor.fetchall() 140 | 141 | def add_episode(self, *args): 142 | self.cursor.execute(QU.add_episode, args) 143 | 144 | def update_episode(self, *args): 145 | self.cursor.execute(QU.update_episode, args) 146 | 147 | def delete_tvshow(self, *args): 148 | self.cursor.execute(QU.delete_tvshow, args) 149 | 150 | def delete_season(self, *args): 151 | self.cursor.execute(QU.delete_season, args) 152 | 153 | def delete_episode(self, kodi_id, file_id): 154 | 155 | self.cursor.execute(QU.delete_episode, (kodi_id,)) 156 | self.cursor.execute(QU.delete_file, (file_id,)) 157 | -------------------------------------------------------------------------------- /tests/test_helper_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | import sys 5 | 6 | # Python 2 7 | if sys.version_info < (3, 0): 8 | zoneinfo = None 9 | # Python 3.0 - 3.8 10 | elif sys.version_info < (3, 9): 11 | from backports import zoneinfo # type: ignore [import,no-redef] 12 | # Python >= 3.9 13 | else: 14 | import zoneinfo 15 | 16 | import pytest 17 | 18 | from jellyfin_kodi.helper.utils import values, convert_to_local, strip_credentials 19 | 20 | item1 = {"foo": 123, "bar": 456, "baz": 789} 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "item,keys,expected", 25 | [ 26 | (item1, ["{foo}", "{baz}"], [123, 789]), 27 | (item1, ["{foo}", "bar"], [123, "bar"]), 28 | (item1, ["{foo}", "bar", 321], [123, "bar", 321]), 29 | ], 30 | ) 31 | def test_values(item, keys, expected): 32 | assert list(values(item, keys)) == expected 33 | 34 | 35 | @pytest.mark.skipif(zoneinfo is None, reason="zoneinfo not available in py2") 36 | @pytest.mark.parametrize( 37 | "utctime,timezone,expected", 38 | [ 39 | # Special case for malformed data from the server, see #212 40 | ("0001-01-01T00:00:00.0000000Z", "UTC", "0001-01-01T00:00:00"), 41 | ("Hello, error.", "Etc/UTC", "Hello, error."), 42 | ("2023-09-21T23:54:24", "Etc/UTC", "2023-09-21T23:54:24"), 43 | # See #725 44 | ("1957-09-21T00:00:00Z", "Europe/Paris", "1957-09-21T01:00:00"), 45 | ("1970-01-01T00:00:00", "Etc/UTC", "1970-01-01T00:00:00"), 46 | ("1969-01-01T00:00:00", "Etc/UTC", "1969-01-01T00:00:00"), 47 | ("1970-01-01T00:00:00", "Europe/Oslo", "1970-01-01T01:00:00"), 48 | ("1969-01-01T00:00:00", "Europe/Oslo", "1969-01-01T01:00:00"), 49 | ("2023-09-21T23:54:24", "Europe/Oslo", "2023-09-22T01:54:24"), 50 | # End of DST in Europe 51 | ("2023-10-29T00:00:00", "Europe/Oslo", "2023-10-29T02:00:00"), 52 | ("2023-10-29T00:59:59", "Europe/Oslo", "2023-10-29T02:59:59"), 53 | ("2023-10-29T01:00:00", "Europe/Oslo", "2023-10-29T02:00:00"), 54 | # Start of DST in Europe 55 | ("2023-03-26T00:59:59", "Europe/Oslo", "2023-03-26T01:59:59"), 56 | ("2023-03-26T01:00:00", "Europe/Oslo", "2023-03-26T03:00:00"), 57 | # Norway was in permanent summertime 1940-08-11 -> 1942-11-02 58 | ("1941-06-24T00:00:00", "Europe/Oslo", "1941-06-24T02:00:00"), 59 | ("1941-12-24T00:00:00", "Europe/Oslo", "1941-12-24T02:00:00"), 60 | # Not going to test them all, but you get the point... 61 | # First one fails on Windows with tzdata==2023.3 62 | # ("1917-07-20T00:00:00", "Europe/Oslo", "1917-07-20T01:00:00"), 63 | ("1916-07-20T00:00:00", "Europe/Oslo", "1916-07-20T02:00:00"), 64 | ("1915-07-20T00:00:00", "Europe/Oslo", "1915-07-20T01:00:00"), 65 | # Some fun outside Europe too! 66 | ("2023-03-11T03:30:00", "America/St_Johns", "2023-03-11T00:00:00"), 67 | ("2023-03-13T02:30:00", "America/St_Johns", "2023-03-13T00:00:00"), 68 | ("2023-11-04T02:30:00", "America/St_Johns", "2023-11-04T00:00:00"), 69 | ("2023-11-06T03:30:00", "America/St_Johns", "2023-11-06T00:00:00"), 70 | ("2023-12-24T00:00:00", "Australia/Eucla", "2023-12-24T08:45:00"), 71 | ("2023-06-24T00:00:00", "Australia/Eucla", "2023-06-24T08:45:00"), 72 | ("2023-12-24T00:00:00", "Australia/Broken_Hill", "2023-12-24T10:30:00"), 73 | ("2023-06-24T00:00:00", "Australia/Broken_Hill", "2023-06-24T09:30:00"), 74 | ("2023-10-31T00:00:00", "Pacific/Kiritimati", "2023-10-31T14:00:00"), 75 | ("2023-10-31T00:00:00", "Pacific/Midway", "2023-10-30T13:00:00"), 76 | ], 77 | ) 78 | def test_convert_to_local(utctime, timezone, expected): 79 | assert convert_to_local(utctime, timezone=zoneinfo.ZoneInfo(timezone)) == expected 80 | 81 | 82 | @pytest.mark.parametrize( 83 | "url,expected", 84 | [ 85 | ("smb://user:pass@server.test/media", "smb://server.test/media"), 86 | ("smb://server.test/media", "smb://server.test/media"), 87 | ("smb://user:pass@192.0.2.1/media", "smb://192.0.2.1/media"), 88 | ("smb://user@192.0.2.1/media", "smb://192.0.2.1/media"), 89 | ("nfs://server.test/media", "nfs://server.test/media"), 90 | ("sftp://user:pass@server.test/media", "sftp://server.test/media"), 91 | ("file://media/movies", "file://media/movies"), 92 | ("/media/movies", "/media/movies"), 93 | ("http://user:pass@server.test/media", "http://server.test/media"), 94 | ("https://user:pass@server.test/media", "https://server.test/media"), 95 | ("http://server.test/media", "http://server.test/media"), 96 | ("https://server.test/media", "https://server.test/media"), 97 | ], 98 | ) 99 | def test_strip_credentials(url, expected): 100 | assert strip_credentials(url) == expected 101 | -------------------------------------------------------------------------------- /resources/skins/default/1080i/script-jellyfin-resume.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <window id="3301" type="dialog"> 3 | <defaultcontrol always="true">100</defaultcontrol> 4 | <controls> 5 | <control type="group"> 6 | <control type="image"> 7 | <top>0</top> 8 | <bottom>0</bottom> 9 | <left>0</left> 10 | <right>0</right> 11 | <texture colordiffuse="CC000000">white.png</texture> 12 | <aspectratio>stretch</aspectratio> 13 | <animation effect="fade" end="100" time="200">WindowOpen</animation> 14 | <animation effect="fade" start="100" end="0" time="200">WindowClose</animation> 15 | </control> 16 | <control type="group"> 17 | <animation effect="slide" time="0" end="0,-15" condition="true">Conditional</animation> 18 | <animation type="WindowOpen" reversible="false"> 19 | <effect type="zoom" start="80" end="100" center="960,540" delay="160" tween="circle" easin="out" time="240" /> 20 | <effect type="fade" delay="160" end="100" time="240" /> 21 | </animation> 22 | <animation type="WindowClose" reversible="false"> 23 | <effect type="zoom" start="100" end="80" center="960,540" easing="in" tween="circle" easin="out" time="240" /> 24 | <effect type="fade" start="100" end="0" time="240" /> 25 | </animation> 26 | <centerleft>50%</centerleft> 27 | <centertop>50%</centertop> 28 | <width>20%</width> 29 | <height>90%</height> 30 | <control type="grouplist" id="100"> 31 | <orientation>vertical</orientation> 32 | <left>0</left> 33 | <right>0</right> 34 | <height>auto</height> 35 | <align>center</align> 36 | <itemgap>0</itemgap> 37 | <onright>close</onright> 38 | <onleft>close</onleft> 39 | <usecontrolcoords>true</usecontrolcoords> 40 | <control type="group"> 41 | <height>30</height> 42 | <control type="image"> 43 | <left>20</left> 44 | <width>100%</width> 45 | <height>25</height> 46 | <texture>logo-white.png</texture> 47 | <aspectratio align="left">keep</aspectratio> 48 | </control> 49 | <control type="image"> 50 | <right>20</right> 51 | <width>100%</width> 52 | <height>25</height> 53 | <aspectratio align="right">keep</aspectratio> 54 | <texture diffuse="user_image.png">$INFO[Window(Home).Property(JellyfinUserImage)]</texture> 55 | <visible>!String.IsEmpty(Window(Home).Property(JellyfinUserImage))</visible> 56 | </control> 57 | <control type="image"> 58 | <right>20</right> 59 | <width>100%</width> 60 | <height>25</height> 61 | <aspectratio align="right">keep</aspectratio> 62 | <texture diffuse="user_image.png">userflyoutdefault.png</texture> 63 | <visible>String.IsEmpty(Window(Home).Property(JellyfinUserImage))</visible> 64 | </control> 65 | </control> 66 | <control type="image"> 67 | <width>100%</width> 68 | <height>10</height> 69 | <texture border="5" colordiffuse="ff222326">dialogs/menu_top.png</texture> 70 | </control> 71 | <control type="button" id="3010"> 72 | <width>100%</width> 73 | <height>65</height> 74 | <align>left</align> 75 | <aligny>center</aligny> 76 | <textoffsetx>20</textoffsetx> 77 | <font>font13</font> 78 | <textcolor>ffe1e1e1</textcolor> 79 | <shadowcolor>66000000</shadowcolor> 80 | <disabledcolor>FF404040</disabledcolor> 81 | <texturefocus border="10" colordiffuse="ff303034">dialogs/menu_back.png</texturefocus> 82 | <texturenofocus border="10" colordiffuse="ff222326">dialogs/menu_back.png</texturenofocus> 83 | <alttexturefocus border="10" colordiffuse="ff303034">dialogs/menu_back.png</alttexturefocus> 84 | <alttexturenofocus border="10" colordiffuse="ff222326">dialogs/menu_back.png</alttexturenofocus> 85 | </control> 86 | <control type="button" id="3011"> 87 | <width>100%</width> 88 | <height>65</height> 89 | <align>left</align> 90 | <aligny>center</aligny> 91 | <textoffsetx>20</textoffsetx> 92 | <font>font13</font> 93 | <textcolor>ffe1e1e1</textcolor> 94 | <shadowcolor>66000000</shadowcolor> 95 | <disabledcolor>FF404040</disabledcolor> 96 | <texturefocus border="10" colordiffuse="ff303034">dialogs/menu_back.png</texturefocus> 97 | <texturenofocus border="10" colordiffuse="ff222326">dialogs/menu_back.png</texturenofocus> 98 | <alttexturefocus border="10" colordiffuse="ff303034">dialogs/menu_back.png</alttexturefocus> 99 | <alttexturenofocus border="10" colordiffuse="ff222326">dialogs/menu_back.png</alttexturenofocus> 100 | </control> 101 | <control type="image"> 102 | <width>100%</width> 103 | <height>10</height> 104 | <texture border="5" colordiffuse="ff222326">dialogs/menu_bottom.png</texture> 105 | </control> 106 | </control> 107 | </control> 108 | </control> 109 | </controls> 110 | </window> 111 | -------------------------------------------------------------------------------- /jellyfin_kodi/dialogs/loginmanual.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import xbmcgui 7 | 8 | from ..helper import translate, LazyLogger, kodi_version 9 | 10 | ################################################################################################## 11 | 12 | LOG = LazyLogger(__name__) 13 | ACTION_PARENT_DIR = 9 14 | ACTION_PREVIOUS_MENU = 10 15 | ACTION_BACK = 92 16 | SIGN_IN = 200 17 | CANCEL = 201 18 | ERROR_TOGGLE = 202 19 | ERROR_MSG = 203 20 | ERROR = {"Invalid": 1, "Empty": 2} 21 | 22 | ################################################################################################## 23 | 24 | 25 | class LoginManual(xbmcgui.WindowXMLDialog): 26 | 27 | _user = None 28 | error = None 29 | username = None 30 | 31 | def __init__(self, *args, **kwargs): 32 | xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) 33 | 34 | def set_args(self, **kwargs): 35 | # connect_manager, user_image, servers 36 | for key, value in kwargs.items(): 37 | setattr(self, key, value) 38 | 39 | def is_logged_in(self): 40 | return bool(self._user) 41 | 42 | def get_user(self): 43 | return self._user 44 | 45 | def onInit(self): 46 | 47 | self.signin_button = self.getControl(SIGN_IN) 48 | self.cancel_button = self.getControl(CANCEL) 49 | self.error_toggle = self.getControl(ERROR_TOGGLE) 50 | self.error_msg = self.getControl(ERROR_MSG) 51 | self.user_field = self._add_editcontrol(755, 433, 40, 415) 52 | self.password_field = self._add_editcontrol(755, 543, 40, 415, password=True) 53 | 54 | if self.username: 55 | 56 | self.user_field.setText(self.username) 57 | self.setFocus(self.password_field) 58 | else: 59 | self.setFocus(self.user_field) 60 | 61 | self.user_field.controlUp(self.cancel_button) 62 | self.user_field.controlDown(self.password_field) 63 | self.password_field.controlUp(self.user_field) 64 | self.password_field.controlDown(self.signin_button) 65 | self.signin_button.controlUp(self.password_field) 66 | self.cancel_button.controlDown(self.user_field) 67 | 68 | def onClick(self, control): 69 | 70 | if control == SIGN_IN: 71 | self._disable_error() 72 | 73 | user = self.user_field.getText() 74 | password = self.password_field.getText() 75 | 76 | if not user: 77 | # Display error 78 | self._error(ERROR["Empty"], translate("empty_user")) 79 | LOG.error("Username cannot be null") 80 | 81 | elif self._login(user, password): 82 | self.close() 83 | 84 | elif control == CANCEL: 85 | # Remind me later 86 | self.close() 87 | 88 | def onAction(self, action): 89 | 90 | if self.error == ERROR["Empty"] and self.user_field.getText(): 91 | self._disable_error() 92 | 93 | if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU): 94 | self.close() 95 | 96 | def _add_editcontrol(self, x, y, height, width, password=False): 97 | 98 | kwargs = dict( 99 | label="", 100 | font="font13", 101 | textColor="FF00A4DC", 102 | disabledColor="FF888888", 103 | focusTexture="-", 104 | noFocusTexture="-", 105 | ) 106 | 107 | # TODO: Kodi 17 compat removal cleanup 108 | if kodi_version() < 18: 109 | kwargs["isPassword"] = password 110 | 111 | control = xbmcgui.ControlEdit(0, 0, 0, 0, **kwargs) 112 | 113 | control.setPosition(x, y) 114 | control.setHeight(height) 115 | control.setWidth(width) 116 | 117 | self.addControl(control) 118 | 119 | # setType has no effect before the control is added to a window 120 | # TODO: Kodi 17 compat removal cleanup 121 | if password and not kodi_version() < 18: 122 | control.setType(xbmcgui.INPUT_TYPE_PASSWORD, "Please enter password") 123 | 124 | return control 125 | 126 | def _login(self, username, password): 127 | 128 | server = self.connect_manager.get_server_info(self.connect_manager.server_id)[ 129 | "address" 130 | ] 131 | result = self.connect_manager.login(server, username, password) 132 | 133 | if not result: 134 | self._error(ERROR["Invalid"], translate("invalid_auth")) 135 | return False 136 | else: 137 | self._user = result 138 | return True 139 | 140 | def _error(self, state, message): 141 | 142 | self.error = state 143 | self.error_msg.setLabel(message) 144 | self.error_toggle.setVisibleCondition("true") 145 | 146 | def _disable_error(self): 147 | 148 | self.error = None 149 | self.error_toggle.setVisibleCondition("false") 150 | -------------------------------------------------------------------------------- /jellyfin_kodi/helper/xmls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | import os 7 | import xml.etree.ElementTree as etree 8 | 9 | import xbmc 10 | import xbmcvfs 11 | 12 | from .utils import translate_path 13 | from . import translate, dialog, settings, LazyLogger 14 | 15 | ################################################################################################# 16 | 17 | LOG = LazyLogger(__name__) 18 | 19 | ################################################################################################# 20 | 21 | 22 | def tvtunes_nfo(path, urls): 23 | """Create tvtunes.nfo""" 24 | try: 25 | xml = etree.parse(path).getroot() 26 | except Exception: 27 | xml = etree.Element("tvtunes") 28 | 29 | for elem in xml.getiterator("tvtunes"): 30 | for file in list(elem): 31 | elem.remove(file) 32 | 33 | for url in urls: 34 | etree.SubElement(xml, "file").text = url 35 | 36 | tree = etree.ElementTree(xml) 37 | tree.write(path) 38 | 39 | 40 | def advanced_settings(): 41 | """Track the existence of <cleanonupdate>true</cleanonupdate> 42 | It is incompatible with plugin paths. 43 | """ 44 | if settings("useDirectPaths") != "0": 45 | return 46 | 47 | path = translate_path("special://profile/") 48 | file = os.path.join(path, "advancedsettings.xml") 49 | 50 | try: 51 | xml = etree.parse(file).getroot() 52 | except Exception: 53 | return 54 | 55 | video = xml.find("videolibrary") 56 | 57 | if video is not None: 58 | cleanonupdate = video.find("cleanonupdate") 59 | 60 | if cleanonupdate is not None and cleanonupdate.text == "true": 61 | 62 | LOG.warning("cleanonupdate disabled") 63 | video.remove(cleanonupdate) 64 | 65 | tree = etree.ElementTree(xml) 66 | tree.write(file) 67 | 68 | dialog("ok", "{jellyfin}", translate(33097)) 69 | xbmc.executebuiltin("RestartApp") 70 | 71 | return True 72 | 73 | 74 | def verify_kodi_defaults(): 75 | """Make sure we have the kodi default folder in place.""" 76 | 77 | source_base_path = translate_path("special://xbmc/system/library/video") 78 | dest_base_path = translate_path("special://profile/library/video") 79 | 80 | if not os.path.exists(source_base_path): 81 | LOG.error("XMLs source path `%s` not found.", source_base_path) 82 | return 83 | 84 | # Make sure the files exist in the local profile. 85 | for source_path, dirs, files in os.walk(source_base_path): 86 | relative_path = os.path.relpath(source_path, source_base_path) 87 | dest_path = os.path.join(dest_base_path, relative_path) 88 | 89 | if not os.path.exists(dest_path): 90 | os.mkdir(os.path.normpath(dest_path)) 91 | 92 | for file_name in files: 93 | dest_file = os.path.join(dest_path, file_name) 94 | copy = False 95 | 96 | if not os.path.exists(dest_file): 97 | copy = True 98 | elif os.path.splitext(file_name)[1].lower() == ".xml": 99 | try: 100 | etree.parse(dest_file) 101 | except etree.ParseError: 102 | LOG.warning( 103 | "Unable to parse `{}`, recovering from default.".format( 104 | dest_file 105 | ) 106 | ) 107 | copy = True 108 | 109 | if copy: 110 | source_file = os.path.join(source_path, file_name) 111 | LOG.debug("Copying `{}` -> `{}`".format(source_file, dest_file)) 112 | xbmcvfs.copy(source_file, dest_file) 113 | 114 | # This code seems to enforce a fixed ordering. 115 | # Is it really desirable to force this on users? 116 | # The default (system wide) order is [10, 20, 30] in Kodi 19. 117 | for index, node in enumerate(["movies", "tvshows", "musicvideos"]): 118 | file_name = os.path.join(dest_base_path, node, "index.xml") 119 | 120 | if xbmcvfs.exists(file_name): 121 | try: 122 | with xbmcvfs.File(file_name) as f: 123 | b = f.read() 124 | tree = etree.ElementTree(etree.fromstring(b)) 125 | except etree.ParseError: 126 | LOG.error("Unable to parse `{}`".format(file_name)) 127 | LOG.exception("We ensured the file was OK above, something is wrong!") 128 | tree = None 129 | 130 | if tree is not None: 131 | tree.getroot().set("order", str(17 + index)) 132 | with xbmcvfs.File(file_name, "w") as f: 133 | f.write(etree.tostring(tree.getroot())) 134 | 135 | playlist_path = translate_path("special://profile/playlists/video") 136 | 137 | if not xbmcvfs.exists(playlist_path): 138 | xbmcvfs.mkdirs(playlist_path) 139 | -------------------------------------------------------------------------------- /jellyfin_kodi/dialogs/servermanual.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import re 7 | 8 | import xbmcgui 9 | 10 | from ..helper import translate 11 | from ..jellyfin.connection_manager import CONNECTION_STATE 12 | from ..helper import LazyLogger 13 | 14 | ################################################################################################## 15 | 16 | LOG = LazyLogger(__name__) 17 | ACTION_PARENT_DIR = 9 18 | ACTION_PREVIOUS_MENU = 10 19 | ACTION_BACK = 92 20 | CONNECT = 200 21 | CANCEL = 201 22 | ERROR_TOGGLE = 202 23 | ERROR_MSG = 203 24 | ERROR = {"Invalid": 1, "Empty": 2} 25 | 26 | # https://stackoverflow.com/a/17871737/1035647 27 | _IPV6_PATTERN = r"^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$" 28 | _IPV6_RE = re.compile(_IPV6_PATTERN) 29 | ################################################################################################## 30 | 31 | 32 | class ServerManual(xbmcgui.WindowXMLDialog): 33 | 34 | _server = None 35 | error = None 36 | 37 | def __init__(self, *args, **kwargs): 38 | 39 | xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) 40 | 41 | def set_args(self, **kwargs): 42 | # connect_manager, user_image, servers, jellyfin_connect 43 | for key, value in kwargs.items(): 44 | setattr(self, key, value) 45 | 46 | def is_connected(self): 47 | return bool(self._server) 48 | 49 | def get_server(self): 50 | return self._server 51 | 52 | def onInit(self): 53 | 54 | self.connect_button = self.getControl(CONNECT) 55 | self.cancel_button = self.getControl(CANCEL) 56 | self.error_toggle = self.getControl(ERROR_TOGGLE) 57 | self.error_msg = self.getControl(ERROR_MSG) 58 | self.host_field = self._add_editcontrol(755, 490, 40, 415) 59 | 60 | self.setFocus(self.host_field) 61 | 62 | self.host_field.controlUp(self.cancel_button) 63 | self.host_field.controlDown(self.connect_button) 64 | self.connect_button.controlUp(self.host_field) 65 | self.cancel_button.controlDown(self.host_field) 66 | 67 | def onClick(self, control): 68 | 69 | if control == CONNECT: 70 | self._disable_error() 71 | 72 | server = self.host_field.getText() 73 | 74 | if not server: 75 | # Display error 76 | self._error(ERROR["Empty"], translate("empty_server")) 77 | LOG.error("Server cannot be null") 78 | 79 | elif self._connect_to_server(server): 80 | self.close() 81 | 82 | elif control == CANCEL: 83 | # Remind me later 84 | self.close() 85 | 86 | def onAction(self, action): 87 | 88 | if self.error == ERROR["Empty"] and self.host_field.getText(): 89 | self._disable_error() 90 | 91 | if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU): 92 | self.close() 93 | 94 | def _add_editcontrol(self, x, y, height, width): 95 | 96 | control = xbmcgui.ControlEdit( 97 | 0, 98 | 0, 99 | 0, 100 | 0, 101 | label="", 102 | font="font13", 103 | textColor="FF00A4DC", 104 | disabledColor="FF888888", 105 | focusTexture="-", 106 | noFocusTexture="-", 107 | ) 108 | control.setPosition(x, y) 109 | control.setHeight(height) 110 | control.setWidth(width) 111 | 112 | self.addControl(control) 113 | return control 114 | 115 | def _connect_to_server(self, server): 116 | if _IPV6_RE.match(server): 117 | server = "[%s]" % (server) 118 | 119 | self._message("%s %s..." % (translate(30610), server)) 120 | result = self.connect_manager.connect_to_address(server) 121 | 122 | if result["State"] == CONNECTION_STATE["Unavailable"]: 123 | self._message(translate(30609)) 124 | return False 125 | else: 126 | self._server = result["Servers"][0] 127 | return True 128 | 129 | def _message(self, message): 130 | 131 | self.error_msg.setLabel(message) 132 | self.error_toggle.setVisibleCondition("true") 133 | 134 | def _error(self, state, message): 135 | 136 | self.error = state 137 | self.error_msg.setLabel(message) 138 | self.error_toggle.setVisibleCondition("true") 139 | 140 | def _disable_error(self): 141 | 142 | self.error = None 143 | self.error_toggle.setVisibleCondition("false") 144 | -------------------------------------------------------------------------------- /jellyfin_kodi/database/jellyfin_db.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | from . import queries as QU 7 | from ..helper import LazyLogger 8 | 9 | from ..jellyfin.utils import sqlite_namedtuple_factory 10 | 11 | ################################################################################################## 12 | 13 | LOG = LazyLogger(__name__) 14 | 15 | ################################################################################################## 16 | 17 | 18 | class JellyfinDatabase: 19 | 20 | def __init__(self, cursor): 21 | self.cursor = cursor 22 | cursor.row_factory = sqlite_namedtuple_factory 23 | 24 | def get_item_by_id(self, *args): 25 | self.cursor.execute(QU.get_item, args) 26 | 27 | return self.cursor.fetchone() 28 | 29 | def add_reference(self, *args): 30 | self.cursor.execute(QU.add_reference, args) 31 | 32 | def update_reference(self, *args): 33 | self.cursor.execute(QU.update_reference, args) 34 | 35 | def update_parent_id(self, *args): 36 | """Parent_id is the parent Kodi id.""" 37 | self.cursor.execute(QU.update_parent, args) 38 | 39 | def get_item_id_by_parent_id(self, *args): 40 | self.cursor.execute(QU.get_item_id_by_parent, args) 41 | 42 | return self.cursor.fetchall() 43 | 44 | def get_item_by_parent_id(self, *args): 45 | self.cursor.execute(QU.get_item_by_parent, args) 46 | 47 | return self.cursor.fetchall() 48 | 49 | def get_item_by_media_folder(self, *args): 50 | self.cursor.execute(QU.get_item_by_media_folder, args) 51 | 52 | return self.cursor.fetchall() 53 | 54 | def get_item_by_wild_id(self, item_id): 55 | self.cursor.execute(QU.get_item_by_wild, (item_id + "%",)) 56 | 57 | return self.cursor.fetchall() 58 | 59 | def get_checksum(self, *args): 60 | self.cursor.execute(QU.get_checksum, args) 61 | 62 | return self.cursor.fetchall() 63 | 64 | def get_item_by_kodi_id(self, *args): 65 | 66 | try: 67 | self.cursor.execute(QU.get_item_by_kodi, args) 68 | 69 | return self.cursor.fetchone()[0] 70 | except TypeError: 71 | return 72 | 73 | def get_episode_kodi_parent_path_id(self, *args): 74 | 75 | try: 76 | self.cursor.execute(QU.get_episode_kodi_parent_path_id, args) 77 | 78 | return self.cursor.fetchone()[0] 79 | except TypeError: 80 | return 81 | 82 | def get_full_item_by_kodi_id(self, *args): 83 | 84 | try: 85 | self.cursor.execute(QU.get_item_by_kodi, args) 86 | 87 | return self.cursor.fetchone() 88 | except TypeError: 89 | return 90 | 91 | def get_media_by_id(self, *args): 92 | 93 | try: 94 | self.cursor.execute(QU.get_media_by_id, args) 95 | 96 | return self.cursor.fetchone()[0] 97 | except TypeError: 98 | return 99 | 100 | def get_media_by_parent_id(self, *args): 101 | self.cursor.execute(QU.get_media_by_parent_id, args) 102 | 103 | return self.cursor.fetchall() 104 | 105 | def remove_item(self, *args): 106 | self.cursor.execute(QU.delete_item, args) 107 | 108 | def remove_items_by_parent_id(self, *args): 109 | self.cursor.execute(QU.delete_item_by_parent, args) 110 | 111 | def remove_item_by_kodi_id(self, *args): 112 | self.cursor.execute(QU.delete_item_by_kodi, args) 113 | 114 | def remove_wild_item(self, item_id): 115 | self.cursor.execute(QU.delete_item_by_wild, (item_id + "%",)) 116 | 117 | def get_view_name(self, item_id): 118 | 119 | self.cursor.execute(QU.get_view_name, (item_id,)) 120 | 121 | return self.cursor.fetchone()[0] 122 | 123 | def get_view(self, *args): 124 | 125 | try: 126 | self.cursor.execute(QU.get_view, args) 127 | 128 | return self.cursor.fetchone() 129 | except TypeError: 130 | return 131 | 132 | def add_view(self, *args): 133 | self.cursor.execute(QU.add_view, args) 134 | 135 | def remove_view(self, *args): 136 | self.cursor.execute(QU.delete_view, args) 137 | 138 | def get_views(self): 139 | self.cursor.execute(QU.get_views) 140 | 141 | return self.cursor.fetchall() 142 | 143 | def get_views_by_media(self, *args): 144 | self.cursor.execute(QU.get_views_by_media, args) 145 | 146 | return self.cursor.fetchall() 147 | 148 | def get_items_by_media(self, *args): 149 | self.cursor.execute(QU.get_items_by_media, args) 150 | 151 | return self.cursor.fetchall() 152 | 153 | def remove_media_by_parent_id(self, *args): 154 | self.cursor.execute(QU.delete_media_by_parent_id, args) 155 | 156 | def get_version(self): 157 | self.cursor.execute(QU.get_version) 158 | 159 | return self.cursor.fetchone() 160 | 161 | def add_version(self, *args): 162 | """ 163 | We only ever want one value here, so erase the existing contents first 164 | """ 165 | self.cursor.execute(QU.delete_version) 166 | self.cursor.execute(QU.add_version, args) 167 | -------------------------------------------------------------------------------- /resources/language/resource.language.mk_mk/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "PO-Revision-Date: 2025-08-06 16:35+0000\n" 4 | "Last-Translator: Kliment <kliment.lambevski@gmail.com>\n" 5 | "Language-Team: Macedonian <https://translate.jellyfin.org/projects/jellyfin/" 6 | "jellyfin-kodi/mk/>\n" 7 | "Language: mk_mk\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=2; plural=n==1 || n%10==1 ? 0 : 1;\n" 12 | "X-Generator: Weblate 5.11.4\n" 13 | 14 | msgctxt "#30022" 15 | msgid "Advanced" 16 | msgstr "Напредно" 17 | 18 | msgctxt "#30116" 19 | msgid "For Movies" 20 | msgstr "За филмови" 21 | 22 | msgctxt "#30170" 23 | msgid "Recently Added TV Shows" 24 | msgstr "Неодамна додаени емисии" 25 | 26 | msgctxt "#30182" 27 | msgid "Favorite Episodes" 28 | msgstr "Омилени епизоди" 29 | 30 | msgctxt "#30185" 31 | msgid "Boxsets" 32 | msgstr "Сет" 33 | 34 | msgctxt "#30235" 35 | msgid "Interface" 36 | msgstr "Интерфејс" 37 | 38 | msgctxt "#30302" 39 | msgid "Movies" 40 | msgstr "Филмови" 41 | 42 | msgctxt "#30405" 43 | msgid "Add to favorites" 44 | msgstr "Додади во омилени" 45 | 46 | msgctxt "#30408" 47 | msgid "Settings" 48 | msgstr "Опции" 49 | 50 | msgctxt "#29999" 51 | msgid "Jellyfin for Kodi" 52 | msgstr "Jellyfin за Kodi" 53 | 54 | msgctxt "#30000" 55 | msgid "Server address" 56 | msgstr "Адреса на серверот" 57 | 58 | msgctxt "#30001" 59 | msgid "Server name" 60 | msgstr "Име на серверот" 61 | 62 | msgctxt "#30004" 63 | msgid "Log level" 64 | msgstr "Ниво на пријавување" 65 | 66 | msgctxt "#30002" 67 | msgid "Force HTTP playback" 68 | msgstr "Присили HTTP плејбек" 69 | 70 | msgctxt "#30114" 71 | msgid "Offer delete after playback" 72 | msgstr "Понуди бришење после плејбек" 73 | 74 | msgctxt "#30003" 75 | msgid "Login method" 76 | msgstr "Метод на најава" 77 | 78 | msgctxt "#30016" 79 | msgid "Device name" 80 | msgstr "Име на уред" 81 | 82 | msgctxt "#30175" 83 | msgid "Recently Added Episodes" 84 | msgstr "Неодамна додадени епизоди" 85 | 86 | msgctxt "#30251" 87 | msgid "Recently added Home Videos" 88 | msgstr "Неодамна додадени домашни видеа" 89 | 90 | msgctxt "#30024" 91 | msgid "Username" 92 | msgstr "Корисничко име" 93 | 94 | msgctxt "#30091" 95 | msgid "Confirm file deletion" 96 | msgstr "Потврди бришење на датотеката" 97 | 98 | msgctxt "#30115" 99 | msgid "For Episodes" 100 | msgstr "За епизоди" 101 | 102 | msgctxt "#30174" 103 | msgid "Recently Added Movies" 104 | msgstr "Неодамна додадени филмови" 105 | 106 | msgctxt "#30157" 107 | msgid "Enable enhanced artwork (i.e. cover art)" 108 | msgstr "Обозможи подобри цртежи (на пр. насловен цртеж)" 109 | 110 | msgctxt "#30160" 111 | msgid "Max stream bitrate" 112 | msgstr "Максимален битрејт на стрим" 113 | 114 | msgctxt "#30171" 115 | msgid "In Progress TV Shows" 116 | msgstr "Емисии во тек" 117 | 118 | msgctxt "#30161" 119 | msgid "Preferred video codec" 120 | msgstr "Посакуван видео кодек" 121 | 122 | msgctxt "#30162" 123 | msgid "Preferred audio codec" 124 | msgstr "Посакуван аудио кодек" 125 | 126 | msgctxt "#30163" 127 | msgid "Audio bitrate" 128 | msgstr "Аудио битрејт" 129 | 130 | msgctxt "#30164" 131 | msgid "Audio max channels" 132 | msgstr "Максимален број на аудио канали" 133 | 134 | msgctxt "#30256" 135 | msgid "Recently added Music videos" 136 | msgstr "Неодамна додадени Спотови" 137 | 138 | msgctxt "#30165" 139 | msgid "Allow burned subtitles" 140 | msgstr "Дозволи изгорени титлови" 141 | 142 | msgctxt "#30177" 143 | msgid "In Progress Movies" 144 | msgstr "Филмови во тек" 145 | 146 | msgctxt "#30230" 147 | msgid "Recommended Items" 148 | msgstr "Препорачани теми" 149 | 150 | msgctxt "#30178" 151 | msgid "In Progress Episodes" 152 | msgstr "Епизоди во тек" 153 | 154 | msgctxt "#30406" 155 | msgid "Remove from favorites" 156 | msgstr "Отстрани од омилени" 157 | 158 | msgctxt "#30179" 159 | msgid "Next Episodes" 160 | msgstr "Следни епизоди" 161 | 162 | msgctxt "#30189" 163 | msgid "Unwatched Movies" 164 | msgstr "Неизгледани филмови" 165 | 166 | msgctxt "#30180" 167 | msgid "Favorite Movies" 168 | msgstr "Омилени филмови" 169 | 170 | msgctxt "#30181" 171 | msgid "Favorite Shows" 172 | msgstr "Омилени емисии" 173 | 174 | msgctxt "#30229" 175 | msgid "Random Items" 176 | msgstr "Случајни теми" 177 | 178 | msgctxt "#30239" 179 | msgid "Reset local Kodi database" 180 | msgstr "Ресетирај локална Kodi датабаза" 181 | 182 | msgctxt "#30249" 183 | msgid "Enable welcome message" 184 | msgstr "Овозможи порака за добредојде" 185 | 186 | msgctxt "#30402" 187 | msgid "Jellyfin transcode" 188 | msgstr "Jellyfin транскодирање" 189 | 190 | msgctxt "#30252" 191 | msgid "Recently added Photos" 192 | msgstr "Нодамна додадени фотографии" 193 | 194 | msgctxt "#30253" 195 | msgid "Favourite Home Videos" 196 | msgstr "Омилени домашни видеа" 197 | 198 | msgctxt "#30254" 199 | msgid "Favourite Photos" 200 | msgstr "Омилени фотографии" 201 | 202 | msgctxt "#30258" 203 | msgid "Unwatched Music videos" 204 | msgstr "Неизгледани спотови" 205 | 206 | msgctxt "#30255" 207 | msgid "Favourite Albums" 208 | msgstr "Омилени албуми" 209 | 210 | msgctxt "#30401" 211 | msgid "Jellyfin options" 212 | msgstr "Jellyfin опции" 213 | 214 | msgctxt "#30257" 215 | msgid "In progress Music videos" 216 | msgstr "Спотови во тек" 217 | 218 | msgctxt "#30305" 219 | msgid "TV Shows" 220 | msgstr "Телевизиски Серии" 221 | 222 | msgctxt "#30409" 223 | msgid "Delete from Jellyfin" 224 | msgstr "Избриши од Jellyfin" 225 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/obj.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################## 5 | 6 | import json 7 | import os 8 | 9 | from ..helper import LazyLogger 10 | 11 | ################################################################################################## 12 | 13 | LOG = LazyLogger(__name__) 14 | 15 | ################################################################################################## 16 | 17 | 18 | class Objects(object): 19 | 20 | # Borg - multiple instances, shared state 21 | _shared_state = {} 22 | 23 | def __init__(self): 24 | """Hold all persistent data here.""" 25 | 26 | self.__dict__ = self._shared_state 27 | 28 | def mapping(self): 29 | """Load objects mapping.""" 30 | file_dir = os.path.dirname(__file__) 31 | 32 | with open(os.path.join(file_dir, "obj_map.json")) as infile: 33 | self.objects = json.load(infile) 34 | 35 | def map(self, item, mapping_name): 36 | """Syntax to traverse the item dictionary. 37 | This of the query almost as a url. 38 | 39 | Item is the Jellyfin item json object structure 40 | 41 | ",": each element will be used as a fallback until a value is found. 42 | "?": split filters and key name from the query part, i.e. MediaSources/0?$Name 43 | "$": lead the key name with $. Only one key value can be requested per element. 44 | ":": indicates it's a list of elements [], i.e. MediaSources/0/MediaStreams:?$Name 45 | MediaStreams is a list. 46 | "/": indicates where to go directly 47 | """ 48 | self.mapped_item = {} 49 | 50 | if not mapping_name: 51 | raise Exception("execute mapping() first") 52 | 53 | mapping = self.objects[mapping_name] 54 | 55 | for key, value in mapping.items(): 56 | 57 | self.mapped_item[key] = None 58 | params = value.split(",") 59 | 60 | for param in params: 61 | 62 | obj = item 63 | obj_param = param 64 | obj_key = "" 65 | obj_filters = {} 66 | 67 | if "?" in obj_param: 68 | 69 | if "$" in obj_param: 70 | obj_param, obj_key = obj_param.rsplit("$", 1) 71 | 72 | obj_param, filters = obj_param.rsplit("?", 1) 73 | 74 | if filters: 75 | for filter in filters.split("&"): 76 | filter_key, filter_value = filter.split("=") 77 | obj_filters[filter_key] = filter_value 78 | 79 | if ":" in obj_param: 80 | result = [] 81 | 82 | for d in self.__recursiveloop__(obj, obj_param): 83 | 84 | if not obj_filters or self.__filters__(d, obj_filters): 85 | result.append(d) 86 | 87 | obj = result 88 | obj_filters = {} 89 | 90 | elif "/" in obj_param: 91 | obj = self.__recursive__(obj, obj_param) 92 | 93 | elif obj is item and obj is not None: 94 | obj = item.get(obj_param) 95 | 96 | if obj_filters and obj and not self.__filters__(obj, obj_filters): 97 | obj = None 98 | 99 | if obj is None and len(params) != params.index(param): 100 | continue 101 | 102 | if obj_key: 103 | obj = ( 104 | [d[obj_key] for d in obj if d.get(obj_key)] 105 | if isinstance(obj, list) 106 | else obj.get(obj_key) 107 | ) 108 | 109 | self.mapped_item[key] = obj 110 | break 111 | 112 | if ( 113 | not mapping_name.startswith("Browse") 114 | and not mapping_name.startswith("Artwork") 115 | and not mapping_name.startswith("UpNext") 116 | ): 117 | 118 | self.mapped_item["ProviderName"] = self.objects.get( 119 | "%sProviderName" % mapping_name 120 | ) 121 | self.mapped_item["Checksum"] = json.dumps(item["UserData"]) 122 | 123 | return self.mapped_item 124 | 125 | def __recursiveloop__(self, obj, keys): 126 | 127 | first, rest = keys.split(":", 1) 128 | obj = self.__recursive__(obj, first) 129 | 130 | if obj: 131 | for item in obj: 132 | if rest: 133 | self.__recursiveloop__(item, rest) 134 | else: 135 | yield item 136 | 137 | def __recursive__(self, obj, keys): 138 | 139 | for string in keys.split("/"): 140 | 141 | if not obj: 142 | return 143 | 144 | obj = obj[int(string)] if string.isdigit() else obj.get(string) 145 | 146 | return obj 147 | 148 | def __filters__(self, obj, filters): 149 | 150 | result = False 151 | 152 | for key, value in filters.items(): 153 | 154 | inverse = False 155 | 156 | if value.startswith("!"): 157 | 158 | inverse = True 159 | value = value.split("!", 1)[1] 160 | 161 | if value.lower() == "null": 162 | value = None 163 | 164 | result = obj.get(key) != value if inverse else obj.get(key) == value 165 | 166 | return result 167 | -------------------------------------------------------------------------------- /resources/skins/default/1080i/script-jellyfin-connect-server-manual.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <window> 3 | <defaultcontrol always="true">200</defaultcontrol> 4 | <animation type="WindowOpen" reversible="false"> 5 | <effect type="zoom" start="80" end="100" center="960,540" delay="160" tween="circle" easin="out" time="240" /> 6 | <effect type="fade" delay="160" end="100" time="240" /> 7 | </animation> 8 | <animation type="WindowClose" reversible="false"> 9 | <effect type="zoom" start="100" end="80" center="960,540" easing="in" tween="circle" easin="out" time="240" /> 10 | <effect type="fade" start="100" end="0" time="240" /> 11 | </animation> 12 | <controls> 13 | <control type="group"> 14 | <control type="image"> 15 | <top>-200</top> 16 | <bottom>-200</bottom> 17 | <left>-200</left> 18 | <right>-200</right> 19 | <texture colordiffuse="CC000000">white.png</texture> 20 | <aspectratio>stretch</aspectratio> 21 | <animation effect="fade" end="100" time="200">WindowOpen</animation> 22 | <animation effect="fade" start="100" end="0" time="200">WindowClose</animation> 23 | </control> 24 | <control type="group"> 25 | <centerleft>50%</centerleft> 26 | <centertop>50%</centertop> 27 | <width>470</width> 28 | <height>360</height> 29 | <control type="group"> 30 | <top>-30</top> 31 | <control type="image"> 32 | <left>20</left> 33 | <width>100%</width> 34 | <height>25</height> 35 | <texture>logo-white.png</texture> 36 | <aspectratio align="left">keep</aspectratio> 37 | </control> 38 | </control> 39 | <control type="image"> 40 | <width>100%</width> 41 | <height>360</height> 42 | <texture colordiffuse="ff222326" border="10">dialogs/dialog_back.png</texture> 43 | </control> 44 | <control type="group"> 45 | <centerleft>50%</centerleft> 46 | <top>10</top> 47 | <width>460</width> 48 | <height>350</height> 49 | <control type="grouplist" id="100"> 50 | <orientation>vertical</orientation> 51 | <itemgap>0</itemgap> 52 | <control type="label"> 53 | <width>100%</width> 54 | <height>75</height> 55 | <aligny>center</aligny> 56 | <textoffsetx>20</textoffsetx> 57 | <font>font13</font> 58 | <textcolor>white</textcolor> 59 | <textshadow>66000000</textshadow> 60 | <label>[B]$ADDON[plugin.video.jellyfin 30614][/B]</label> 61 | </control> 62 | <control type="group" id="101"> 63 | <height>110</height> 64 | <control type="label"> 65 | <label>$ADDON[plugin.video.jellyfin 30615]</label> 66 | <textcolor>ffe1e1e1</textcolor> 67 | <shadowcolor>66000000</shadowcolor> 68 | <font>font12</font> 69 | <aligny>top</aligny> 70 | <textoffsetx>20</textoffsetx> 71 | </control> 72 | <control type="label"> 73 | <height>50</height> 74 | </control> 75 | <control type="image"> 76 | <left>20</left> 77 | <right>20</right> 78 | <height>1</height> 79 | <top>90</top> 80 | <texture colordiffuse="ff525252">white.png</texture> 81 | </control> 82 | </control> 83 | <control type="button" id="200"> 84 | <label>[B]$ADDON[plugin.video.jellyfin 30616][/B]</label> 85 | <width>426</width> 86 | <height>65</height> 87 | <font>font13</font> 88 | <textcolor>ffe1e1e1</textcolor> 89 | <focusedcolor>white</focusedcolor> 90 | <selectedcolor>ffe1e1e1</selectedcolor> 91 | <shadowcolor>66000000</shadowcolor> 92 | <textoffsetx>20</textoffsetx> 93 | <aligny>center</aligny> 94 | <align>center</align> 95 | <texturefocus border="10" colordiffuse="FF00A4DC">buttons/shadow_smallbutton.png</texturefocus> 96 | <texturenofocus border="10" colordiffuse="ff464646">buttons/shadow_smallbutton.png</texturenofocus> 97 | <pulseonselect>no</pulseonselect> 98 | <animation effect="slide" time="0" end="17,0" condition="true">Conditional</animation> 99 | </control> 100 | <control type="button" id="201"> 101 | <label>[B]$ADDON[plugin.video.jellyfin 30606][/B]</label> 102 | <width>426</width> 103 | <height>65</height> 104 | <font>font13</font> 105 | <textcolor>ffe1e1e1</textcolor> 106 | <focusedcolor>white</focusedcolor> 107 | <selectedcolor>ffe1e1e1</selectedcolor> 108 | <shadowcolor>66000000</shadowcolor> 109 | <textoffsetx>20</textoffsetx> 110 | <aligny>center</aligny> 111 | <align>center</align> 112 | <texturefocus border="10" colordiffuse="FF00A4DC">buttons/shadow_smallbutton.png</texturefocus> 113 | <texturenofocus border="10" colordiffuse="ff464646">buttons/shadow_smallbutton.png</texturenofocus> 114 | <pulseonselect>no</pulseonselect> 115 | <animation effect="slide" time="0" end="17,0" condition="true">Conditional</animation> 116 | </control> 117 | </control> 118 | </control> 119 | <control type="group" id="202"> 120 | <top>360</top> 121 | <visible>false</visible> 122 | <control type="image"> 123 | <description>Error box</description> 124 | <width>100%</width> 125 | <height>70</height> 126 | <texture colordiffuse="ff222326" border="10">dialogs/dialog_back.png</texture> 127 | </control> 128 | <control type="label" id="203"> 129 | <top>10</top> 130 | <height>50</height> 131 | <textcolor>ffe1e1e1</textcolor> 132 | <scroll>true</scroll> 133 | <shadowcolor>66000000</shadowcolor> 134 | <font>font12</font> 135 | <textoffsetx>20</textoffsetx> 136 | <aligny>center</aligny> 137 | <align>center</align> 138 | </control> 139 | </control> 140 | </control> 141 | </control> 142 | </controls> 143 | </window> 144 | -------------------------------------------------------------------------------- /jellyfin_kodi/objects/kodi/movies.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | from sqlite3 import DatabaseError 5 | 6 | ################################################################################################## 7 | 8 | from ...helper import LazyLogger 9 | 10 | from .kodi import Kodi 11 | from . import queries as QU 12 | 13 | ################################################################################################## 14 | 15 | LOG = LazyLogger(__name__) 16 | 17 | ################################################################################################## 18 | 19 | 20 | class Movies(Kodi): 21 | 22 | itemtype: int 23 | 24 | def __init__(self, cursor): 25 | 26 | self.cursor = cursor 27 | Kodi.__init__(self) 28 | try: 29 | self.cursor.execute(QU.get_videoversion_itemtype, [40400]) 30 | self.itemtype = self.cursor.fetchone()[0] 31 | except (IndexError, DatabaseError, TypeError) as e: 32 | LOG.warning("Unable to fetch videoversion itemtype: %s", e) 33 | self.itemtype = 0 34 | 35 | def create_entry_unique_id(self): 36 | self.cursor.execute(QU.create_unique_id) 37 | 38 | return self.cursor.fetchone()[0] + 1 39 | 40 | def create_entry_rating(self): 41 | self.cursor.execute(QU.create_rating) 42 | 43 | return self.cursor.fetchone()[0] + 1 44 | 45 | def create_entry(self): 46 | self.cursor.execute(QU.create_movie) 47 | 48 | return self.cursor.fetchone()[0] + 1 49 | 50 | def get(self, *args): 51 | 52 | try: 53 | self.cursor.execute(QU.get_movie, args) 54 | return self.cursor.fetchone()[0] 55 | except TypeError: 56 | return 57 | 58 | def add(self, *args): 59 | self.cursor.execute(QU.add_movie, args) 60 | 61 | def add_videoversion(self, *args): 62 | self.cursor.execute(QU.check_video_version) 63 | if self.cursor.fetchone()[0] == 1: 64 | self.cursor.execute(QU.add_video_version, args) 65 | 66 | def update(self, *args): 67 | self.cursor.execute(QU.update_movie, args) 68 | 69 | def delete(self, kodi_id, file_id): 70 | 71 | self.cursor.execute(QU.delete_movie, (kodi_id,)) 72 | self.cursor.execute(QU.delete_file, (file_id,)) 73 | self.cursor.execute(QU.check_video_version) 74 | if self.cursor.fetchone()[0] == 1: 75 | self.cursor.execute(QU.delete_video_version, (file_id,)) 76 | 77 | def get_rating_id(self, *args): 78 | 79 | try: 80 | self.cursor.execute(QU.get_rating, args) 81 | 82 | return self.cursor.fetchone()[0] 83 | except TypeError: 84 | return None 85 | 86 | def add_ratings(self, *args): 87 | """Add ratings, rating type and votes.""" 88 | self.cursor.execute(QU.add_rating, args) 89 | 90 | def update_ratings(self, *args): 91 | """Update rating by rating_id.""" 92 | self.cursor.execute(QU.update_rating, args) 93 | 94 | def get_unique_id(self, *args): 95 | 96 | try: 97 | self.cursor.execute(QU.get_unique_id, args) 98 | 99 | return self.cursor.fetchone()[0] 100 | except TypeError: 101 | return 102 | 103 | def add_unique_id(self, *args): 104 | """Add the provider id, imdb, tvdb.""" 105 | self.cursor.execute(QU.add_unique_id, args) 106 | 107 | def update_unique_id(self, *args): 108 | """Update the provider id, imdb, tvdb.""" 109 | self.cursor.execute(QU.update_unique_id, args) 110 | 111 | def add_countries(self, countries, *args): 112 | 113 | for country in countries: 114 | self.cursor.execute(QU.update_country, (self.get_country(country),) + args) 115 | 116 | def add_country(self, *args): 117 | self.cursor.execute(QU.add_country, args) 118 | return self.cursor.lastrowid 119 | 120 | def get_country(self, *args): 121 | 122 | try: 123 | self.cursor.execute(QU.get_country, args) 124 | 125 | return self.cursor.fetchone()[0] 126 | except TypeError: 127 | return self.add_country(*args) 128 | 129 | def add_boxset(self, *args): 130 | self.cursor.execute(QU.add_set, args) 131 | return self.cursor.lastrowid 132 | 133 | def update_boxset(self, *args): 134 | self.cursor.execute(QU.update_set, args) 135 | 136 | def set_boxset(self, *args): 137 | self.cursor.execute(QU.update_movie_set, args) 138 | 139 | def remove_from_boxset(self, *args): 140 | self.cursor.execute(QU.delete_movie_set, args) 141 | 142 | def delete_boxset(self, *args): 143 | self.cursor.execute(QU.delete_set, args) 144 | 145 | def migrations(self): 146 | """ 147 | Used to trigger required database migrations for new versions 148 | """ 149 | self.cursor.execute(QU.get_version) 150 | version_id = self.cursor.fetchone()[0] 151 | changes = False 152 | 153 | # Will run every time Kodi starts, but will be fast enough on 154 | # subsequent runs to not be a meaningful delay 155 | if version_id >= 131: 156 | changes = self.omega_migration() 157 | 158 | return changes 159 | 160 | def omega_migration(self): 161 | """ 162 | Adds a video version for all existing movies 163 | 164 | For Omega: video_version_id = 0 165 | For Piers: video_version_id = 1 166 | 167 | Migration from Nexus to Omega adds video version with id 0 168 | Migration from Nexus to Peirs adds video version with id 1 169 | Migration from Omega to Piers this does nothing and is handled by kodi itself 170 | """ 171 | LOG.info("Starting migration for Omega database changes") 172 | # Tracks if this migration made any changes 173 | changes = False 174 | self.cursor.execute(QU.get_missing_versions) 175 | 176 | # Sets all existing movies without a version to standard version 177 | for entry in self.cursor.fetchall(): 178 | self.add_videoversion(entry[0], entry[1], "movie", self.itemtype, 40400) 179 | changes = True 180 | 181 | LOG.info("Omega database migration is complete") 182 | return changes 183 | -------------------------------------------------------------------------------- /resources/skins/default/1080i/script-jellyfin-connect-login-manual.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <window> 3 | <defaultcontrol always="true">200</defaultcontrol> 4 | <animation type="WindowOpen" reversible="false"> 5 | <effect type="zoom" start="80" end="100" center="960,540" delay="160" tween="circle" easin="out" time="240" /> 6 | <effect type="fade" delay="160" end="100" time="240" /> 7 | </animation> 8 | <animation type="WindowClose" reversible="false"> 9 | <effect type="zoom" start="100" end="80" center="960,540" easing="in" tween="circle" easin="out" time="240" /> 10 | <effect type="fade" start="100" end="0" time="240" /> 11 | </animation> 12 | <controls> 13 | <control type="group"> 14 | <control type="image"> 15 | <top>-200</top> 16 | <bottom>-200</bottom> 17 | <left>-200</left> 18 | <right>-200</right> 19 | <texture colordiffuse="CC000000">white.png</texture> 20 | <aspectratio>stretch</aspectratio> 21 | <animation effect="fade" end="100" time="200">WindowOpen</animation> 22 | <animation effect="fade" start="100" end="0" time="200">WindowClose</animation> 23 | </control> 24 | <control type="group"> 25 | <centerleft>50%</centerleft> 26 | <centertop>50%</centertop> 27 | <width>470</width> 28 | <height>470</height> 29 | <control type="group"> 30 | <top>-30</top> 31 | <control type="image"> 32 | <left>20</left> 33 | <width>100%</width> 34 | <height>25</height> 35 | <texture>logo-white.png</texture> 36 | <aspectratio align="left">keep</aspectratio> 37 | </control> 38 | </control> 39 | <control type="image"> 40 | <width>100%</width> 41 | <height>470</height> 42 | <texture colordiffuse="ff222326" border="10">dialogs/dialog_back.png</texture> 43 | </control> 44 | <control type="group"> 45 | <centerleft>50%</centerleft> 46 | <top>10</top> 47 | <width>460</width> 48 | <height>460</height> 49 | <control type="grouplist" id="100"> 50 | <orientation>vertical</orientation> 51 | <itemgap>0</itemgap> 52 | <control type="label"> 53 | <width>100%</width> 54 | <height>75</height> 55 | <aligny>center</aligny> 56 | <textoffsetx>20</textoffsetx> 57 | <font>font13</font> 58 | <textcolor>white</textcolor> 59 | <textshadow>66000000</textshadow> 60 | <label>[B]$ADDON[plugin.video.jellyfin 30612][/B]</label> 61 | </control> 62 | <control type="group" id="101"> 63 | <height>110</height> 64 | <control type="label"> 65 | <label>$ADDON[plugin.video.jellyfin 30024]</label> 66 | <textcolor>ffe1e1e1</textcolor> 67 | <shadowcolor>66000000</shadowcolor> 68 | <font>font12</font> 69 | <aligny>top</aligny> 70 | <textoffsetx>20</textoffsetx> 71 | </control> 72 | <control type="label"> 73 | <height>50</height> 74 | </control> 75 | <control type="image"> 76 | <left>20</left> 77 | <right>20</right> 78 | <height>1</height> 79 | <top>90</top> 80 | <texture colordiffuse="ff525252">white.png</texture> 81 | </control> 82 | </control> 83 | <control type="group" id="102"> 84 | <height>110</height> 85 | <control type="label"> 86 | <label>$ADDON[plugin.video.jellyfin 30602]</label> 87 | <textcolor>ffe1e1e1</textcolor> 88 | <textshadow>66000000</textshadow> 89 | <font>font12</font> 90 | <aligny>top</aligny> 91 | <textoffsetx>20</textoffsetx> 92 | </control> 93 | <control type="label"> 94 | <height>50</height> 95 | </control> 96 | <control type="image"> 97 | <description>separator</description> 98 | <left>20</left> 99 | <right>20</right> 100 | <height>1</height> 101 | <top>90</top> 102 | <texture colordiffuse="ff525252">white.png</texture> 103 | </control> 104 | </control> 105 | <control type="button" id="200"> 106 | <label>[B]$ADDON[plugin.video.jellyfin 30605][/B]</label> 107 | <width>426</width> 108 | <height>65</height> 109 | <font>font13</font> 110 | <textcolor>ffe1e1e1</textcolor> 111 | <focusedcolor>white</focusedcolor> 112 | <selectedcolor>ffe1e1e1</selectedcolor> 113 | <shadowcolor>66000000</shadowcolor> 114 | <textoffsetx>20</textoffsetx> 115 | <aligny>center</aligny> 116 | <align>center</align> 117 | <texturefocus border="10" colordiffuse="FF00A4DC">buttons/shadow_smallbutton.png</texturefocus> 118 | <texturenofocus border="10" colordiffuse="ff464646">buttons/shadow_smallbutton.png</texturenofocus> 119 | <pulseonselect>no</pulseonselect> 120 | <onup>205</onup> 121 | <animation effect="slide" time="0" end="17,0" condition="true">Conditional</animation> 122 | </control> 123 | <control type="button" id="201"> 124 | <label>[B]$ADDON[plugin.video.jellyfin 30606][/B]</label> 125 | <width>426</width> 126 | <height>65</height> 127 | <font>font13</font> 128 | <textcolor>ffe1e1e1</textcolor> 129 | <focusedcolor>white</focusedcolor> 130 | <selectedcolor>ffe1e1e1</selectedcolor> 131 | <shadowcolor>66000000</shadowcolor> 132 | <textoffsetx>20</textoffsetx> 133 | <aligny>center</aligny> 134 | <align>center</align> 135 | <texturefocus border="10" colordiffuse="FF00A4DC">buttons/shadow_smallbutton.png</texturefocus> 136 | <texturenofocus border="10" colordiffuse="ff464646">buttons/shadow_smallbutton.png</texturenofocus> 137 | <pulseonselect>no</pulseonselect> 138 | <animation effect="slide" time="0" end="17,0" condition="true">Conditional</animation> 139 | </control> 140 | </control> 141 | </control> 142 | <control type="group" id="202"> 143 | <top>470</top> 144 | <visible>false</visible> 145 | <control type="image"> 146 | <description>Error box</description> 147 | <width>100%</width> 148 | <height>70</height> 149 | <texture colordiffuse="ff222326" border="10">dialogs/dialog_back.png</texture> 150 | </control> 151 | <control type="label" id="203"> 152 | <top>10</top> 153 | <height>50</height> 154 | <textcolor>ffe1e1e1</textcolor> 155 | <scroll>true</scroll> 156 | <shadowcolor>66000000</shadowcolor> 157 | <font>font12</font> 158 | <textoffsetx>20</textoffsetx> 159 | <aligny>center</aligny> 160 | <align>center</align> 161 | </control> 162 | </control> 163 | </control> 164 | </control> 165 | </controls> 166 | </window> 167 | -------------------------------------------------------------------------------- /jellyfin_kodi/entrypoint/context.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division, absolute_import, print_function, unicode_literals 3 | 4 | ################################################################################################# 5 | 6 | import json 7 | import sys 8 | 9 | import xbmc 10 | import xbmcaddon 11 | 12 | from .. import database 13 | from ..dialogs import context 14 | from ..helper import translate, settings, dialog, LazyLogger 15 | from ..helper.utils import translate_path 16 | from ..jellyfin import Jellyfin 17 | 18 | ################################################################################################# 19 | 20 | LOG = LazyLogger(__name__) 21 | XML_PATH = ( 22 | xbmcaddon.Addon("plugin.video.jellyfin").getAddonInfo("path"), 23 | "default", 24 | "1080i", 25 | ) 26 | OPTIONS = { 27 | "Refresh": translate(30410), 28 | "Delete": translate(30409), 29 | "Addon": translate(30408), 30 | "AddFav": translate(30405), 31 | "RemoveFav": translate(30406), 32 | "Transcode": translate(30412), 33 | } 34 | 35 | ################################################################################################# 36 | 37 | 38 | class Context(object): 39 | 40 | _selected_option = None 41 | 42 | def __init__(self, transcode=False, delete=False): 43 | 44 | try: 45 | self.kodi_id = sys.listitem.getVideoInfoTag().getDbId() or None 46 | self.media = self.get_media_type() 47 | self.server_id = sys.listitem.getProperty("jellyfinserver") or None 48 | self.api_client = Jellyfin(self.server_id).get_client().jellyfin 49 | item_id = sys.listitem.getProperty("jellyfinid") 50 | except AttributeError: 51 | self.server_id = None 52 | 53 | if xbmc.getInfoLabel("ListItem.Property(jellyfinid)"): 54 | item_id = xbmc.getInfoLabel("ListItem.Property(jellyfinid)") 55 | else: 56 | self.kodi_id = xbmc.getInfoLabel("ListItem.DBID") 57 | self.media = xbmc.getInfoLabel("ListItem.DBTYPE") 58 | item_id = None 59 | 60 | addon_data = translate_path( 61 | "special://profile/addon_data/plugin.video.jellyfin/data.json" 62 | ) 63 | with open(addon_data, "rb") as infile: 64 | data = json.load(infile) 65 | 66 | try: 67 | server_data = data["Servers"][0] 68 | self.api_client.config.data["auth.server"] = server_data.get("address") 69 | self.api_client.config.data["auth.server-name"] = server_data.get("Name") 70 | self.api_client.config.data["auth.user_id"] = server_data.get("UserId") 71 | self.api_client.config.data["auth.token"] = server_data.get("AccessToken") 72 | except Exception as e: 73 | LOG.warning("Addon appears to not be configured yet: {}".format(e)) 74 | 75 | if self.server_id or item_id: 76 | self.item = self.api_client.get_item(item_id) 77 | else: 78 | self.item = self.get_item_id() 79 | 80 | if self.item: 81 | 82 | if transcode: 83 | self.transcode() 84 | 85 | elif delete: 86 | self.delete_item() 87 | 88 | elif self.select_menu(): 89 | self.action_menu() 90 | 91 | if self._selected_option in ( 92 | OPTIONS["Delete"], 93 | OPTIONS["AddFav"], 94 | OPTIONS["RemoveFav"], 95 | ): 96 | 97 | xbmc.sleep(500) 98 | xbmc.executebuiltin("Container.Refresh") 99 | 100 | def get_media_type(self): 101 | """Get media type based on sys.listitem. If unfilled, base on visible window.""" 102 | media = sys.listitem.getVideoInfoTag().getMediaType() 103 | 104 | if not media: 105 | 106 | if xbmc.getCondVisibility("Container.Content(albums)"): 107 | media = "album" 108 | elif xbmc.getCondVisibility("Container.Content(artists)"): 109 | media = "artist" 110 | elif xbmc.getCondVisibility("Container.Content(songs)"): 111 | media = "song" 112 | elif xbmc.getCondVisibility("Container.Content(pictures)"): 113 | media = "picture" 114 | else: 115 | LOG.info("media is unknown") 116 | 117 | return media 118 | 119 | def get_item_id(self): 120 | """Get synced item from jellyfindb.""" 121 | item = database.get_item(self.kodi_id, self.media) 122 | 123 | if not item: 124 | return 125 | 126 | return { 127 | "Id": item[0], 128 | "UserData": json.loads(item[4]) if item[4] else {}, 129 | "Type": item[3], 130 | } 131 | 132 | def select_menu(self): 133 | """Display the select dialog. 134 | Favorites, Refresh, Delete (opt), Settings. 135 | """ 136 | options = [] 137 | 138 | if self.item["Type"] != "Season": 139 | 140 | if self.item["UserData"].get("IsFavorite"): 141 | options.append(OPTIONS["RemoveFav"]) 142 | else: 143 | options.append(OPTIONS["AddFav"]) 144 | 145 | options.append(OPTIONS["Refresh"]) 146 | 147 | if settings("enableContextDelete.bool"): 148 | options.append(OPTIONS["Delete"]) 149 | 150 | options.append(OPTIONS["Addon"]) 151 | 152 | context_menu = context.ContextMenu("script-jellyfin-context.xml", *XML_PATH) 153 | context_menu.set_options(options) 154 | context_menu.doModal() 155 | 156 | if context_menu.is_selected(): 157 | self._selected_option = context_menu.get_selected() 158 | 159 | return self._selected_option 160 | 161 | def action_menu(self): 162 | 163 | selected = self._selected_option 164 | 165 | if selected == OPTIONS["Refresh"]: 166 | self.api_client.refresh_item(self.item["Id"]) 167 | 168 | elif selected == OPTIONS["AddFav"]: 169 | self.api_client.favorite(self.item["Id"], True) 170 | 171 | elif selected == OPTIONS["RemoveFav"]: 172 | self.api_client.favorite(self.item["Id"], False) 173 | 174 | elif selected == OPTIONS["Addon"]: 175 | xbmc.executebuiltin("Addon.OpenSettings(plugin.video.jellyfin)") 176 | 177 | elif selected == OPTIONS["Delete"]: 178 | self.delete_item() 179 | 180 | def delete_item(self): 181 | if settings("skipContextMenu.bool") or dialog( 182 | "yesno", "{jellyfin}", translate(33015) 183 | ): 184 | self.api_client.delete_item(self.item["Id"]) 185 | 186 | def transcode(self): 187 | filename = xbmc.getInfoLabel("ListItem.Filenameandpath") 188 | filename += "&transcode=true" 189 | xbmc.executebuiltin("PlayMedia(%s)" % filename) 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | <h1 align="center">Jellyfin for Kodi</h1> 2 | <h3 align="center">Part of the <a href="https://jellyfin.org">Jellyfin Project</a></h3> 3 | 4 | --- 5 | 6 | <p align="center"> 7 | <img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/> 8 | <br/> 9 | <br/> 10 | <a href="https://github.com/jellyfin/jellyfin-kodi"><img src="https://img.shields.io/github/license/jellyfin/jellyfin-kodi" alt="GPL 3.0 License" /></a> 11 | <a href="https://github.com/jellyfin/jellyfin-kodi/releases"><img src="https://img.shields.io/github/v/release/jellyfin/jellyfin-kodi" alt="GitHub release (latest SemVer)" /></a> 12 | <a href="https://matrix.to/#/+jellyfin:matrix.org"><img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/></a> 13 | <br /> 14 | <a href="https://translate.jellyfin.org/engage/jellyfin/?utm_source=widget"><img src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-kodi/svg-badge.svg" alt="Translation status" /></a> 15 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=alert_status" alt="Quality Gate Status" /></a> 16 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=sqale_index" alt="Technical Debt" /></a> 17 | <br /> 18 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=code_smells" alt="Code Smells" /></a> 19 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=bugs" alt="Bugs" /></a> 20 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=vulnerabilities" alt="Vulnerabilities" /></a> 21 | <br /> 22 | <img src="https://img.shields.io/github/languages/code-size/jellyfin/jellyfin-kodi" alt="GitHub code size in bytes" /> 23 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=ncloc" alt="Lines of Code" /></a> 24 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=duplicated_lines_density" alt="Duplicated Lines (%)" /></a> 25 | <br /> 26 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=sqale_rating" alt="Maintainability Rating" /></a> 27 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=reliability_rating" alt="Reliability Rating" /></a> 28 | <a href="https://sonarcloud.io/dashboard?id=jellyfin_jellyfin-kodi"><img src="https://sonarcloud.io/api/project_badges/measure?project=jellyfin_jellyfin-kodi&metric=security_rating" alt="Security Rating" /></a> 29 | <br /> 30 | <a href="https://codecov.io/github/jellyfin/jellyfin-kodi"><img src="https://codecov.io/github/jellyfin/jellyfin-kodi/graph/badge.svg" alt="Code coverage" /></a> 31 | <a href="https://github.com/jellyfin/jellyfin-kodi/actions/workflows/codeql.yaml"><img alt="CodeQL Analysis" src="https://github.com/jellyfin/jellyfin-kodi/actions/workflows/codeql.yaml/badge.svg" /></a> 32 | </p> 33 | 34 | <table> 35 | <thead> 36 | <tr> 37 | <td align="left"> 38 | :warning: Python 2 deprecation (Kodi 18 Leia and older) 39 | </td> 40 | </tr> 41 | </thead> 42 | 43 | <tbody> 44 | <tr> 45 | <td> 46 | <p> 47 | Kodi installs based on Python 2 are no longer supported 48 | going forward. 49 | <br/> 50 | This means that Kodi v18 (Leia) and earlier 51 | (Krypton, Jarvis...) is no longer supported, 52 | and will cease receiving updates. 53 | </p> 54 | <p> 55 | Our informal support target is current release±1, 56 | which currently translates to Nexus (old), Omega (current) and Piers (next). 57 | <br /> 58 | Please note that next release is a moving target, 59 | has a relatively low priority, 60 | and is unlikely to receive active work before the release candidate stage. 61 | </p> 62 | <p> 63 | The major version of Jellyfin for Kodi will be bumped for the first release without Python 2 support. 64 | </p> 65 | </td> 66 | </tr> 67 | </tbody> 68 | </table> 69 | 70 | --- 71 | 72 | **A whole new way to manage and view your media library.** 73 | 74 | The Jellyfin for Kodi add-on combines the best of Kodi - ultra smooth navigation, beautiful UIs and playback of any file under the sun, and Jellyfin - the most powerful open source multi-client media metadata indexer and server. You can now retire your MySQL setup in favor of a more flexible setup. 75 | 76 | Synchronize your media on your Jellyfin server to the native Kodi database, browsing your media at full speed, while retaining the ability to use other Kodi add-ons to enhance your experience. In addition, you can use any Kodi skin you'd like! 77 | 78 | --- 79 | 80 | ### Supported 81 | 82 | The add-on supports a hybrid approach. You can decide which Jellyfin libraries to sync to the Kodi database. Other libraries and features are accessible dynamically, as a plugin listing. 83 | 84 | - Library types available to sync: 85 | - Movies and sets 86 | - TV shows 87 | - Music videos 88 | - Music 89 | - Other features supported: 90 | - Simple Live TV presentation 91 | - Home Videos & photos 92 | - Playlists 93 | - Theme media 94 | - Direct play and transcode 95 | - A 2-way watched and resume state between your server and Kodi. This is a near instant feature. 96 | - Remote control your Kodi; send play commands from your Jellyfin web client or Jellyfin mobile apps. 97 | - Extrafanart (rotating backgrounds) for skins that support it 98 | - Offer to delete content after playback 99 | - Backup your Jellyfin Kodi profile ([Create and restore from backup 100 | ](https://web.archive.org/web/20190202213116/https://github.com/MediaBrowser/plugin.video.emby/wiki/create-and-restore-from-backup)) 101 | - and more... 102 | 103 | ### Install Jellyfin for Kodi 104 | 105 | Detailed installation instructions can be found in the [Jellyfin Client Documentation](https://docs.jellyfin.org/general/clients/kodi.html). 106 | 107 | <!-- Get started with the [wiki guide](https://github.com/MediaBrowser/plugin.video.emby/wiki) --> 108 | 109 | ### Known limitations 110 | 111 | - Chapter images are missing unless native playback mode is used. 112 | - Certain add-ons that depend on seeing where your content is located will not work unless native playback mode is selected. 113 | -------------------------------------------------------------------------------- /resources/language/resource.language.mn_mn/strings.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "PO-Revision-Date: 2025-10-29 21:52+0000\n" 4 | "Last-Translator: Battseren Badral <bbattseren88@gmail.com>\n" 5 | "Language-Team: Mongolian <https://translate.jellyfin.org/projects/jellyfin/" 6 | "jellyfin-kodi/mn/>\n" 7 | "Language: mn_mn\n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "Plural-Forms: nplurals=2; plural=n != 1;\n" 12 | "X-Generator: Weblate 5.14\n" 13 | 14 | msgctxt "#30001" 15 | msgid "Server name" 16 | msgstr "Серверийн нэр" 17 | 18 | msgctxt "#30003" 19 | msgid "Login method" 20 | msgstr "Нэвтрэх арга" 21 | 22 | msgctxt "#30002" 23 | msgid "Force HTTP playback" 24 | msgstr "HTTP хүчээр тоглуулахыг идэвхжүүлэх" 25 | 26 | msgctxt "#29999" 27 | msgid "Jellyfin for Kodi" 28 | msgstr "Kodi-д зориулсан Jellyfin" 29 | 30 | msgctxt "#30000" 31 | msgid "Server address" 32 | msgstr "Server-н хаяг" 33 | 34 | msgctxt "#30004" 35 | msgid "Log level" 36 | msgstr "Бүртгэлийн түвшин" 37 | 38 | msgctxt "#30016" 39 | msgid "Device name" 40 | msgstr "Төхөөрөмжийн нэр" 41 | 42 | msgctxt "#30022" 43 | msgid "Advanced" 44 | msgstr "Нарийвчилсан" 45 | 46 | msgctxt "#30024" 47 | msgid "Username" 48 | msgstr "Хэрэглэгчийн нэр" 49 | 50 | msgctxt "#30091" 51 | msgid "Confirm file deletion" 52 | msgstr "Файл устгахыг баталгаажуулах" 53 | 54 | msgctxt "#30114" 55 | msgid "Offer delete after playback" 56 | msgstr "Тоглуулсны дараа устгах санал болгох" 57 | 58 | msgctxt "#30115" 59 | msgid "For Episodes" 60 | msgstr "Ангиудын хувьд" 61 | 62 | msgctxt "#30116" 63 | msgid "For Movies" 64 | msgstr "Кинонуудын хувьд" 65 | 66 | msgctxt "#30157" 67 | msgid "Enable enhanced artwork (i.e. cover art)" 68 | msgstr "Сайжруулсан зураг (жишээ нь: нүүрний зураг) идэвхжүүлэх" 69 | 70 | msgctxt "#30160" 71 | msgid "Max stream bitrate" 72 | msgstr "Дээд хэмжээний урсгалын битрейт" 73 | 74 | msgctxt "#30161" 75 | msgid "Preferred video codec" 76 | msgstr "Тавигдсан видео кодек" 77 | 78 | msgctxt "#30162" 79 | msgid "Preferred audio codec" 80 | msgstr "Тавигдсан аудио кодек" 81 | 82 | msgctxt "#30163" 83 | msgid "Audio bitrate" 84 | msgstr "Аудио битрейт" 85 | 86 | msgctxt "#30164" 87 | msgid "Audio max channels" 88 | msgstr "Дээд хэмжээний аудио сувгууд" 89 | 90 | msgctxt "#30165" 91 | msgid "Allow burned subtitles" 92 | msgstr "Шатаасан хадмалийг зөвшөөрөх" 93 | 94 | msgctxt "#30170" 95 | msgid "Recently Added TV Shows" 96 | msgstr "Саяхан нэмэгдсэн ТВ нэвтрүүлэгүүд" 97 | 98 | msgctxt "#30171" 99 | msgid "In Progress TV Shows" 100 | msgstr "Гарч байгаа ТВ цувралууд" 101 | 102 | msgctxt "#30174" 103 | msgid "Recently Added Movies" 104 | msgstr "Сүүлд нэмэгдсэн кино" 105 | 106 | msgctxt "#30175" 107 | msgid "Recently Added Episodes" 108 | msgstr "Сүүлд нэмэгдсэн ангиуд" 109 | 110 | msgctxt "#30177" 111 | msgid "In Progress Movies" 112 | msgstr "Гарч байгаа кинонууд" 113 | 114 | msgctxt "#30178" 115 | msgid "In Progress Episodes" 116 | msgstr "Гарч байгаа ангиуд" 117 | 118 | msgctxt "#30179" 119 | msgid "Next Episodes" 120 | msgstr "Дараагийн ангиуд" 121 | 122 | msgctxt "#30180" 123 | msgid "Favorite Movies" 124 | msgstr "Дуртай кинонууд" 125 | 126 | msgctxt "#30181" 127 | msgid "Favorite Shows" 128 | msgstr "Дуртай нэвтрүүлгүүд" 129 | 130 | msgctxt "#30182" 131 | msgid "Favorite Episodes" 132 | msgstr "Дуртай Ангиуд" 133 | 134 | msgctxt "#30185" 135 | msgid "Boxsets" 136 | msgstr "Багц цуврал" 137 | 138 | msgctxt "#30189" 139 | msgid "Unwatched Movies" 140 | msgstr "Үзээгүй кинонууд" 141 | 142 | msgctxt "#30229" 143 | msgid "Random Items" 144 | msgstr "Санамсаргүй зүйлс" 145 | 146 | msgctxt "#30230" 147 | msgid "Recommended Items" 148 | msgstr "Санал болгох зүйлс" 149 | 150 | msgctxt "#30235" 151 | msgid "Interface" 152 | msgstr "Интерфейс" 153 | 154 | msgctxt "#30239" 155 | msgid "Reset local Kodi database" 156 | msgstr "Local Kodi өгөгдлийн санг дахин тохируулах" 157 | 158 | msgctxt "#30249" 159 | msgid "Enable welcome message" 160 | msgstr "Тавтай морилно уу мессежийг идэвхжүүлэх" 161 | 162 | msgctxt "#30251" 163 | msgid "Recently added Home Videos" 164 | msgstr "Саяхан нэмэгдсэн нүүр видеонууд" 165 | 166 | msgctxt "#30252" 167 | msgid "Recently added Photos" 168 | msgstr "Саяхан нэмэгдсэн зурагнууд" 169 | 170 | msgctxt "#30253" 171 | msgid "Favourite Home Videos" 172 | msgstr "Дуртай гэрийн бичлэгүүд" 173 | 174 | msgctxt "#30254" 175 | msgid "Favourite Photos" 176 | msgstr "Дуртай зургууд" 177 | 178 | msgctxt "#30255" 179 | msgid "Favourite Albums" 180 | msgstr "Дуртай цомгууд" 181 | 182 | msgctxt "#30256" 183 | msgid "Recently added Music videos" 184 | msgstr "Саяхан нэмэгдсэн хөгжмийн видеонууд" 185 | 186 | msgctxt "#30257" 187 | msgid "In progress Music videos" 188 | msgstr "Үргэлжилж буй хөгжмийн видеонууд" 189 | 190 | msgctxt "#30258" 191 | msgid "Unwatched Music videos" 192 | msgstr "Үзээгүй хөгжмийн видеонууд" 193 | 194 | msgctxt "#30302" 195 | msgid "Movies" 196 | msgstr "Кинонууд" 197 | 198 | msgctxt "#30305" 199 | msgid "TV Shows" 200 | msgstr "ТВ нэвтрүүлгүүд" 201 | 202 | msgctxt "#30401" 203 | msgid "Jellyfin options" 204 | msgstr "Jellyfin-ийн тохиргоо" 205 | 206 | msgctxt "#30402" 207 | msgid "Jellyfin transcode" 208 | msgstr "Jellyfin хувиргалт" 209 | 210 | msgctxt "#30405" 211 | msgid "Add to favorites" 212 | msgstr "Дуртай жагсаалтад нэмэх" 213 | 214 | msgctxt "#30406" 215 | msgid "Remove from favorites" 216 | msgstr "Дуртай жагсаалтаас хасах" 217 | 218 | msgctxt "#30408" 219 | msgid "Settings" 220 | msgstr "Тохиргоо" 221 | 222 | msgctxt "#30409" 223 | msgid "Delete from Jellyfin" 224 | msgstr "Jellyfin-с устгах" 225 | 226 | msgctxt "#30410" 227 | msgid "Refresh this item" 228 | msgstr "Дахин уншуулах" 229 | 230 | msgctxt "#30412" 231 | msgid "Transcode" 232 | msgstr "Хувиргах" 233 | 234 | msgctxt "#30500" 235 | msgid "Verify connection" 236 | msgstr "Холболт баталгаажуулах" 237 | 238 | msgctxt "#30504" 239 | msgid "Use alternate device name" 240 | msgstr "Өөр төхөөрөмжийн нэр ашиглах" 241 | 242 | msgctxt "#30506" 243 | msgid "Sync" 244 | msgstr "Синхрончлох" 245 | 246 | msgctxt "#30507" 247 | msgid "Enable notification if update count is greater than" 248 | msgstr "Шинэчлэлтийн тооноос их бол мэдэгдэл идэвхжүүлэх" 249 | 250 | msgctxt "#30509" 251 | msgid "Enable music library" 252 | msgstr "Хөгжмийн санг идэвхжүүлэх" 253 | 254 | msgctxt "#30511" 255 | msgid "Playback mode" 256 | msgstr "Тоглуулах горим" 257 | 258 | msgctxt "#30512" 259 | msgid "Enable artwork caching" 260 | msgstr "Зургийн кэшлэлт идэвхжүүлэх" 261 | 262 | msgctxt "#30515" 263 | msgid "Paging - max items requested (default: 15)" 264 | msgstr "Хуудаслалт – хүсэх дээд зүйлсийн тоо (анхдагч: 15)" 265 | 266 | msgctxt "#30516" 267 | msgid "Playback" 268 | msgstr "Тоглуулах" 269 | 270 | msgctxt "#30517" 271 | msgid "Network credentials" 272 | msgstr "Сүлжээний нууц үг/нэр" 273 | 274 | msgctxt "#30518" 275 | msgid "Enable cinema mode" 276 | msgstr "Кино театрын горимыг идэвхжүүлэх" 277 | 278 | msgctxt "#30519" 279 | msgid "Ask to play trailers" 280 | msgstr "Трейлэр тоглуулахыг асуух" 281 | 282 | msgctxt "#30520" 283 | msgid "Skip the delete confirmation (use at your own risk)" 284 | msgstr "Устгах баталгаажуулалтыг алгасах (эрсдлээ тооцож ашиглана уу)" 285 | --------------------------------------------------------------------------------