mirror of
https://github.com/jellyfin/jellyfin-mpv-shim.git
synced 2024-11-27 00:00:37 +00:00
474 lines
16 KiB
Python
474 lines
16 KiB
Python
import logging
|
|
import urllib.parse
|
|
import os.path
|
|
import re
|
|
import pathlib
|
|
from io import BytesIO
|
|
from sys import platform
|
|
|
|
from .conf import settings
|
|
from .utils import is_local_domain, get_profile, get_seq
|
|
from .i18n import _
|
|
|
|
log = logging.getLogger("media")
|
|
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
if TYPE_CHECKING:
|
|
from jellyfin_apiclient_python import JellyfinClient as JellyfinClient_type
|
|
|
|
|
|
class Video(object):
|
|
def __init__(
|
|
self,
|
|
item_id: str,
|
|
parent: "Media",
|
|
aid: Optional[int] = None,
|
|
sid: Optional[int] = None,
|
|
srcid: Optional[str] = None,
|
|
):
|
|
self.item_id = item_id
|
|
self.parent = parent
|
|
self.client = parent.client
|
|
self.aid = aid
|
|
self.sid = sid
|
|
self.item = self.client.jellyfin.get_item(item_id)
|
|
|
|
self.is_tv = self.item.get("Type") == "Episode"
|
|
|
|
self.subtitle_seq = {}
|
|
self.subtitle_uid = {}
|
|
self.subtitle_url = {}
|
|
self.subtitle_enc = set()
|
|
self.audio_seq = {}
|
|
self.audio_uid = {}
|
|
self.is_transcode = False
|
|
self.trs_ovr = None
|
|
self.playback_info = None
|
|
self.media_source = None
|
|
self.srcid = srcid
|
|
self.intro_tried = False
|
|
self.intro_start = None
|
|
self.intro_end = None
|
|
|
|
def map_streams(self):
|
|
self.subtitle_seq = {}
|
|
self.subtitle_uid = {}
|
|
self.subtitle_url = {}
|
|
self.subtitle_enc = set()
|
|
self.audio_seq = {}
|
|
self.audio_uid = {}
|
|
|
|
if self.media_source is None or self.media_source["Protocol"] != "File":
|
|
return
|
|
|
|
index = 1
|
|
for stream in self.media_source["MediaStreams"]:
|
|
if stream.get("Type") != "Audio":
|
|
continue
|
|
|
|
self.audio_uid[index] = stream["Index"]
|
|
self.audio_seq[stream["Index"]] = index
|
|
|
|
if not stream.get("IsExternal"):
|
|
index += 1
|
|
|
|
index = 1
|
|
for sub in self.media_source["MediaStreams"]:
|
|
if sub.get("Type") != "Subtitle":
|
|
continue
|
|
|
|
if sub.get("DeliveryMethod") == "Embed":
|
|
self.subtitle_uid[index] = sub["Index"]
|
|
self.subtitle_seq[sub["Index"]] = index
|
|
elif sub.get("DeliveryMethod") == "External":
|
|
url = sub.get("DeliveryUrl")
|
|
if not sub.get("IsExternalUrl"):
|
|
url = self.client.config.data["auth.server"] + url
|
|
self.subtitle_url[sub["Index"]] = url
|
|
elif sub.get("DeliveryMethod") == "Encode":
|
|
self.subtitle_enc.add(sub["Index"])
|
|
|
|
if not sub.get("IsExternal"):
|
|
index += 1
|
|
|
|
user_aid = self.media_source.get("DefaultAudioStreamIndex")
|
|
user_sid = self.media_source.get("DefaultSubtitleStreamIndex")
|
|
|
|
if user_aid is not None and self.aid is None:
|
|
self.aid = user_aid
|
|
|
|
if user_sid is not None and self.sid is None:
|
|
self.sid = user_sid
|
|
|
|
def get_current_streams(self):
|
|
return self.aid, self.sid
|
|
|
|
def get_proper_title(self):
|
|
if not hasattr(self, "_title"):
|
|
title = self.item.get("Name")
|
|
if (
|
|
self.is_tv
|
|
and self.item.get("IndexNumber") is not None
|
|
and self.item.get("ParentIndexNumber") is not None
|
|
):
|
|
episode_number = int(self.item.get("IndexNumber"))
|
|
season_number = int(self.item.get("ParentIndexNumber"))
|
|
series_name = self.item.get("SeriesName")
|
|
title = "%s - s%de%.2d - %s" % (
|
|
series_name,
|
|
season_number,
|
|
episode_number,
|
|
title,
|
|
)
|
|
elif self.item.get("Type") == "Movie":
|
|
year = self.item.get("ProductionYear")
|
|
if year is not None:
|
|
title = "%s (%s)" % (title, year)
|
|
setattr(self, "_title", title)
|
|
return getattr(self, "_title") + (
|
|
_(" (Transcode)") if self.is_transcode else ""
|
|
)
|
|
|
|
def set_trs_override(self, video_bitrate: Optional[int], force_transcode: bool):
|
|
if force_transcode:
|
|
self.trs_ovr = (video_bitrate, force_transcode)
|
|
else:
|
|
self.trs_ovr = None
|
|
|
|
def get_transcode_bitrate(self):
|
|
if not self.is_transcode:
|
|
return "none"
|
|
elif self.trs_ovr is not None:
|
|
if self.trs_ovr[0] is not None:
|
|
return self.trs_ovr[0]
|
|
elif self.trs_ovr[1]:
|
|
return "max"
|
|
elif self.parent.is_local:
|
|
return "max"
|
|
else:
|
|
return settings.remote_kbps
|
|
|
|
def terminate_transcode(self):
|
|
if self.is_transcode:
|
|
try:
|
|
self.client.jellyfin.close_transcode(
|
|
self.client.config.data["app.device_id"]
|
|
)
|
|
except:
|
|
log.warning("Terminating transcode failed.", exc_info=1)
|
|
|
|
def _get_url_from_source(self):
|
|
# Only use Direct Paths if:
|
|
# - The media source supports direct paths.
|
|
# - Direct paths are enabled in the config.
|
|
# - The server is local or the override config is set.
|
|
# - If there's a scheme specified or the path exists as a local file.
|
|
if (
|
|
(
|
|
self.media_source.get("Protocol") == "Http"
|
|
or self.media_source["SupportsDirectPlay"]
|
|
)
|
|
and settings.direct_paths
|
|
and (settings.remote_direct_paths or self.parent.is_local)
|
|
):
|
|
|
|
if platform.startswith("win32") or platform.startswith("cygwin"):
|
|
# matches on SMB scheme
|
|
match = re.search("(?:\\\\).+:.*@(.+)", self.media_source["Path"])
|
|
if match:
|
|
# replace forward slash to backward slashes
|
|
log.debug("cleaned up credentials from path")
|
|
self.media_source["Path"] = str(
|
|
pathlib.Path("\\\\" + match.groups()[0])
|
|
)
|
|
|
|
if urllib.parse.urlparse(self.media_source["Path"]).scheme:
|
|
self.is_transcode = False
|
|
log.debug("Using remote direct path.")
|
|
# translate path for windows
|
|
# if path is smb path in credential format for kodi and maybe linux \\username:password@mediaserver\foo,
|
|
# translate it to mediaserver/foo
|
|
return str(pathlib.Path(self.media_source["Path"]))
|
|
else:
|
|
# If there's no uri scheme, check if the file exixsts because it might not be mounted
|
|
if os.path.isfile(self.media_source["Path"]):
|
|
log.debug("Using local direct path.")
|
|
self.is_transcode = False
|
|
return self.media_source["Path"]
|
|
|
|
if self.media_source["SupportsDirectStream"]:
|
|
self.is_transcode = False
|
|
log.debug("Using direct url.")
|
|
return "%s/Videos/%s/stream?static=true&MediaSourceId=%s&api_key=%s" % (
|
|
self.client.config.data["auth.server"],
|
|
self.item_id,
|
|
self.media_source["Id"],
|
|
self.client.config.data["auth.token"],
|
|
)
|
|
elif self.media_source["SupportsTranscoding"]:
|
|
log.debug("Using transcode url.")
|
|
self.is_transcode = True
|
|
return self.client.config.data["auth.server"] + self.media_source.get(
|
|
"TranscodingUrl"
|
|
)
|
|
|
|
def get_best_media_source(self, preferred: Optional[str] = None):
|
|
weight_selected = 0
|
|
preferred_selected = None
|
|
selected = None
|
|
for media_source in self.playback_info["MediaSources"]:
|
|
if media_source.get("Id") == preferred:
|
|
preferred_selected = media_source
|
|
# Prefer the highest bitrate file that will direct play.
|
|
weight = (media_source.get("SupportsDirectPlay") or 0) * 50000 + (
|
|
media_source.get("Bitrate") or 0
|
|
) / 1000
|
|
if weight > weight_selected:
|
|
weight_selected = weight
|
|
selected = media_source
|
|
if preferred_selected:
|
|
return preferred_selected
|
|
else:
|
|
if preferred is not None:
|
|
log.warning("Preferred media source is unplayable.")
|
|
return selected
|
|
|
|
def get_intro(self, media_source_id):
|
|
if self.intro_tried:
|
|
return
|
|
self.intro_tried = True
|
|
|
|
# provided by plugin
|
|
try:
|
|
skip_intro_data = self.client.jellyfin._get(
|
|
f"Episode/{media_source_id}/IntroTimestamps"
|
|
)
|
|
if skip_intro_data is not None and skip_intro_data["Valid"]:
|
|
self.intro_start = skip_intro_data["IntroStart"]
|
|
self.intro_end = skip_intro_data["IntroEnd"]
|
|
except:
|
|
log.warning(
|
|
"Fetching intro data failed. Do you have the plugin installed?",
|
|
exc_info=1,
|
|
)
|
|
|
|
def get_chapters(self):
|
|
return [
|
|
{"start": item["StartPositionTicks"] / 10000000, "name": item["Name"]}
|
|
for item in self.item.get("Chapters", [])
|
|
if item.get("ImageTag")
|
|
]
|
|
|
|
def get_chapter_images(self, max_width=400, quality=90):
|
|
for i, item in enumerate(self.item.get("Chapters", [])):
|
|
data = BytesIO()
|
|
self.client.jellyfin._get_stream(
|
|
f"Items/{self.item_id}/Images/Chapter/{i}",
|
|
data,
|
|
{"tag": item["ImageTag"], "maxWidth": max_width, "quality": quality},
|
|
)
|
|
yield data.getvalue()
|
|
|
|
def get_hls_tile_images(self, width, count):
|
|
for i in range(1, count + 1):
|
|
data = BytesIO()
|
|
self.client.jellyfin._get_stream(
|
|
f"Trickplay/{self.media_source['Id']}/{width}/{i}.jpg", data
|
|
)
|
|
yield data.getvalue()
|
|
|
|
def get_bif(self, prefer_width=320):
|
|
# requires JellyScrub plugin
|
|
manifest = self.client.jellyfin._get(
|
|
f"Trickplay/{self.media_source['Id']}/GetManifest"
|
|
)
|
|
if (
|
|
manifest is not None
|
|
and manifest.get("WidthResolutions") is not None
|
|
and len(manifest["WidthResolutions"]) > 0
|
|
):
|
|
available_widths = manifest["WidthResolutions"]
|
|
if type(manifest["WidthResolutions"]) is dict:
|
|
available_widths = [int(x) for x in manifest["WidthResolutions"].keys()]
|
|
|
|
if prefer_width is not None:
|
|
width = min(available_widths, key=lambda x: abs(x - prefer_width))
|
|
else:
|
|
width = max(available_widths)
|
|
|
|
if type(manifest["WidthResolutions"]) is dict:
|
|
return manifest["WidthResolutions"][str(width)]
|
|
else:
|
|
data = BytesIO()
|
|
self.client.jellyfin._get_stream(
|
|
f"Trickplay/{self.media_source['Id']}/{width}/GetBIF", data
|
|
)
|
|
|
|
data.seek(0)
|
|
return data
|
|
else:
|
|
return None
|
|
|
|
def get_playback_url(
|
|
self,
|
|
video_bitrate: Optional[int] = None,
|
|
force_transcode: Optional[int] = False,
|
|
):
|
|
"""
|
|
Returns the URL to use for the transcoded file.
|
|
"""
|
|
self.terminate_transcode()
|
|
|
|
if self.trs_ovr:
|
|
video_bitrate, force_transcode = self.trs_ovr
|
|
|
|
log.debug(
|
|
"Bandwidth: local={0}, bitrate={1}, force={2}".format(
|
|
self.parent.is_local, video_bitrate, force_transcode
|
|
)
|
|
)
|
|
profile = get_profile(not self.parent.is_local, video_bitrate, force_transcode)
|
|
self.playback_info = self.client.jellyfin.get_play_info(
|
|
self.item_id, profile, self.aid, self.sid
|
|
)
|
|
|
|
self.media_source = self.get_best_media_source(self.srcid)
|
|
if settings.skip_intro_always or settings.skip_intro_prompt:
|
|
self.get_intro(self.media_source["Id"])
|
|
|
|
self.map_streams()
|
|
url = self._get_url_from_source()
|
|
|
|
# If there are more media sources and the default one fails, try all of them.
|
|
if url is None and len(self.playback_info["MediaSources"]) > 1:
|
|
log.warning("Selected media source is unplayable.")
|
|
for media_source in self.playback_info["MediaSources"]:
|
|
if media_source["Id"] != self.srcid:
|
|
self.media_source = media_source
|
|
self.map_streams()
|
|
url = self._get_url_from_source()
|
|
if url is not None:
|
|
break
|
|
|
|
if settings.log_decisions:
|
|
if len(self.playback_info["MediaSources"]) > 1:
|
|
log.debug("Full Playback Info: {0}".format(self.playback_info))
|
|
log.debug("Media Decision: {0}".format(self.media_source))
|
|
return url
|
|
|
|
def get_duration(self):
|
|
ticks = self.item.get("RunTimeTicks")
|
|
if ticks:
|
|
return ticks / 10000000
|
|
|
|
def set_played(self, watched: bool = True):
|
|
self.client.jellyfin.item_played(self.item_id, watched)
|
|
|
|
def set_streams(self, aid: Optional[int], sid: Optional[int]):
|
|
need_restart = False
|
|
|
|
if aid is not None and self.aid != aid:
|
|
self.aid = aid
|
|
if self.is_transcode:
|
|
need_restart = True
|
|
|
|
if sid is not None and self.sid != sid:
|
|
self.sid = sid
|
|
if sid in self.subtitle_enc:
|
|
need_restart = True
|
|
|
|
return need_restart
|
|
|
|
def get_playlist_id(self):
|
|
return self.parent.queue[self.parent.seq]["PlaylistItemId"]
|
|
|
|
|
|
class Media(object):
|
|
def __init__(
|
|
self,
|
|
client: "JellyfinClient_type",
|
|
queue: list,
|
|
seq: int = 0,
|
|
user_id: Optional[str] = None,
|
|
aid: Optional[int] = None,
|
|
sid: Optional[int] = None,
|
|
srcid: Optional[str] = None,
|
|
queue_override: bool = True,
|
|
):
|
|
if queue_override:
|
|
self.queue = [
|
|
{"PlaylistItemId": "playlistItem{0}".format(get_seq()), "Id": id_num}
|
|
for id_num in queue
|
|
]
|
|
else:
|
|
self.queue = queue
|
|
self.client = client
|
|
self.seq = seq
|
|
self.user_id = user_id
|
|
|
|
self.video = Video(self.queue[seq]["Id"], self, aid, sid, srcid)
|
|
self.is_tv = self.video.is_tv
|
|
self.is_local = is_local_domain(client)
|
|
self.has_next = seq < len(queue) - 1
|
|
self.has_prev = seq > 0
|
|
|
|
def get_next(self):
|
|
if self.has_next:
|
|
return Media(
|
|
self.client,
|
|
self.queue,
|
|
self.seq + 1,
|
|
self.user_id,
|
|
queue_override=False,
|
|
)
|
|
|
|
def get_prev(self):
|
|
if self.has_prev:
|
|
return Media(
|
|
self.client,
|
|
self.queue,
|
|
self.seq - 1,
|
|
self.user_id,
|
|
queue_override=False,
|
|
)
|
|
|
|
def get_from_key(self, item_id: str):
|
|
for i, video in enumerate(self.queue):
|
|
if video["Id"] == item_id:
|
|
return Media(
|
|
self.client, self.queue, i, self.user_id, queue_override=False
|
|
)
|
|
return None
|
|
|
|
def get_video(self, index: int):
|
|
if index == 0 and self.video:
|
|
return self.video
|
|
|
|
if index < len(self.queue):
|
|
return Video(self.queue[index]["Id"], self)
|
|
|
|
log.error("Media::get_video couldn't find video at index %s" % index)
|
|
|
|
def insert_items(self, items, append: bool = False):
|
|
items = [
|
|
{"PlaylistItemId": "playlistItem{0}".format(get_seq()), "Id": id_num}
|
|
for id_num in items
|
|
]
|
|
if append:
|
|
self.queue.extend(items)
|
|
else:
|
|
self.queue = (
|
|
self.queue[0 : self.seq + 1] + items + self.queue[self.seq + 1 :]
|
|
)
|
|
self.has_next = self.seq < len(self.queue) - 1
|
|
|
|
def replace_queue(self, sp_items, seq):
|
|
"""Update queue for SyncPlay.
|
|
Returns None if the video is the same or a new Media if not."""
|
|
if self.queue[self.seq]["Id"] == sp_items[seq]["Id"]:
|
|
self.queue, self.seq = sp_items, seq
|
|
return None
|
|
else:
|
|
return Media(self.client, sp_items, seq, self.user_id, queue_override=False)
|