jellyfin-mpv-shim/jellyfin_mpv_shim/media.py

474 lines
16 KiB
Python
Raw Normal View History

2019-08-17 07:06:12 +00:00
import logging
import urllib.parse
import os.path
2020-08-22 15:38:07 +00:00
import re
import pathlib
2023-02-17 02:43:06 +00:00
from io import BytesIO
from sys import platform
2019-08-17 07:06:12 +00:00
2019-08-18 06:02:09 +00:00
from .conf import settings
2020-01-13 04:50:00 +00:00
from .utils import is_local_domain, get_profile, get_seq
2020-08-10 07:33:08 +00:00
from .i18n import _
2019-08-17 07:06:12 +00:00
2020-08-22 15:38:07 +00:00
log = logging.getLogger("media")
2020-08-22 20:13:35 +00:00
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from jellyfin_apiclient_python import JellyfinClient as JellyfinClient_type
2019-08-17 07:06:12 +00:00
class Video(object):
2020-08-22 20:13:35 +00:00
def __init__(
self,
item_id: str,
parent: "Media",
aid: Optional[int] = None,
sid: Optional[int] = None,
srcid: Optional[str] = None,
):
2020-08-22 15:38:07 +00:00
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)
2020-01-12 17:48:32 +00:00
self.is_tv = self.item.get("Type") == "Episode"
2020-08-22 15:38:07 +00:00
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
2020-08-22 15:38:07 +00:00
self.media_source = None
self.srcid = srcid
2023-02-13 04:36:01 +00:00
self.intro_tried = False
self.intro_start = None
self.intro_end = None
2019-08-17 07:06:12 +00:00
def map_streams(self):
2020-08-22 15:38:07 +00:00
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":
2019-08-17 07:06:12 +00:00
return
2020-01-05 19:51:14 +00:00
2020-01-12 17:48:32 +00:00
index = 1
for stream in self.media_source["MediaStreams"]:
if stream.get("Type") != "Audio":
2020-01-12 17:48:32 +00:00
continue
2019-08-17 07:06:12 +00:00
2020-01-12 17:48:32 +00:00
self.audio_uid[index] = stream["Index"]
self.audio_seq[stream["Index"]] = index
2019-08-17 07:06:12 +00:00
2020-08-22 19:00:24 +00:00
if not stream.get("IsExternal"):
index += 1
index = 1
for sub in self.media_source["MediaStreams"]:
if sub.get("Type") != "Subtitle":
continue
2019-08-17 07:06:12 +00:00
if sub.get("DeliveryMethod") == "Embed":
2020-01-12 17:48:32 +00:00
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"])
2020-08-22 19:00:24 +00:00
if not sub.get("IsExternal"):
index += 1
2020-08-22 15:38:07 +00:00
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
2020-08-22 15:38:07 +00:00
if user_sid is not None and self.sid is None:
self.sid = user_sid
2019-08-17 07:06:12 +00:00
2020-01-12 17:48:32 +00:00
def get_current_streams(self):
return self.aid, self.sid
2019-08-17 07:06:12 +00:00
def get_proper_title(self):
if not hasattr(self, "_title"):
2020-01-12 17:48:32 +00:00
title = self.item.get("Name")
2020-08-22 15:38:07 +00:00
if (
self.is_tv
and self.item.get("IndexNumber") is not None
and self.item.get("ParentIndexNumber") is not None
):
2020-01-12 17:48:32 +00:00
episode_number = int(self.item.get("IndexNumber"))
2020-08-22 15:38:07 +00:00
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":
2020-08-22 15:38:07 +00:00
year = self.item.get("ProductionYear")
2020-01-12 17:48:32 +00:00
if year is not None:
title = "%s (%s)" % (title, year)
2019-08-17 07:06:12 +00:00
setattr(self, "_title", title)
2020-08-22 15:38:07 +00:00
return getattr(self, "_title") + (
_(" (Transcode)") if self.is_transcode else ""
)
2019-08-17 07:06:12 +00:00
2020-08-22 20:13:35 +00:00
def set_trs_override(self, video_bitrate: Optional[int], force_transcode: bool):
if force_transcode:
2020-01-12 17:48:32 +00:00
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"
2020-01-12 17:48:32 +00:00
elif self.parent.is_local:
return "max"
else:
2020-01-12 17:48:32 +00:00
return settings.remote_kbps
2019-08-17 07:06:12 +00:00
def terminate_transcode(self):
2020-01-12 17:48:32 +00:00
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)
2020-08-22 19:00:24 +00:00
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.
2020-08-22 15:38:07 +00:00
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"):
2020-08-22 15:38:07 +00:00
# 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
2020-02-24 15:55:43 +00:00
log.debug("Using remote direct path.")
2020-08-22 15:38:07 +00:00
# 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
2020-08-22 15:38:07 +00:00
if os.path.isfile(self.media_source["Path"]):
2020-02-24 15:55:43 +00:00
log.debug("Using local direct path.")
self.is_transcode = False
2020-08-22 15:38:07 +00:00
return self.media_source["Path"]
2020-08-22 15:38:07 +00:00
if self.media_source["SupportsDirectStream"]:
self.is_transcode = False
2020-02-24 15:55:43 +00:00
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,
2020-08-22 15:38:07 +00:00
self.media_source["Id"],
self.client.config.data["auth.token"],
)
2020-08-22 15:38:07 +00:00
elif self.media_source["SupportsTranscoding"]:
2020-02-24 15:55:43 +00:00
log.debug("Using transcode url.")
self.is_transcode = True
2020-08-22 15:38:07 +00:00
return self.client.config.data["auth.server"] + self.media_source.get(
"TranscodingUrl"
)
2020-08-22 20:13:35 +00:00
def get_best_media_source(self, preferred: Optional[str] = None):
2020-01-17 15:37:17 +00:00
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.
2020-08-22 15:38:07 +00:00
weight = (media_source.get("SupportsDirectPlay") or 0) * 50000 + (
media_source.get("Bitrate") or 0
) / 1000
2020-01-17 15:37:17 +00:00
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
2023-02-13 04:36:01 +00:00
def get_intro(self, media_source_id):
if self.intro_tried:
return
self.intro_tried = True
# provided by plugin
try:
2023-02-15 02:33:58 +00:00
skip_intro_data = self.client.jellyfin._get(
f"Episode/{media_source_id}/IntroTimestamps"
)
2023-02-13 04:36:01 +00:00
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:
2023-02-15 02:33:58 +00:00
log.warning(
"Fetching intro data failed. Do you have the plugin installed?",
exc_info=1,
)
2023-02-13 04:36:01 +00:00
2023-02-17 02:43:06 +00:00
def get_chapters(self):
return [
{"start": item["StartPositionTicks"] / 10000000, "name": item["Name"]}
for item in self.item.get("Chapters", [])
if item.get("ImageTag")
2023-02-17 02:43:06 +00:00
]
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()
2023-02-17 02:43:06 +00:00
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()]
2023-02-17 02:43:06 +00:00
if prefer_width is not None:
width = min(available_widths, key=lambda x: abs(x - prefer_width))
2023-02-17 02:43:06 +00:00
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
2023-02-17 02:43:06 +00:00
else:
return None
2020-08-22 20:13:35 +00:00
def get_playback_url(
self,
video_bitrate: Optional[int] = None,
force_transcode: Optional[int] = False,
):
2019-08-17 07:06:12 +00:00
"""
2020-08-22 19:00:24 +00:00
Returns the URL to use for the transcoded file.
2019-08-17 07:06:12 +00:00
"""
self.terminate_transcode()
if self.trs_ovr:
2020-01-12 17:48:32 +00:00
video_bitrate, force_transcode = self.trs_ovr
2020-08-22 15:38:07 +00:00
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)
2020-08-22 15:38:07 +00:00
self.playback_info = self.client.jellyfin.get_play_info(
self.item_id, profile, self.aid, self.sid
)
2020-01-17 15:37:17 +00:00
self.media_source = self.get_best_media_source(self.srcid)
2023-02-13 04:36:01 +00:00
if settings.skip_intro_always or settings.skip_intro_prompt:
self.get_intro(self.media_source["Id"])
self.map_streams()
2020-08-22 19:00:24 +00:00
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:
2020-01-17 15:37:17 +00:00
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()
2020-08-22 19:00:24 +00:00
url = self._get_url_from_source()
if url is not None:
break
2020-08-22 15:38:07 +00:00
2020-02-24 15:55:43 +00:00
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
2020-01-05 19:51:14 +00:00
2019-08-17 07:06:12 +00:00
def get_duration(self):
2020-01-12 17:48:32 +00:00
ticks = self.item.get("RunTimeTicks")
if ticks:
return ticks / 10000000
2019-08-17 07:06:12 +00:00
2020-08-22 20:13:35 +00:00
def set_played(self, watched: bool = True):
2020-01-12 17:48:32 +00:00
self.client.jellyfin.item_played(self.item_id, watched)
2020-08-22 15:38:07 +00:00
2020-08-22 20:13:35 +00:00
def set_streams(self, aid: Optional[int], sid: Optional[int]):
need_restart = False
2020-08-22 15:38:07 +00:00
if aid is not None and self.aid != aid:
self.aid = aid
if self.is_transcode:
need_restart = True
2020-08-22 15:38:07 +00:00
if sid is not None and self.sid != sid:
self.sid = sid
if sid in self.subtitle_enc:
need_restart = True
return need_restart
2019-08-17 07:06:12 +00:00
def get_playlist_id(self):
return self.parent.queue[self.parent.seq]["PlaylistItemId"]
2020-08-22 15:38:07 +00:00
2020-01-12 17:48:32 +00:00
class Media(object):
2020-08-22 15:38:07 +00:00
def __init__(
self,
2020-08-22 20:13:35 +00:00
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,
2020-08-22 15:38:07 +00:00
):
2020-01-13 04:50:00 +00:00
if queue_override:
2020-08-22 15:38:07 +00:00
self.queue = [
{"PlaylistItemId": "playlistItem{0}".format(get_seq()), "Id": id_num}
for id_num in queue
]
2020-01-13 04:50:00 +00:00
else:
self.queue = queue
2020-01-12 17:48:32 +00:00
self.client = client
self.seq = seq
self.user_id = user_id
2020-01-17 15:37:17 +00:00
self.video = Video(self.queue[seq]["Id"], self, aid, sid, srcid)
2020-01-12 17:48:32 +00:00
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
2019-08-18 18:52:13 +00:00
def get_next(self):
if self.has_next:
2020-08-22 15:38:07 +00:00
return Media(
self.client,
self.queue,
self.seq + 1,
self.user_id,
queue_override=False,
)
def get_prev(self):
if self.has_prev:
2020-08-22 15:38:07 +00:00
return Media(
self.client,
self.queue,
self.seq - 1,
self.user_id,
queue_override=False,
)
2020-08-22 20:13:35 +00:00
def get_from_key(self, item_id: str):
2020-01-12 17:48:32 +00:00
for i, video in enumerate(self.queue):
2020-01-13 04:50:00 +00:00
if video["Id"] == item_id:
2020-08-22 15:38:07 +00:00
return Media(
self.client, self.queue, i, self.user_id, queue_override=False
)
2020-01-12 17:48:32 +00:00
return None
2020-08-22 20:13:35 +00:00
def get_video(self, index: int):
if index == 0 and self.video:
2020-01-12 17:48:32 +00:00
return self.video
2020-08-22 15:38:07 +00:00
2020-01-12 17:48:32 +00:00
if index < len(self.queue):
2020-08-10 07:33:08 +00:00
return Video(self.queue[index]["Id"], self)
2019-08-17 07:06:12 +00:00
2020-08-10 07:33:08 +00:00
log.error("Media::get_video couldn't find video at index %s" % index)
2020-08-22 15:38:07 +00:00
2020-08-22 20:13:35 +00:00
def insert_items(self, items, append: bool = False):
2020-08-22 15:38:07 +00:00
items = [
{"PlaylistItemId": "playlistItem{0}".format(get_seq()), "Id": id_num}
for id_num in items
]
2020-01-13 04:50:00 +00:00
if append:
self.queue.extend(items)
else:
2020-08-22 15:38:07 +00:00
self.queue = (
self.queue[0 : self.seq + 1] + items + self.queue[self.seq + 1 :]
)
2020-01-13 04:50:00 +00:00
self.has_next = self.seq < len(self.queue) - 1
def replace_queue(self, sp_items, seq):
"""Update queue for SyncPlay.
2023-02-15 02:33:58 +00:00
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)