jellyfin-kodi/jellyfin_kodi/player.py
2026-02-11 00:25:51 +01:00

724 lines
23 KiB
Python

# -*- coding: utf-8 -*-
from __future__ import division, absolute_import, print_function, unicode_literals
#################################################################################################
import os
import xbmc
import xbmcvfs
from .objects.obj import Objects
from .helper import translate, api, window, settings, dialog, event, JSONRPC
from .jellyfin import Jellyfin
from .helper import LazyLogger
from .helper.utils import translate_path
#################################################################################################
LOG = LazyLogger(__name__)
#################################################################################################
class Player(xbmc.Player):
played = {}
up_next = False
skip_segments = {}
skip_prompted = set()
skip_dialog = None
def __init__(self):
xbmc.Player.__init__(self)
def get_playing_file(self):
try:
return self.getPlayingFile()
except Exception as error:
LOG.exception(error)
def get_file_info(self, file):
try:
return self.played[file]
except Exception as error:
LOG.exception(error)
def is_playing_file(self, file):
return file in self.played
def onPlayBackStarted(self):
"""We may need to wait for info to be set in kodi monitor.
Accounts for scenario where Kodi starts playback and exits immediately.
First, ensure previous playback terminated correctly in Jellyfin.
"""
self.stop_playback()
self.up_next = False
count = 0
monitor = xbmc.Monitor()
try:
current_file = self.getPlayingFile()
except Exception:
while count < 5:
try:
current_file = self.getPlayingFile()
count = 0
break
except Exception:
count += 1
if monitor.waitForAbort(1):
return
else:
LOG.info("Cancel playback report")
return
items = window("jellyfin_play.json")
item = None
while not items:
if monitor.waitForAbort(2):
return
items = window("jellyfin_play.json")
count += 1
if count == 20:
LOG.info("Could not find jellyfin prop...")
return
for item in items:
if item["Path"] == current_file:
items.pop(items.index(item))
break
else:
item = items.pop(0)
window("jellyfin_play.json", items)
self.set_item(current_file, item)
data = {
"QueueableMediaTypes": "Video,Audio",
"CanSeek": True,
"ItemId": item["Id"],
"MediaSourceId": item["MediaSourceId"],
"PlayMethod": item["PlayMethod"],
"VolumeLevel": item["Volume"],
"PositionTicks": int(item["CurrentPosition"] * 10000000),
"IsPaused": item["Paused"],
"IsMuted": item["Muted"],
"PlaySessionId": item["PlaySessionId"],
"AudioStreamIndex": item["AudioStreamIndex"],
"SubtitleStreamIndex": item["SubtitleStreamIndex"],
}
try:
item["Server"].jellyfin.session_playing(data)
except Exception as e:
LOG.warning("Failed to report session playing: %s", e)
window("jellyfin.skip.%s.bool" % item["Id"], True)
# Immediate skip check for segments starting at 0:00
if settings("mediaSegmentsEnabled.bool"):
try:
self._fetch_skip_segments(item)
current_pos = int(self.getTime())
self.check_skip_segments(item, current_pos)
except Exception:
pass # Player may not be ready yet
if monitor.waitForAbort(2):
return
if item["PlayOption"] == "Addon":
self.set_audio_subs(item["AudioStreamIndex"], item["SubtitleStreamIndex"])
def set_item(self, file, item):
"""Set playback information."""
try:
item["Runtime"] = int(item["Runtime"])
except (TypeError, ValueError):
try:
item["Runtime"] = int(self.getTotalTime())
LOG.info("Runtime is missing, Kodi runtime: %s" % item["Runtime"])
except Exception:
item["Runtime"] = 0
LOG.info("Runtime is missing, Using Zero")
try:
seektime = self.getTime()
except Exception: # at this point we should be playing and if not then bail out
return
result = JSONRPC("Application.GetProperties").execute(
{"properties": ["volume", "muted"]}
)
result = result.get("result", {})
volume = result.get("volume")
muted = result.get("muted")
item.update(
{
"File": file,
"CurrentPosition": item.get("CurrentPosition") or int(seektime),
"Muted": muted,
"Volume": volume,
"Server": Jellyfin(item["ServerId"]).get_client(),
"Paused": False,
}
)
self.played[file] = item
LOG.info("-->[ play/%s ] %s", item["Id"], item)
def set_audio_subs(self, audio=None, subtitle=None):
if audio:
audio = int(audio)
if subtitle:
subtitle = int(subtitle)
""" Only for after playback started
"""
LOG.info("Setting audio: %s subs: %s", audio, subtitle)
current_file = self.get_playing_file()
if self.is_playing_file(current_file):
item = self.get_file_info(current_file)
mapping = item["SubsMapping"]
if audio and len(self.getAvailableAudioStreams()) > 1:
self.setAudioStream(audio - 1)
if subtitle is None or subtitle == -1:
self.showSubtitles(False)
return
tracks = len(self.getAvailableAudioStreams())
if mapping:
for index in mapping:
if mapping[index] == subtitle:
self.setSubtitleStream(int(index))
break
else:
self.setSubtitleStream(len(mapping) + subtitle - tracks - 1)
else:
self.setSubtitleStream(subtitle - tracks - 1)
def detect_audio_subs(self, item):
params = {
"playerid": 1,
"properties": ["currentsubtitle", "currentaudiostream", "subtitleenabled"],
}
result = JSONRPC("Player.GetProperties").execute(params)
result = result.get("result")
try: # Audio tracks
audio = result["currentaudiostream"]["index"]
except (KeyError, TypeError):
audio = 0
try: # Subtitles tracks
subs = result["currentsubtitle"]["index"]
except (KeyError, TypeError):
subs = 0
try: # If subtitles are enabled
subs_enabled = result["subtitleenabled"]
except (KeyError, TypeError):
subs_enabled = False
item["AudioStreamIndex"] = audio + 1
if not subs_enabled or not len(self.getAvailableSubtitleStreams()):
item["SubtitleStreamIndex"] = None
return
mapping = item["SubsMapping"]
tracks = len(self.getAvailableAudioStreams())
if mapping:
if str(subs) in mapping:
item["SubtitleStreamIndex"] = mapping[str(subs)]
else:
item["SubtitleStreamIndex"] = subs - len(mapping) + tracks + 1
else:
item["SubtitleStreamIndex"] = subs + tracks + 1
def next_up(self):
item = self.get_file_info(self.get_playing_file())
objects = Objects()
if item["Type"] != "Episode" or not item.get("CurrentEpisode"):
return
next_items = item["Server"].jellyfin.get_adjacent_episodes(
item["CurrentEpisode"]["tvshowid"], item["Id"]
)
for index, next_item in enumerate(next_items["Items"]):
if next_item["Id"] == item["Id"]:
try:
next_item = next_items["Items"][index + 1]
except IndexError:
LOG.warning("No next up episode.")
return
break
server_address = item["Server"].auth.get_server_info(
item["Server"].auth.server_id
)["address"]
API = api.API(next_item, server_address)
data = objects.map(next_item, "UpNext")
artwork = API.get_all_artwork(objects.map(next_item, "ArtworkParent"), True)
data["art"] = {
"tvshow.poster": artwork.get("Series.Primary"),
"tvshow.fanart": None,
"thumb": artwork.get("Primary"),
}
if artwork["Backdrop"]:
data["art"]["tvshow.fanart"] = artwork["Backdrop"][0]
next_info = {
"play_info": {
"ItemIds": [data["episodeid"]],
"ServerId": item["ServerId"],
"PlayCommand": "PlayNow",
},
"current_episode": item["CurrentEpisode"],
"next_episode": data,
}
LOG.info("--[ next up ] %s", next_info)
event("upnext_data", next_info, hexlify=True)
def onPlayBackPaused(self):
current_file = self.get_playing_file()
if self.is_playing_file(current_file):
self.get_file_info(current_file)["Paused"] = True
self.report_playback()
LOG.debug("-->[ paused ]")
def onPlayBackResumed(self):
current_file = self.get_playing_file()
if self.is_playing_file(current_file):
self.get_file_info(current_file)["Paused"] = False
self.report_playback()
LOG.debug("--<[ paused ]")
def onPlayBackSeek(self, time, seek_offset):
"""Does not seem to work in Leia??"""
if self.is_playing_file(self.get_playing_file()):
self.report_playback()
LOG.info("--[ seek ]")
# Check skip segments immediately after seek
if settings("mediaSegmentsEnabled.bool"):
try:
current_file = self.get_playing_file()
item = self.get_file_info(current_file)
current_pos = int(self.getTime())
self.check_skip_segments(item, current_pos)
except Exception:
pass
def report_playback(self, report=True):
"""Report playback progress to jellyfin server.
Check if the user seek.
"""
current_file = self.get_playing_file()
if not self.is_playing_file(current_file):
return
item = self.get_file_info(current_file)
if window("jellyfin.external.bool"):
return
if settings("mediaSegmentsEnabled.bool"):
self.check_skip_segments(item, item["CurrentPosition"])
if not report:
previous = item["CurrentPosition"]
try:
item["CurrentPosition"] = int(self.getTime())
except Exception as e:
# getTime() raises RuntimeError if nothing is playing
LOG.debug("Failed to get playback position: %s", e)
return
if int(item["CurrentPosition"]) == 1:
return
try:
played = (
float(item["CurrentPosition"] * 10000000)
/ int(item["Runtime"])
* 100
)
except ZeroDivisionError: # Runtime is 0.
played = 0
if played > 2.0 and not self.up_next:
self.up_next = True
self.next_up()
if (item["CurrentPosition"] - previous) < 30:
return
result = JSONRPC("Application.GetProperties").execute(
{"properties": ["volume", "muted"]}
)
result = result.get("result", {})
item["Volume"] = result.get("volume")
item["Muted"] = result.get("muted")
item["CurrentPosition"] = int(self.getTime())
self.detect_audio_subs(item)
data = {
"QueueableMediaTypes": "Video,Audio",
"CanSeek": True,
"ItemId": item["Id"],
"MediaSourceId": item["MediaSourceId"],
"PlayMethod": item["PlayMethod"],
"VolumeLevel": item["Volume"],
"PositionTicks": int(item["CurrentPosition"] * 10000000),
"IsPaused": item["Paused"],
"IsMuted": item["Muted"],
"PlaySessionId": item["PlaySessionId"],
"AudioStreamIndex": item["AudioStreamIndex"],
"SubtitleStreamIndex": item["SubtitleStreamIndex"],
}
item["Server"].jellyfin.session_progress(data)
def onPlayBackStopped(self):
"""Will be called when user stops playing a file."""
window("jellyfin_play", clear=True)
self.stop_playback()
LOG.info("--<[ playback ]")
def onPlayBackEnded(self):
"""Will be called when kodi stops playing a file."""
self.stop_playback()
LOG.info("--<<[ playback ]")
def stop_playback(self):
"""Stop all playback. Check for external player for positionticks."""
if not self.played:
return
LOG.info("Played info: %s", self.played)
for file in self.played:
item = self.get_file_info(file)
window("jellyfin.skip.%s.bool" % item["Id"], True)
if window("jellyfin.external.bool"):
window("jellyfin.external", clear=True)
if int(item["CurrentPosition"]) == 1:
item["CurrentPosition"] = int(item["Runtime"])
data = {
"ItemId": item["Id"],
"MediaSourceId": item["MediaSourceId"],
"PositionTicks": int(item["CurrentPosition"] * 10000000),
"PlaySessionId": item["PlaySessionId"],
}
item["Server"].jellyfin.session_stop(data)
if item.get("LiveStreamId"):
LOG.info("<[ livestream/%s ]", item["LiveStreamId"])
item["Server"].jellyfin.close_live_stream(item["LiveStreamId"])
elif item["PlayMethod"] == "Transcode":
LOG.info("<[ transcode/%s ]", item["Id"])
item["Server"].jellyfin.close_transcode(
item["DeviceId"], item["PlaySessionId"]
)
path = translate_path(
"special://profile/addon_data/plugin.video.jellyfin/temp/"
)
if xbmcvfs.exists(path):
dirs, files = xbmcvfs.listdir(path)
for file in files:
# Only delete the cached files for the previous play session
if item["Id"] in file:
xbmcvfs.delete(os.path.join(path, file))
result = item["Server"].jellyfin.get_item(item["Id"]) or {}
if "UserData" in result and result["UserData"]["Played"]:
delete = False
if result["Type"] == "Episode" and settings("deleteTV.bool"):
delete = True
elif result["Type"] == "Movie" and settings("deleteMovies.bool"):
delete = True
if not settings("offerDelete.bool"):
delete = False
if delete:
LOG.info("Offer delete option")
if dialog(
"yesno", translate(30091), translate(33015), autoclose=120000
):
item["Server"].jellyfin.delete_item(item["Id"])
window("jellyfin.external_check", clear=True)
self.played.clear()
def _fetch_skip_segments(self, item):
if not settings("mediaSegmentsEnabled.bool"):
return
item_id = item["Id"]
self.skip_segments.pop(item_id, None)
self.skip_prompted = set()
if self.skip_dialog:
try:
self.skip_dialog.close()
except Exception:
pass
self.skip_dialog = None
segments = item["Server"].jellyfin.get_media_segments(item_id)
if segments:
segments = self._convert_media_segments(segments)
if segments:
self.skip_segments[item_id] = segments
LOG.info("Loaded media segments for %s: %s", item_id, list(segments.keys()))
def _convert_media_segments(self, response):
if not response or "Items" not in response:
return None
type_map = {
"Intro": "Introduction",
"Outro": "Credits",
"Recap": "Recap",
"Preview": "Preview",
"Commercial": "Commercial",
}
segments = {}
for item in response["Items"]:
seg_type = type_map.get(item.get("Type"))
if seg_type:
segments[seg_type] = {
"EpisodeId": item.get("ItemId"),
"Start": item.get("StartTicks", 0) / 10000000.0,
"End": item.get("EndTicks", 0) / 10000000.0,
}
return segments if segments else None
def _process_segment(
self, item_id, segment_type, segment, current_position, skip_mode
):
"""Check if current position is within segment bounds. Returns (start, end) tuple, None if outside bounds, or False if invalid."""
start = segment.get("Start")
end = segment.get("End")
if start is None or end is None or end <= start:
return False
LOG.debug(
"Skip check: pos=%.1f, %s start=%.1f end=%.1f, in_segment=%s",
current_position,
segment_type,
start,
end,
start <= current_position <= end,
)
if not (start <= current_position <= end):
return None
return (start, end)
def check_skip_segments(self, item, current_position):
item_id = item["Id"]
segments = self.skip_segments.get(item_id)
if not segments:
return
for segment_type, segment in segments.items():
skip_mode = self._get_segment_skip_mode(segment_type)
if skip_mode == 0: # Off
continue
bounds = self._process_segment(
item_id, segment_type, segment, current_position, skip_mode
)
if not bounds:
continue
start, end = bounds
segment_key = "%s:%s" % (item_id, segment_type)
LOG.debug(
"Skip check: IN WINDOW! segment_key=%s, already_prompted=%s",
segment_key,
segment_key in self.skip_prompted,
)
if segment_key in self.skip_prompted:
continue
self.skip_prompted.add(segment_key)
LOG.debug(
"Skip check: Triggering _handle_skip_segment for %s", segment_type
)
if segment_type == "Credits" and not self.up_next:
self.up_next = True
self.next_up()
self._handle_skip_segment(segment_type, start, end, skip_mode)
break
def _get_segment_skip_mode(self, segment_type):
"""Get the skip mode for a segment type. Returns 0=Off, 1=Auto, 2=Button."""
setting_map = {
"Introduction": "skipIntroductionMode",
"Credits": "skipCreditsMode",
"Recap": "skipRecapMode",
"Preview": "skipPreviewMode",
"Commercial": "skipCommercialMode",
}
setting_key = setting_map.get(segment_type)
if not setting_key:
return 0
return int(settings(setting_key) or 0)
def _handle_skip_segment(self, segment_type, start, end, mode):
LOG.debug(
"_handle_skip_segment: type=%s, mode=%d, start=%.1f, end=%.1f",
segment_type,
mode,
start,
end,
)
if mode == 1: # Auto skip
self.seekTime(end)
LOG.info("Auto-skipped %s to %.1f", segment_type, end)
# Show notification
message = "Skipped %s" % segment_type
dialog(
"notification",
heading="Jellyfin",
message=message,
icon="{jellyfin}",
time=3000,
)
elif mode == 2: # Show skip button
self._show_skip_button(segment_type, end - start, end)
def _show_skip_button(self, segment_type, duration, end_time):
LOG.debug(
"_show_skip_button: type=%s, duration=%.1f, end_time=%.1f",
segment_type,
duration,
end_time,
)
try:
import xbmcaddon
from .dialogs.skip import SkipDialog
if self.skip_dialog:
try:
self.skip_dialog.close()
except Exception:
pass
addon_path = xbmcaddon.Addon("plugin.video.jellyfin").getAddonInfo("path")
LOG.debug("_show_skip_button: addon_path=%s", addon_path)
self.skip_dialog = SkipDialog(
"script-jellyfin-skip.xml",
addon_path,
"default",
"1080i",
)
self.skip_dialog.set_skip_info(segment_type, duration)
LOG.debug("_show_skip_button: calling show()")
self.skip_dialog.show()
LOG.debug("_show_skip_button: show() completed")
self._skip_end_time = end_time
self._monitor_skip_dialog()
except Exception as e:
LOG.error("_show_skip_button error: %s", e, exc_info=True)
def _monitor_skip_dialog(self):
"""Monitor the skip dialog and handle user input or timeout."""
LOG.debug("_monitor_skip_dialog: starting, end_time=%.1f", self._skip_end_time)
monitor = xbmc.Monitor()
# Monitor loop - check for user input or end of segment
while self.skip_dialog and not monitor.abortRequested():
# Check if user clicked skip
if self.skip_dialog.is_skip():
self.seekTime(self._skip_end_time)
LOG.info("User skipped to %.1f", self._skip_end_time)
break
# Check if user cancelled
if self.skip_dialog.is_cancel():
LOG.debug("User cancelled skip dialog")
break
# Check if we've passed the segment end time
try:
current_pos = self.getTime()
if current_pos >= self._skip_end_time:
LOG.debug(
"_monitor_skip_dialog: passed end_time %.1f, closing",
self._skip_end_time,
)
break
except Exception:
break
if monitor.waitForAbort(0.2):
break
LOG.debug("_monitor_skip_dialog: exiting loop")
if self.skip_dialog:
try:
self.skip_dialog.close()
except Exception:
pass
self.skip_dialog = None