import Foundation import MediaKit /// Die Übersetzung der Jellyfin-Modelle in die neutrale Form. /// /// Das Gegenstück zu `PlexItem+Media`. Beide Dateien machen dasselbe, und dass /// es sie zweimal gibt, ist der Preis dafür, dass es die Ansichten nur einmal /// gibt. extension JellyfinItem { public func asMediaItem(imageBase: JellyfinImagePaths) -> MediaItem { MediaItem( id: id, path: nil, kind: Self.kind(type), title: name ?? "—", summary: overview, year: productionYear, duration: runTimeTicks?.millisecondsFromTicks, contentRating: officialRating, // Jellyfin führt beide auf einer Zehnerskala, wie Plex auch. rating: criticRating.map { $0 / 10 } ?? communityRating, audienceRating: communityRating, thumb: imageBase.primary(itemID: id, tag: imageTags?["Primary"]), // Nur wenn es auch eines gibt: die Adresse entsteht aus der // Kennung, nicht aus einer Antwort des Servers — ohne diese // Prüfung stünde hinter jedem Titel ohne Hintergrundbild eine // Adresse, die mit „nicht gefunden" antwortet. Fehlt dem Titel // eines, nennt Jellyfin die Serie, von der es geliehen werden darf. art: backdropImageTags?.first.map { imageBase.backdrop(itemID: id, tag: $0) } ?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) }, parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) }, seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) }, seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) }, index: indexNumber, parentIndex: parentIndexNumber, parentTitle: seasonName, parentID: seasonID, seriesTitle: seriesName, seriesID: seriesID, playCount: userData?.playCount, resumeMilliseconds: userData?.playbackPositionTicks?.millisecondsFromTicks, lastPlayedAt: nil, addedAt: dateCreated, // Das Feld, das es bei Plex nicht gibt — hier kommt es frei mit. isFavourite: userData?.isFavorite ?? false, episodeCount: recursiveItemCount, playedEpisodeCount: playedCount, childCount: childCount, versions: (mediaSources ?? []).map { $0.asMediaVersion(itemID: id) }, genres: genres ?? [], directors: (people ?? []).filter { $0.type == "Director" }.compactMap(\.name), writers: (people ?? []).filter { $0.type == "Writer" }.compactMap(\.name), cast: (people ?? []).filter { $0.type == "Actor" }.map { person in MediaPerson( id: person.id ?? person.name ?? UUID().uuidString, name: person.name ?? "—", role: person.role, thumb: person.id.map { imageBase.primary(itemID: $0, tag: person.primaryImageTag) } ?? nil ) }, markers: [], artwork: logoArtwork(imageBase: imageBase) ) } /// Wie viele Folgen gesehen sind. /// /// Jellyfin meldet die **ungesehenen**. Die Zahl, die die Oberfläche /// zeigt, ist die andere — und sie auszurechnen ist ehrlicher, als das /// Gegenteil anzuzeigen und es „Fortschritt" zu nennen. private var playedCount: Int? { guard let total = recursiveItemCount, let unplayed = userData?.unplayedItemCount else { return nil } return max(0, total - unplayed) } private func logoArtwork(imageBase: JellyfinImagePaths) -> [MediaArtwork] { guard let tag = imageTags?["Logo"] else { return [] } return [MediaArtwork(kind: .clearLogo, path: imageBase.logo(itemID: id, tag: tag))] } private static func kind(_ type: String?) -> MediaItem.Kind? { switch type { case "Movie": .movie case "Series": .show case "Season": .season case "Episode": .episode case "BoxSet": .collection case "Trailer", "Video": .clip default: nil } } } extension JellyfinMediaSource { public func asMediaVersion(itemID: String) -> MediaVersion { let streams = mediaStreams ?? [] let video = streams.first { $0.type == "Video" } let audio = streams.first { $0.type == "Audio" && $0.isDefault == true } ?? streams.first { $0.type == "Audio" } return MediaVersion( id: id ?? itemID, container: container, videoCodec: video?.codec, audioCodec: audio?.codec, videoResolution: video?.height.map(String.init), // Jellyfin rechnet in Bit je Sekunde, die App in Kilobit. bitrate: bitrate.map { $0 / 1_000 }, width: video?.width, height: video?.height, audioChannels: audio?.channels, duration: runTimeTicks?.millisecondsFromTicks, files: [ // Jellyfin trennt Fassung und Datei nicht: eine Quelle ist // beides. Die neutrale Form kennt beide Ebenen, weil Plex sie // trennt — hier ist die zweite eben einelementig. MediaFile( id: id ?? itemID, key: id ?? itemID, itemID: itemID, container: container, size: size, duration: runTimeTicks?.millisecondsFromTicks, streams: streams.compactMap(\.asMediaStream) ) ] ) } } extension JellyfinMediaStream { public var asMediaStream: MediaStream? { let mapped: MediaStream.Kind switch type { case "Video": mapped = .video case "Audio": mapped = .audio case "Subtitle": mapped = .subtitle default: return nil } return MediaStream( id: String(index ?? 0), kind: mapped, codec: codec, language: language, languageCode: language, displayTitle: displayTitle, channels: channels, isSelected: isDefault ?? false, isForced: isForced ?? false ) } } /// Baut die Adressen der Bilder. /// /// Eigener Typ, weil jedes Bild eine eigene Adresse ist und nicht ein Pfad wie /// bei Plex. Die Marke (`tag`) gehört dazu: ohne sie liefert der Server zwar /// ein Bild, aber der Zwischenspeicher merkt nicht, wenn es sich ändert. public struct JellyfinImagePaths: Sendable { public init() {} public func primary(itemID: String, tag: String?) -> String { path(itemID: itemID, kind: "Primary", tag: tag) } public func backdrop(itemID: String, tag: String?) -> String { path(itemID: itemID, kind: "Backdrop", tag: tag) } public func logo(itemID: String, tag: String?) -> String { path(itemID: itemID, kind: "Logo", tag: tag) } private func path(itemID: String, kind: String, tag: String?) -> String { var path = "/Items/\(itemID)/Images/\(kind)" if let tag { path += "?tag=\(tag)" } return path } }