This commit is contained in:
Dan Riley 2026-04-14 16:58:26 +02:00 committed by GitHub
commit 15987f4b69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 337 additions and 28 deletions

View file

@ -263,6 +263,10 @@ def reset_kodi():
if name not in ["version", "videoversiontype"]:
videodb.cursor.execute("DELETE FROM " + name)
# Delete the custom videoversiontype entries
if name == "videoversiontype":
videodb.cursor.execute("DELETE from videoversiontype WHERE id > 40800 AND owner = 1")
if settings("enableMusic.bool") or dialog("yesno", "{jellyfin}", translate(33162)):
with Database("music") as musicdb:
@ -427,3 +431,17 @@ def get_item(kodi_id, media):
return
return item
def get_item_by_file_id(file_id, media):
"""Get jellyfin item based on kodi file id and media."""
with Database("jellyfin") as jellyfindb:
item = jellyfin_db.JellyfinDatabase(jellyfindb.cursor).get_item_by_file_id(
file_id, media
)
if not item:
LOG.debug("Not an jellyfin item")
return
return item

View file

@ -61,6 +61,15 @@ class JellyfinDatabase:
return self.cursor.fetchall()
def get_item_by_file_id(self, *args):
try:
self.cursor.execute(QU.get_item_by_file_id, args)
return self.cursor.fetchone()[0]
except TypeError:
return
def get_item_by_kodi_id(self, *args):
try:

View file

@ -23,7 +23,7 @@ WHERE parent_id = ?
AND media_type = ?
"""
get_item_by_media_folder = """
SELECT jellyfin_id, jellyfin_type
SELECT jellyfin_id, jellyfin_type, jellyfin_parent_id
FROM jellyfin
WHERE media_folder = ?
"""
@ -39,6 +39,12 @@ FROM jellyfin
WHERE jellyfin_id LIKE ?
"""
get_item_by_wild_obj = ["{Id}"]
get_item_by_file_id = """
SELECT jellyfin_id, parent_id, media_folder, jellyfin_type, checksum
FROM jellyfin
WHERE kodi_fileid = ?
AND media_type = ?
"""
get_item_by_kodi = """
SELECT jellyfin_id, parent_id, media_folder, jellyfin_type, checksum
FROM jellyfin

View file

@ -328,7 +328,9 @@ class GetItemWorker(threading.Thread):
result = self.server.http.request(request, s)
for item in result["Items"]:
# Force Jellyfin to treat version as a movie to get the right metadata
if settings("useVersions") == "true" and item["Type"] == "Video":
item["Type"] = "Movie"
if item["Type"] in self.output:
self.output[item["Type"]].put(item)
except HTTPException as error:

View file

@ -80,6 +80,7 @@ class Service(xbmc.Monitor):
LOG.info("Platform: %s", settings("platformDetected"))
LOG.info("Python Version: %s", sys.version)
LOG.info("Using dynamic paths: %s", settings("useDirectPaths") == "0")
LOG.info("Syncing video versions: %s", settings("useVersions") == "true")
LOG.info("Log Level: %s", self.settings["log_level"])
verify_kodi_defaults()

View file

@ -359,7 +359,9 @@ class FullSync(object):
for x in items:
if x[0] not in current and x[1] == "Movie":
obj.remove(x[0])
# Parent ID tied to main version so check it before removing
if x[2] not in current:
obj.remove(x[0])
@progress()
def tvshows(self, library, dialog):

View file

@ -236,6 +236,11 @@ class API(object):
elif self.item["Container"] == "bluray":
path = "%s/BDMV/index.bdmv" % path
# Loop through configured path replacements searching for a match prior to slash correction
for local_path in self.path_data.keys():
if local_path in path:
path = path.replace(local_path, self.path_data[local_path])
path = path.replace("\\\\", "\\")
if "\\" in path:
@ -245,11 +250,6 @@ class API(object):
protocol = path.split("://")[0]
path = path.replace(protocol, protocol.lower())
# Loop through configured path replacements searching for a match
for local_path in self.path_data.keys():
if local_path in path:
path = path.replace(local_path, self.path_data[local_path])
return path
def get_user_artwork(self, user_id):

View file

@ -95,7 +95,7 @@ class PlayUtils(object):
break
elif not self.is_selection(info) or len(info["MediaSources"]) == 1:
elif not self.is_selection(info) or len(info["MediaSources"]) == 1 or settings("useVersions") == "true":
LOG.info("Skip source selection.")
sources.append(info["MediaSources"][0])

View file

@ -142,6 +142,9 @@ class API(object):
def get_media_folders(self):
return self.users("/Items")
def get_extras(self, item_id):
return self.users("/Items/%s/SpecialFeatures" % item_id)
def get_item(self, item_id):
return self.users("/Items/%s" % item_id)

View file

@ -3,6 +3,7 @@ from __future__ import division, absolute_import, print_function, unicode_litera
#################################################################################################
import os
import threading
import sys
import json
@ -935,6 +936,29 @@ def on_play(data, server):
return
item = server.jellyfin.get_item(item[0])
# If using Video Versions, need to match the actual file as the kodi_id is always the primary
if settings("useVersions") == "true":
# Addon Mode matches the jellyfin_id's properly automatically
# This may need to be done elsewhere as well until Kodi exposes versions properly
# Check the playing file against the primary file; if no match get the right one
playing_file = os.path.basename(file)
primary_file = os.path.basename(item["Path"])
if playing_file != primary_file:
from .kodi import Movies
# If not, search kodi database for the filename and get kodi fileid
with database.Database("video") as videodb:
path_id = Movies(videodb.cursor).get_path(file.replace(playing_file, ""))
file_id = Movies(videodb.cursor).get_file(path_id, playing_file)
# Get the proper jellyfin_id for this file
newitem = database.get_item_by_file_id(file_id, media)
# Get the correct item now with the new id
item = server.jellyfin.get_item(newitem)
item["PlaybackInfo"] = {"Path": file}
playutils.set_properties(
item,

View file

@ -86,21 +86,13 @@ class Kodi(object):
def remove_path(self, *args):
self.cursor.execute(QU.delete_path, args)
def add_file(self, filename, path_id):
def add_file(self, *args):
file_id = self.get_file(*args)
try:
self.cursor.execute(
QU.get_file,
(
filename,
path_id,
),
)
file_id = self.cursor.fetchone()[0]
except TypeError:
if file_id is None:
file_id = self.create_entry_file()
self.cursor.execute(QU.add_file, (file_id, path_id, filename))
self.cursor.execute(QU.add_file, (file_id,) + args)
return file_id
@ -113,6 +105,15 @@ class Kodi(object):
if path_id is not None:
self.cursor.execute(QU.delete_file_by_path, (path_id,) + args)
def get_file(self, *args):
try:
self.cursor.execute(QU.get_file, args)
return self.cursor.fetchone()[0]
except TypeError:
return
def get_filename(self, *args):
try:

View file

@ -1,11 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import division, absolute_import, print_function, unicode_literals
import os
import re
from sqlite3 import DatabaseError
##################################################################################################
from ...helper import LazyLogger
from ...helper import LazyLogger, settings
from .kodi import Kodi
from . import queries as QU
@ -63,6 +65,58 @@ class Movies(Kodi):
if self.cursor.fetchone()[0] == 1:
self.cursor.execute(QU.add_video_version, args)
def update_videoversion(self, *args):
self.cursor.execute(QU.check_video_version)
if self.cursor.fetchone()[0] == 1:
self.cursor.execute(QU.update_video_version, args)
def check_videoversion(self, *args):
self.cursor.execute(QU.count_video_version, args)
return self.cursor.fetchone()[0]
def get_or_create_videoversiontype(self, name, filepath, extra=False):
"""Retrieve or create a video version type based on the Jellyfin version name or filename."""
# If versions are disabled, always return the standard edition
if settings("useVersions") != "true":
return 40400
# Change itemtype for extras. If other types added in the future, need to adjust.
itemtype = self.itemtype + 1 if extra else self.itemtype
# Get the filename without extension
filename = os.path.splitext(os.path.basename(filepath))[0]
# Remove Jellyfin-added suffixes--may need to add others
test_name = re.sub(r"/(3D|DVD|Bluray)$", "", name)
# If the Jellyfin version name matches the filename completely, a good version name
# wasn't created automatically, so extract it, or set to Standard Edition
if not extra and test_name == filename:
# Check for ' - XXXX' at end of the name to use for version name
match = re.search(r" - (.+)$", name)
if match:
name = match.group(1).strip()
else:
name = None
# Set Standard Edition for empty names or DVD/Bluray folders
if not name or filename.lower() in ("index", "video_ts"):
return 40400
# Remove */3D suffixes that Jellyfin adds (ie '.mvc/3D') as long as 3D in the name already
if '3D' in name[:-2]:
name = re.sub(r'\.(\w{3,4})/3D$', lambda m: ' ' + m.group(1).upper(), name)
# Check if this version type already exists and return it
self.cursor.execute(QU.get_video_version_type, (name, itemtype,))
row = self.cursor.fetchone()
if row:
return row[0]
# Create a new version type and return the id
self.cursor.execute(QU.add_video_version_type, (name, 1, itemtype))
return self.cursor.lastrowid
def update(self, *args):
self.cursor.execute(QU.update_movie, args)
@ -72,7 +126,39 @@ class Movies(Kodi):
self.cursor.execute(QU.delete_file, (file_id,))
self.cursor.execute(QU.check_video_version)
if self.cursor.fetchone()[0] == 1:
# Cleanup version types
versions = self.get_videoversions(kodi_id)
type_id = next((row[0] for row in versions if row[1] == file_id), None)
self.cursor.execute(QU.delete_video_version, (file_id,))
self.delete_unused_version_type(type_id)
# Remove all other versions; Jellyfin creates a new base entry if other versions are left
for row in versions:
self.delete_video_version(row[1], row[0])
def delete_video_version(self, file_id, type_id):
"""Remove video version file and cleanup version type if unused."""
self.cursor.execute(QU.delete_file, (file_id,))
self.cursor.execute(QU.delete_video_version, (file_id,))
self.delete_unused_version_type(type_id)
def delete_unused_version_type(self, type_id):
"""Delete video version type if no references exist, and its not a builtin type."""
if type_id and type_id > 40800:
self.cursor.execute(QU.count_video_version_type, (type_id,))
if self.cursor.fetchone()[0] == 0:
self.cursor.execute(QU.delete_video_version_type, (type_id,))
def get_videoversions(self, kodi_id):
self.cursor.execute(QU.get_video_versions, (kodi_id,))
return self.cursor.fetchall()
def check_movie_file_primary(self, kodi_id, file_id):
"""Return True if the movie row with idMovie matches the provided idFile."""
self.cursor.execute(QU.check_movie_file_primary, (kodi_id, file_id))
row = self.cursor.fetchone()
return row is not None
def get_rating_id(self, *args):

View file

@ -70,12 +70,13 @@ FROM files
WHERE idPath = ?
AND strFilename = ?
"""
get_file_obj = ["{FileId}"]
get_file_obj = ["{PathId}", "{Filename}"]
get_filename = """
SELECT strFilename
FROM files
WHERE idFile = ?
"""
get_filename_obj = ["{FileId}"]
get_all_people = """
SELECT name, actor_id
FROM actor
@ -416,8 +417,30 @@ add_video_version_obj = [
"{MovieId}",
"movie",
"{VideoVersionItemType}",
40400,
"{VideoVersionTypeId}",
]
update_video_version = """
UPDATE videoversion
SET idMedia = ?, media_type = ?, itemType = ?, idType = ?
WHERE idFile = ?
"""
update_video_version_obj = [
"{MovieId}",
"movie",
"{VideoVersionItemType}",
"{VideoVersionTypeId}",
"{FileId}",
]
count_video_version = """
SELECT COUNT(*) FROM videoversion WHERE idFile = ? AND idMedia = ? AND idType = ?
"""
count_video_version_obj = ["{FileId}", "{MovieId}", "{VideoVersionTypeId}"]
count_video_version_type = """
SELECT COUNT(*) FROM videoversion WHERE idType = ?
"""
get_video_versions = """
SELECT DISTINCT idType, idFile FROM videoversion WHERE idMedia = ?
"""
get_videoversion_itemtype = """
SELECT itemType FROM videoversiontype WHERE id = ?
"""
@ -425,6 +448,18 @@ get_videoversion_itemtype_obj = ["{VideoVersionId}"]
check_video_version = """
SELECT COUNT(name) FROM sqlite_master WHERE type='table' AND name='videoversion'
"""
get_video_version_type = """
SELECT id FROM videoversiontype WHERE name = ? and itemType = ?
"""
add_video_version_type = """
INSERT INTO videoversiontype(name, owner, itemType) VALUES (?, ?, ?)
"""
get_max_video_version_type = """
SELECT MAX(id) FROM videoversiontype
"""
check_movie_file_primary = """
SELECT 1 FROM movie WHERE idMovie = ? AND idFile = ? LIMIT 1
"""
add_musicvideo = """
INSERT INTO musicvideo(idMVideo, idFile, c00, c04, c05, c06, c07, c08, c09, c10,
c11, c12, premiered)
@ -801,6 +836,11 @@ delete_video_version = """
DELETE FROM videoversion
WHERE idFile = ?
"""
delete_video_version_type = """
DELETE FROM videoversiontype
WHERE id = ?
AND owner = 1
"""
delete_set = """
DELETE FROM sets
WHERE idSet = ?

View file

@ -16,6 +16,7 @@ from ..helper import (
jellyfin_item,
values,
Local,
settings,
)
from ..helper import LazyLogger
from ..helper.utils import find_library
@ -66,6 +67,11 @@ class Movies(KodiDb):
obj["PathId"] = e_item[2]
obj["LibraryId"] = e_item[6]
obj["LibraryName"] = self.jellyfin_db.get_view_name(obj["LibraryId"])
if settings("useVersions") == "true":
# Only process primary movie files, not versions and extras; those are processed in add_versions.
if not self.check_movie_file_primary(obj["MovieId"], obj["FileId"]):
return
except TypeError:
update = False
LOG.debug("MovieId %s not found", obj["Id"])
@ -127,6 +133,7 @@ class Movies(KodiDb):
tags.append("Favorite movies")
obj["Tags"] = tags
obj["media_sources"] = item.get("MediaSources")
if update:
self.movie_update(obj)
@ -142,10 +149,80 @@ class Movies(KodiDb):
self.add_people(*values(obj, QU.add_people_movie_obj))
self.add_streams(*values(obj, QU.add_streams_obj))
self.artwork.add(obj["Artwork"], obj["MovieId"], "movie")
self.artwork.add(obj["Artwork"], obj["FileId"], "videoversion")
self.item_ids.append(obj["Id"])
self.current_type_ids = set()
self.add_versions(API, obj)
self.add_versions(API, obj, True) # Add extras as versions
self.cleanup_versions(obj)
return not update
def add_versions(self, API, obj, extra=False):
"""Add all additional media sources as Kodi versions."""
if settings("useVersions") != "true" or (extra and settings("useExtras") != "true"):
return
sources = self.server.jellyfin.get_extras(obj["Id"]) if extra else obj["media_sources"]
for source in sources:
if obj["Id"] == source["Id"]:
# Found primary version, so skip
continue
# Extras already in the right format from get_extras, but versions need to be pulled
jfitem = source if extra else self.server.jellyfin.get_item(source["Id"])
version = self.objects.map(jfitem, "Movie")
version["MovieId"] = obj["MovieId"]
version["LibraryId"] = obj["LibraryId"]
version["JellyfinParentId"] = obj["Id"]
# Version specific metadata
version["DateAdded"] = Local(version["DateAdded"]).split(".")[0].replace("T", " ")
version["Resume"] = API.adjust_resume((version["Resume"] or 0) / 10000000.0)
version["Runtime"] = round(float((version["Runtime"] or 0) / 10000000.0), 6)
version["PlayCount"] = API.get_playcount(version["Played"], version["PlayCount"])
version["Video"] = API.video_streams(version["Video"] or [], version["Container"])
version["Audio"] = API.audio_streams(version["Audio"] or [])
version["Streams"] = API.media_streams(version["Video"], version["Audio"], version["Subtitles"])
version["Artwork"] = API.get_all_artwork(self.objects.map(jfitem, "Artwork"))
if version["DatePlayed"]:
version["DatePlayed"] = Local(version["DatePlayed"]).split(".")[0].replace("T", " ")
version["Path"] = API.get_file_path(version["Path"])
self.get_path_filename(version)
version["PathId"] = self.add_path(*values(version, QU.add_path_obj))
# Find the correct version name for this source
version_type_id = self.get_or_create_videoversiontype(source.get("Name"), version["SourceFilename"], extra)
version["VideoVersionTypeId"] = version_type_id
version["VideoVersionItemType"] = self.itemtype + 1 if extra else self.itemtype
self.current_type_ids.add(version_type_id)
version["FileId"] = self.get_file(*values(version, QU.get_file_obj))
if version["FileId"]:
# Version already exists
self.update_videoversion(*values(version, QU.update_video_version_obj))
self.jellyfin_db.update_reference(*values(version, QUEM.update_reference_obj))
else:
# Add the version file and version type
version["FileId"] = self.add_file(*values(version, QU.add_file_obj))
self.add_videoversion(*values(version, QU.add_video_version_obj))
self.jellyfin_db.add_reference(*values(version, QUEM.add_reference_movie_obj))
self.update_file(*values(version, QU.update_file_obj))
self.add_playstate(*values(version, QU.add_bookmark_obj))
self.add_streams(*values(version, QU.add_streams_obj))
self.artwork.add(version["Artwork"], version["FileId"], "videoversion")
def cleanup_versions(self, obj):
"""Cleanup versions that are no longer in Jellyfin"""
versions = self.get_videoversions(obj["MovieId"])
for row in versions:
type_id = row[0]
file_id = row[1]
if file_id != obj["FileId"] and type_id not in self.current_type_ids:
self.delete_video_version(file_id, type_id)
def movie_add(self, obj):
"""Add object to kodi."""
obj["RatingId"] = self.create_entry_rating()
@ -158,6 +235,15 @@ class Movies(KodiDb):
obj["FileId"] = self.add_file(*values(obj, QU.add_file_obj))
obj["VideoVersionItemType"] = self.itemtype
version_name = None
for source in obj["media_sources"]:
# First media source isn't always the main version, so find the correct version name for the primary
if obj["Id"] == source["Id"]:
version_name = source.get("Name")
break
version_type_id = self.get_or_create_videoversiontype(version_name, obj["SourceFilename"])
obj["VideoVersionTypeId"] = version_type_id
self.add(*values(obj, QU.add_movie_obj))
self.add_videoversion(*values(obj, QU.add_video_version_obj))
self.jellyfin_db.add_reference(*values(obj, QUEM.add_reference_movie_obj))
@ -217,6 +303,7 @@ class Movies(KodiDb):
if "\\" in obj["Path"]
else obj["Path"].rsplit("/", 1)[1]
)
obj["SourceFilename"] = obj["Filename"]
if self.direct_path:
@ -225,18 +312,20 @@ class Movies(KodiDb):
obj["Path"] = obj["Path"].replace(obj["Filename"], "")
sl = "\\" if "\\" in obj["Path"] else "/"
"""check dvd directories and point it to ./VIDEO_TS/VIDEO_TS.IFO"""
if validate_dvd_dir(obj["Path"] + obj["Filename"]):
obj["Path"] = obj["Path"] + obj["Filename"] + "/VIDEO_TS/"
obj["Path"] = obj["Path"] + obj["Filename"] + sl + "VIDEO_TS" + sl
obj["Filename"] = "VIDEO_TS.IFO"
LOG.debug("DVD directory %s", obj["Path"])
"""check bluray directories and point it to ./BDMV/index.bdmv"""
if validate_bluray_dir(obj["Path"] + obj["Filename"]):
obj["Path"] = obj["Path"] + obj["Filename"] + "/BDMV/"
obj["Path"] = obj["Path"] + obj["Filename"] + sl + "BDMV" + sl
obj["Filename"] = "index.bdmv"
LOG.debug("Bluray directory %s", obj["Path"])
obj["SourceFilename"] = obj["Filename"]
else:
obj["Path"] = "plugin://plugin.video.jellyfin/%s/" % obj["LibraryId"]
params = {

View file

@ -616,7 +616,7 @@ class TVShows(KodiDb):
temp_obj = dict(obj)
temp_obj["Filename"] = self.get_filename(
*values(temp_obj, QU.get_file_obj)
*values(temp_obj, QU.get_filename_obj)
)
temp_obj["Path"] = "plugin://plugin.video.jellyfin/"
self.remove_file(*values(temp_obj, QU.delete_file_obj))
@ -625,7 +625,7 @@ class TVShows(KodiDb):
temp_obj = dict(obj)
temp_obj["Filename"] = self.get_filename(
*values(temp_obj, QU.get_file_obj)
*values(temp_obj, QU.get_filename_obj)
)
temp_obj["PathId"] = self.get_path("plugin://plugin.video.jellyfin/")
temp_obj["FileId"] = self.add_file(*values(temp_obj, QU.add_file_obj))

View file

@ -1257,3 +1257,10 @@ msgctxt "#33261"
msgid "Off"
msgstr "Off"
msgctxt "#30900"
msgid "Sync video versions"
msgstr "Sync video versions"
msgctxt "#30901"
msgid "Sync video extras"
msgstr "Sync video extras"

View file

@ -1180,3 +1180,11 @@ msgstr "16.0 Mbps"
msgctxt "#33239"
msgid "96"
msgstr "96"
msgctxt "#30900"
msgid "Sync video versions"
msgstr "Sync video versions"
msgctxt "#30901"
msgid "Sync video extras"
msgstr "Sync video extras"

View file

@ -108,6 +108,19 @@
</constraints>
<control type="spinner" format="string"/>
</setting>
<setting id="useVersions" type="boolean" label="30900" help="">
<level>0</level>
<default>false</default>
<control type="toggle"/>
</setting>
<setting id="useExtras" type="boolean" label="30901" help="" parent="useVersions">
<level>0</level>
<default>false</default>
<dependencies>
<dependency type="enable" setting="useVersions">true</dependency>
</dependencies>
<control type="toggle"/>
</setting>
</group>
<group id="3" label="33175">
<setting id="limitIndex" type="integer" label="30515" help="">