jellyflood/Shared/Objects/MediaPlayerManager/MediaPlayerItem/MediaPlayerItem+Build.swift

219 lines
7.3 KiB
Swift

//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import Defaults
import Factory
import Foundation
import JellyfinAPI
import Logging
// TODO: build report of determined values for playback information
// - transcode, video stream, path
extension MediaPlayerItem {
/// The main `MediaPlayerItem` builder for normal online usage.
static func build(
for initialItem: BaseItemDto,
mediaSource _initialMediaSource: MediaSourceInfo? = nil,
videoPlayerType: VideoPlayerType = Defaults[.VideoPlayer.videoPlayerType],
requestedBitrate: PlaybackBitrate = Defaults[.VideoPlayer.Playback.appMaximumBitrate],
compatibilityMode: PlaybackCompatibility = Defaults[.VideoPlayer.Playback.compatibilityMode],
modifyItem: ((inout BaseItemDto) -> Void)? = nil
) async throws -> MediaPlayerItem {
let logger = Logger.swiftfin()
guard let itemID = initialItem.id else {
logger.critical("No item ID!")
throw JellyfinAPIError(L10n.unknownError)
}
guard let userSession = Container.shared.currentUserSession() else {
logger.critical("No user session!")
throw JellyfinAPIError(L10n.unknownError)
}
var item = try await initialItem.getFullItem(userSession: userSession)
if let modifyItem {
modifyItem(&item)
}
guard let initialMediaSource = {
if let _initialMediaSource {
return _initialMediaSource
}
if let first = item.mediaSources?.first {
logger.trace("Using first media source for item \(itemID)")
return first
}
return nil
}() else {
logger.error("No media sources for item \(itemID)!")
throw JellyfinAPIError(L10n.unknownError)
}
let maxBitrate = try await requestedBitrate.getMaxBitrate()
let deviceProfile = DeviceProfile.build(
for: videoPlayerType,
compatibilityMode: compatibilityMode,
maxBitrate: maxBitrate
)
var playbackInfo = PlaybackInfoDto()
playbackInfo.isAutoOpenLiveStream = true
playbackInfo.deviceProfile = deviceProfile
playbackInfo.liveStreamID = initialMediaSource.liveStreamID
playbackInfo.maxStreamingBitrate = maxBitrate
playbackInfo.userID = userSession.user.id
let request = Paths.getPostedPlaybackInfo(
itemID: itemID,
playbackInfo
)
let response = try await userSession.client.send(request)
let mediaSource: MediaSourceInfo? = {
guard let mediaSources = response.value.mediaSources else { return nil }
if let matchingTag = mediaSources.first(where: { $0.eTag == initialMediaSource.eTag }) {
return matchingTag
}
for source in mediaSources {
if let openToken = source.openToken,
let id = source.id,
openToken.contains(id)
{
return source
}
}
logger.warning("Unable to find matching media source, defaulting to first media source")
return mediaSources.first
}()
guard let mediaSource else {
throw JellyfinAPIError("Unable to find media source for item")
}
guard let playSessionID = response.value.playSessionID else {
throw JellyfinAPIError("No associated play session ID")
}
let playbackURL = try Self.streamURL(
item: item,
mediaSource: mediaSource,
playSessionID: playSessionID,
userSession: userSession,
logger: logger
)
let previewImageProvider: (any PreviewImageProvider)? = {
let previewImageScrubbingSetting = StoredValues[.User.previewImageScrubbing]
lazy var chapterPreviewImageProvider: ChapterPreviewImageProvider? = {
if let chapters = item.fullChapterInfo, chapters.isNotEmpty {
return ChapterPreviewImageProvider(chapters: chapters)
}
return nil
}()
if case let PreviewImageScrubbingOption.trickplay(fallbackToChapters: fallbackToChapters) = previewImageScrubbingSetting {
if let mediaSourceID = mediaSource.id,
let trickplayInfo = item.trickplay?[mediaSourceID]?.first
{
return TrickplayPreviewImageProvider(
info: trickplayInfo.value,
itemID: itemID,
mediaSourceID: mediaSourceID,
runtime: item.runtime ?? .zero
)
}
if fallbackToChapters {
return chapterPreviewImageProvider
}
} else if previewImageScrubbingSetting == .chapters {
return chapterPreviewImageProvider
}
return nil
}()
return .init(
baseItem: item,
mediaSource: mediaSource,
playSessionID: playSessionID,
url: playbackURL,
requestedBitrate: requestedBitrate,
previewImageProvider: previewImageProvider,
thumbnailProvider: item.getNowPlayingImage
)
}
// TODO: audio type stream
// TODO: build live tv stream from Paths.getLiveHlsStream?
private static func streamURL(
item: BaseItemDto,
mediaSource: MediaSourceInfo,
playSessionID: String,
userSession: UserSession,
logger: Logger
) throws -> URL {
guard let itemID = item.id else {
throw JellyfinAPIError("No item ID while building online media player item!")
}
if let transcodingURL = mediaSource.transcodingURL {
logger.trace("Using transcoding URL for item \(itemID)")
guard let fullTranscodeURL = userSession.client.fullURL(with: transcodingURL)
else { throw JellyfinAPIError("Unable to make transcode URL") }
return fullTranscodeURL
}
if item.mediaType == .video, !item.isLiveStream {
logger.trace("Making video stream URL for item \(itemID)")
let videoStreamParameters = Paths.GetVideoStreamParameters(
isStatic: true,
tag: item.etag,
playSessionID: playSessionID,
mediaSourceID: itemID
)
let videoStreamRequest = Paths.getVideoStream(
itemID: itemID,
parameters: videoStreamParameters
)
guard let videoStreamURL = userSession.client.fullURL(with: videoStreamRequest)
else { throw JellyfinAPIError("Unable to make video stream URL") }
return videoStreamURL
}
logger.trace("Using media source path for item \(itemID)")
guard let path = mediaSource.path, let streamURL = URL(
string: path
) else { throw JellyfinAPIError("Unable to make stream URL") }
return streamURL
}
}