From 7cde663436fb39021a71f84afe80a285c7503c83 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Wed, 29 Apr 2026 20:34:02 -0700 Subject: [PATCH 1/3] Add stream diagnostics to session cards --- Sources/PlexBar/Models/PlexSession.swift | 141 ++++++++++- .../Models/PlexStreamDiagnostics.swift | 234 ++++++++++++++++++ .../Resources/MockServer/mock-server.json | 201 ++++++++++++++- .../PlexBar/Support/PlexDebugMockServer.swift | 194 +++++++++++++-- .../Support/PlexMockServerPayload.swift | 78 ++++++ .../PlexBar/Views/MenuBarContentView.swift | 13 +- Sources/PlexBar/Views/StreamCardView.swift | 97 +++++++- .../PlexDebugMockServerTests.swift | 23 ++ Tests/PlexBarTests/PlexSessionTests.swift | 74 +++++- 9 files changed, 1019 insertions(+), 36 deletions(-) create mode 100644 Sources/PlexBar/Models/PlexStreamDiagnostics.swift diff --git a/Sources/PlexBar/Models/PlexSession.swift b/Sources/PlexBar/Models/PlexSession.swift index 18e30f1..5085a8f 100644 --- a/Sources/PlexBar/Models/PlexSession.swift +++ b/Sources/PlexBar/Models/PlexSession.swift @@ -346,6 +346,10 @@ struct PlexSession: Decodable, Identifiable { [playbackStatusDisplayName, decisionDisplayName, locationDisplayName].compactMap { $0?.nilIfBlank }.joined(separator: " • ") } + var streamDiagnostics: PlexStreamDiagnostics { + PlexStreamDiagnostics(session: self) + } + var isPaused: Bool { player.state?.nilIfBlank?.lowercased() == "paused" } @@ -464,7 +468,7 @@ struct PlexSession: Decodable, Identifiable { } private var decisionDisplayName: String? { - media?.first?.part?.first?.decision?.nilIfBlank?.capitalized + PlexStreamDiagnostics.formatDecision(media?.first?.part?.first?.decision) } private var locationDisplayName: String? { @@ -596,46 +600,159 @@ struct PlexTranscodeSession: Decodable { } struct PlexMedia: Decodable { + let bitrate: Int? + let videoCodec: String? + let audioCodec: String? + let container: String? + let width: Int? + let height: Int? let part: [PlexPart]? - init(part: [PlexPart]?) { + init( + bitrate: Int? = nil, + videoCodec: String? = nil, + audioCodec: String? = nil, + container: String? = nil, + width: Int? = nil, + height: Int? = nil, + part: [PlexPart]? + ) { + self.bitrate = bitrate + self.videoCodec = videoCodec + self.audioCodec = audioCodec + self.container = container + self.width = width + self.height = height self.part = part } enum CodingKeys: String, CodingKey { + case bitrate + case videoCodec + case audioCodec + case container + case width + case height case part = "Part" } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + bitrate = try container.decodeFlexibleIntIfPresent(forKey: .bitrate) + videoCodec = try container.decodeIfPresent(String.self, forKey: .videoCodec) + audioCodec = try container.decodeIfPresent(String.self, forKey: .audioCodec) + self.container = try container.decodeIfPresent(String.self, forKey: .container) + width = try container.decodeFlexibleIntIfPresent(forKey: .width) + height = try container.decodeFlexibleIntIfPresent(forKey: .height) + part = try container.decodeIfPresent([PlexPart].self, forKey: .part) + } } struct PlexPart: Decodable { let decision: String? + let bitrate: Int? + let videoCodec: String? + let audioCodec: String? + let container: String? + let width: Int? + let height: Int? let stream: [PlexStream]? - init(decision: String?, stream: [PlexStream]? = nil) { + init( + decision: String?, + bitrate: Int? = nil, + videoCodec: String? = nil, + audioCodec: String? = nil, + container: String? = nil, + width: Int? = nil, + height: Int? = nil, + stream: [PlexStream]? = nil + ) { self.decision = decision + self.bitrate = bitrate + self.videoCodec = videoCodec + self.audioCodec = audioCodec + self.container = container + self.width = width + self.height = height self.stream = stream } enum CodingKeys: String, CodingKey { case decision + case bitrate + case videoCodec + case audioCodec + case container + case width + case height case stream = "Stream" } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + decision = try container.decodeIfPresent(String.self, forKey: .decision) + bitrate = try container.decodeFlexibleIntIfPresent(forKey: .bitrate) + videoCodec = try container.decodeIfPresent(String.self, forKey: .videoCodec) + audioCodec = try container.decodeIfPresent(String.self, forKey: .audioCodec) + self.container = try container.decodeIfPresent(String.self, forKey: .container) + width = try container.decodeFlexibleIntIfPresent(forKey: .width) + height = try container.decodeFlexibleIntIfPresent(forKey: .height) + stream = try container.decodeIfPresent([PlexStream].self, forKey: .stream) + } } struct PlexStream: Decodable { let id: Int? let streamType: Int? let codec: String? + let bitrate: Int? + let width: Int? + let height: Int? + let displayTitle: String? + let extendedDisplayTitle: String? + let channels: Int? + let language: String? + let title: String? let selected: Bool? + var isVideo: Bool { + streamType == 1 + } + var isAudio: Bool { streamType == 2 } - init(id: Int?, streamType: Int?, codec: String? = nil, selected: Bool? = nil) { + var isSubtitle: Bool { + streamType == 3 + } + + init( + id: Int?, + streamType: Int?, + codec: String? = nil, + bitrate: Int? = nil, + width: Int? = nil, + height: Int? = nil, + displayTitle: String? = nil, + extendedDisplayTitle: String? = nil, + channels: Int? = nil, + language: String? = nil, + title: String? = nil, + selected: Bool? = nil + ) { self.id = id self.streamType = streamType self.codec = codec + self.bitrate = bitrate + self.width = width + self.height = height + self.displayTitle = displayTitle + self.extendedDisplayTitle = extendedDisplayTitle + self.channels = channels + self.language = language + self.title = title self.selected = selected } @@ -643,6 +760,14 @@ struct PlexStream: Decodable { case id case streamType case codec + case bitrate + case width + case height + case displayTitle + case extendedDisplayTitle + case channels + case language + case title case selected } @@ -651,6 +776,14 @@ struct PlexStream: Decodable { id = try container.decodeFlexibleIntIfPresent(forKey: .id) streamType = try container.decodeFlexibleIntIfPresent(forKey: .streamType) codec = try container.decodeIfPresent(String.self, forKey: .codec) + bitrate = try container.decodeFlexibleIntIfPresent(forKey: .bitrate) + width = try container.decodeFlexibleIntIfPresent(forKey: .width) + height = try container.decodeFlexibleIntIfPresent(forKey: .height) + displayTitle = try container.decodeIfPresent(String.self, forKey: .displayTitle) + extendedDisplayTitle = try container.decodeIfPresent(String.self, forKey: .extendedDisplayTitle) + channels = try container.decodeFlexibleIntIfPresent(forKey: .channels) + language = try container.decodeIfPresent(String.self, forKey: .language) + title = try container.decodeIfPresent(String.self, forKey: .title) selected = try container.decodeFlexibleBoolIfPresent(forKey: .selected) } } diff --git a/Sources/PlexBar/Models/PlexStreamDiagnostics.swift b/Sources/PlexBar/Models/PlexStreamDiagnostics.swift new file mode 100644 index 0000000..44bbc88 --- /dev/null +++ b/Sources/PlexBar/Models/PlexStreamDiagnostics.swift @@ -0,0 +1,234 @@ +import Foundation + +struct PlexStreamDiagnostics: Equatable { + struct Item: Identifiable, Equatable { + let label: String + let value: String + let symbolName: String + let isWarning: Bool + + var id: String { + "\(label):\(value)" + } + } + + let playbackItems: [Item] + let streamItems: [Item] + let connectionItems: [Item] + let decisionItem: Item? + let mediaRows: [Item] + let connectionRows: [Item] + + init(session: PlexSession) { + let media = session.media?.first + let part = media?.part?.first + let streams = media?.part?.flatMap { $0.stream ?? [] } ?? [] + let videoStream = streams.first(where: { $0.isVideo && $0.selected == true }) ?? streams.first(where: \.isVideo) + let audioStream = streams.first(where: { $0.isAudio && $0.selected == true }) ?? streams.first(where: \.isAudio) + let subtitleStream = streams.first(where: { $0.isSubtitle && $0.selected == true }) ?? streams.first(where: \.isSubtitle) + + let decision = Self.formatDecision(part?.decision) + let location = session.session?.location?.nilIfBlank?.uppercased() + let bandwidth = Self.formatBandwidth(session.session?.bandwidth) + let route = Self.routeDisplayName(local: session.player.local, relayed: session.player.relayed) + let security = Self.securityDisplayName(session.player.secure) + let videoCodec = Self.formatCodec(videoStream?.codec ?? part?.videoCodec ?? media?.videoCodec) + let audioCodec = Self.formatCodec(audioStream?.codec ?? part?.audioCodec ?? media?.audioCodec) + let resolution = Self.formatResolution( + width: videoStream?.width ?? part?.width ?? media?.width, + height: videoStream?.height ?? part?.height ?? media?.height + ) + let bitrate = Self.formatBitrate(videoStream?.bitrate ?? audioStream?.bitrate ?? part?.bitrate ?? media?.bitrate) + let videoSummary = Self.formatVideoSummary(resolution: resolution, codec: videoCodec, bitrate: Self.formatBitrate(videoStream?.bitrate ?? part?.bitrate ?? media?.bitrate)) + let audioSummary = Self.formatAudioSummary(stream: audioStream, codec: audioCodec) + let subtitleSummary = Self.formatSubtitleSummary(stream: subtitleStream) + + decisionItem = decision.map { Item(label: "Stream", value: $0, symbolName: Self.decisionSymbol(for: $0), isWarning: false) } + playbackItems = [decisionItem].compactMap { $0 } + + streamItems = [ + videoSummary.map { Item(label: "Video", value: $0, symbolName: "film", isWarning: false) }, + audioSummary.map { Item(label: "Audio", value: $0, symbolName: "waveform", isWarning: false) }, + subtitleSummary.map { Item(label: "Subtitles", value: $0, symbolName: "captions.bubble", isWarning: false) }, + bitrate.map { Item(label: "Bitrate", value: $0, symbolName: "gauge.with.dots.needle.67percent", isWarning: false) }, + ].compactMap { $0 } + + connectionItems = [ + location.map { Item(label: "Network", value: $0, symbolName: "network", isWarning: false) }, + route.map { Item(label: "Route", value: $0, symbolName: $0 == "Relay" ? "exclamationmark.arrow.triangle.2.circlepath" : "point.3.connected.trianglepath.dotted", isWarning: $0 == "Relay") }, + security.map { Item(label: "Security", value: $0, symbolName: $0 == "Secure" ? "lock" : "lock.open", isWarning: $0 == "Insecure") }, + bandwidth.map { Item(label: "Bandwidth", value: $0, symbolName: "speedometer", isWarning: false) }, + ].compactMap { $0 } + + mediaRows = [ + videoSummary.map { Item(label: "Video", value: $0, symbolName: "film", isWarning: false) }, + audioSummary.map { Item(label: "Audio", value: $0, symbolName: "waveform", isWarning: false) }, + subtitleSummary.map { Item(label: "Subtitles", value: $0, symbolName: "captions.bubble", isWarning: false) }, + ].compactMap { $0 } + connectionRows = connectionItems + } + + static func formatDecision(_ rawValue: String?) -> String? { + switch rawValue?.nilIfBlank?.lowercased() { + case "directplay", "direct play": + "Direct Play" + case "directstream", "direct stream", "copy": + "Direct Stream" + case "transcode": + "Transcode" + case let value?: + value.capitalized + case nil: + nil + } + } + + private static func decisionSymbol(for decision: String) -> String { + switch decision { + case "Direct Play": + "play.circle" + case "Direct Stream": + "arrow.triangle.branch" + case "Transcode": + "arrow.triangle.2.circlepath" + default: + "play.square" + } + } + + private static func routeDisplayName(local: Bool?, relayed: Bool?) -> String? { + if relayed == true { + return "Relay" + } + + if local == true { + return "Local" + } + + if local == false { + return "Remote" + } + + return nil + } + + private static func securityDisplayName(_ secure: Bool?) -> String? { + switch secure { + case true: + "Secure" + case false: + "Insecure" + case nil: + nil + } + } + + private static func formatCodec(_ rawValue: String?) -> String? { + guard let rawValue = rawValue?.nilIfBlank else { + return nil + } + + return rawValue.uppercased() + } + + private static func formatResolution(width: Int?, height: Int?) -> String? { + guard let width, let height, width > 0, height > 0 else { + return nil + } + + return "\(width)x\(height)" + } + + private static func formatVideoSummary(resolution: String?, codec: String?, bitrate: String?) -> String? { + let base = [resolution, codec].compactMap { $0?.nilIfBlank }.joined(separator: " ") + guard !base.isEmpty else { + return bitrate + } + + guard let bitrate = bitrate?.nilIfBlank else { + return base + } + + return "\(base), \(bitrate)" + } + + private static func formatAudioSummary(stream: PlexStream?, codec: String?) -> String? { + let language = stream?.language?.nilIfBlank + let codec = codec?.nilIfBlank + let channels = formatChannels(stream?.channels) + let bitrate = formatBitrate(stream?.bitrate) + let base = [language, codec, channels].compactMap { $0 }.joined(separator: " ") + guard !base.isEmpty else { + return bitrate + } + + guard let bitrate else { + return base + } + + return "\(base), \(bitrate)" + } + + private static func formatSubtitleSummary(stream: PlexStream?) -> String? { + guard let stream else { + return nil + } + + if let displayTitle = stream.displayTitle?.nilIfBlank { + return displayTitle + } + + let language = stream.language?.nilIfBlank + let codec = formatCodec(stream.codec) + let value = [language, codec].compactMap { $0 }.joined(separator: " ") + return value.nilIfBlank + } + + private static func formatChannels(_ value: Int?) -> String? { + switch value { + case 1: + "Mono" + case 2: + "Stereo" + case 6: + "5.1" + case 8: + "7.1" + case let value? where value > 0: + "\(value)ch" + default: + nil + } + } + + private static func formatBandwidth(_ value: Int?) -> String? { + guard let value, value > 0 else { + return nil + } + + return formatMegabits(value) + } + + private static func formatBitrate(_ value: Int?) -> String? { + guard let value, value > 0 else { + return nil + } + + if value < 1000 { + return "\(value) Kbps" + } + + return formatMegabits(value) + } + + private static func formatMegabits(_ value: Int) -> String { + let megabits = Double(value) / 1000 + + if megabits >= 10 { + return "\(Int(megabits.rounded())) Mbps" + } + + let formatted = String(format: "%.1f", megabits) + return "\(formatted) Mbps" + } +} diff --git a/Sources/PlexBar/Resources/MockServer/mock-server.json b/Sources/PlexBar/Resources/MockServer/mock-server.json index 8b940cd..c70d5ee 100644 --- a/Sources/PlexBar/Resources/MockServer/mock-server.json +++ b/Sources/PlexBar/Resources/MockServer/mock-server.json @@ -181,7 +181,52 @@ "bandwidth": 12000, "location": "lan" }, - "mediaDecision": "direct play" + "mediaDecision": "direct play", + "media": [ + { + "bitrate": 11980, + "videoCodec": "h264", + "audioCodec": "aac", + "container": "mp4", + "width": 1920, + "height": 1080, + "audioChannels": 2, + "videoResolution": "1080", + "videoFrameRate": "24p", + "videoProfile": "high", + "part": [ + { + "decision": "directplay", + "container": "mp4", + "stream": [ + { + "id": 1101001, + "streamType": 1, + "codec": "h264", + "bitrate": 11400, + "width": 1920, + "height": 1080, + "displayTitle": "1080p (H.264)", + "extendedDisplayTitle": "1080p (H.264 High)", + "location": "direct" + }, + { + "id": 1101002, + "streamType": 2, + "codec": "aac", + "bitrate": 320, + "displayTitle": "English (AAC Stereo)", + "extendedDisplayTitle": "English (AAC Stereo)", + "location": "direct", + "channels": 2, + "language": "English", + "selected": true + } + ] + } + ] + } + ] }, { "sessionKey": "stream-2", @@ -208,9 +253,85 @@ "location": "wan" }, "transcodeSession": { - "key": "transcode-2" + "key": "transcode-2", + "videoDecision": "transcode", + "audioDecision": "copy", + "protocol": "hls", + "container": "mkv", + "videoCodec": "h264", + "audioCodec": "ac3", + "sourceVideoCodec": "hevc", + "sourceAudioCodec": "ac3", + "width": 1280, + "height": 720, + "progress": 82.0, + "speed": 1.4, + "throttled": false, + "transcodeHwRequested": true, + "transcodeHwFullPipeline": false }, - "mediaDecision": "transcode" + "mediaDecision": "transcode", + "media": [ + { + "bitrate": 8400, + "videoCodec": "hevc", + "audioCodec": "ac3", + "container": "mkv", + "width": 3840, + "height": 2160, + "audioChannels": 6, + "videoResolution": "4k", + "videoFrameRate": "24p", + "videoProfile": "main 10", + "part": [ + { + "decision": "transcode", + "bitrate": 8400, + "container": "mkv", + "width": 1280, + "height": 720, + "stream": [ + { + "id": 1102001, + "streamType": 1, + "codec": "hevc", + "bitrate": 7800, + "width": 3840, + "height": 2160, + "displayTitle": "4K (HEVC Main 10)", + "extendedDisplayTitle": "4K (HEVC Main 10)", + "decision": "transcode", + "location": "segments-video" + }, + { + "id": 1102002, + "streamType": 2, + "codec": "ac3", + "bitrate": 640, + "displayTitle": "English (AC3 5.1)", + "extendedDisplayTitle": "English (AC3 5.1)", + "decision": "copy", + "location": "segments-audio", + "channels": 6, + "language": "English", + "selected": true + }, + { + "id": 1102003, + "streamType": 3, + "codec": "srt", + "bitrate": 0, + "displayTitle": "English (SRT)", + "extendedDisplayTitle": "English (SRT)", + "location": "direct", + "language": "English", + "selected": true + } + ] + } + ] + } + ] }, { "sessionKey": "stream-3", @@ -236,7 +357,51 @@ "bandwidth": 10200, "location": "lan" }, - "mediaDecision": "direct play" + "mediaDecision": "direct play", + "media": [ + { + "bitrate": 4600, + "videoCodec": "h264", + "audioCodec": "aac", + "container": "mkv", + "width": 1440, + "height": 1080, + "audioChannels": 2, + "videoResolution": "1080", + "videoFrameRate": "24p", + "videoProfile": "main", + "part": [ + { + "decision": "directplay", + "container": "mkv", + "stream": [ + { + "id": 1103001, + "streamType": 1, + "codec": "h264", + "bitrate": 4200, + "width": 1440, + "height": 1080, + "displayTitle": "1080p (H.264)", + "extendedDisplayTitle": "1080p (H.264 Main)", + "location": "direct" + }, + { + "id": 1103002, + "streamType": 2, + "codec": "aac", + "bitrate": 192, + "displayTitle": "Unknown (AAC Stereo)", + "extendedDisplayTitle": "Unknown (AAC Stereo)", + "location": "direct", + "channels": 2, + "selected": true + } + ] + } + ] + } + ] }, { "sessionKey": "stream-4", @@ -263,6 +428,34 @@ "location": "wan" }, "mediaDecision": "direct play", + "media": [ + { + "bitrate": 256, + "audioCodec": "aac", + "container": "m4b", + "audioChannels": 2, + "part": [ + { + "decision": "directplay", + "container": "m4b", + "stream": [ + { + "id": 3103001, + "streamType": 2, + "codec": "aac", + "bitrate": 256, + "displayTitle": "English (AAC Stereo)", + "extendedDisplayTitle": "English (AAC Stereo)", + "location": "direct", + "channels": 2, + "language": "English", + "selected": true + } + ] + } + ] + } + ], "audioStream": { "id": 3103001, "streamType": 2, diff --git a/Sources/PlexBar/Support/PlexDebugMockServer.swift b/Sources/PlexBar/Support/PlexDebugMockServer.swift index 02f1ed9..8bd45cd 100644 --- a/Sources/PlexBar/Support/PlexDebugMockServer.swift +++ b/Sources/PlexBar/Support/PlexDebugMockServer.swift @@ -71,6 +71,7 @@ private struct PlexDebugMockFixture { let server: PlexServerResource let activeConnection: PlexResolvedConnection let sessions: [PlexSession] + let activeSessionPayloadsByKey: [String: PlexMockServerPayload.ActiveSession] let streamLevelsByID: [Int: [Double]] let resolvedLocationsBySessionKey: [String: String] let historyItems: [PlexHistoryItem] @@ -148,6 +149,9 @@ private struct PlexDebugMockFixture { server: server, activeConnection: activeConnection, sessions: sessions, + activeSessionPayloadsByKey: Dictionary( + uniqueKeysWithValues: payload.activeSessions.map { ($0.sessionKey, $0) } + ), streamLevelsByID: Dictionary( payload.activeSessions.compactMap { session in session.audioStream.map { ($0.id, $0.levels) } @@ -276,7 +280,9 @@ private struct PlexDebugMockFixture { url: url, object: [ "MediaContainer": [ - "Metadata": filteredSessions.map { sessionObject(from: $0) } + "Metadata": filteredSessions.map { session in + sessionObject(from: session, payload: session.canonicalSessionKey.flatMap { activeSessionPayloadsByKey[$0] }) + } ] ] ) @@ -584,7 +590,7 @@ private struct PlexDebugMockFixture { return PlexDebugMockResponse(response: response, data: data) } - private func sessionObject(from session: PlexSession) -> [String: Any] { + private func sessionObject(from session: PlexSession, payload: PlexMockServerPayload.ActiveSession?) -> [String: Any] { var object = compactObject([ "sessionKey": session.sessionKey, "ratingKey": session.ratingKey, @@ -626,36 +632,134 @@ private struct PlexDebugMockFixture { ]), ]) - if let transcodeSession = session.transcodeSession { + if let transcodeSession = payload?.transcodeSession { + object["TranscodeSession"] = transcodeSessionObject(from: transcodeSession) + } else if let transcodeSession = session.transcodeSession { object["TranscodeSession"] = compactObject(["key": transcodeSession.key]) } - if let media = session.media { + if let media = payload?.media { + object["Media"] = media.map(mediaObject(from:)) + } else if let media = session.media { object["Media"] = media.map { media in - [ - "Part": (media.part ?? []).map { part in - var partObject = compactObject(["decision": part.decision]) - - if let stream = part.stream { - partObject["Stream"] = stream.map { stream in - compactObject([ - "id": stream.id, - "streamType": stream.streamType, - "codec": stream.codec, - "selected": stream.selected, - ]) - } - } - - return partObject - } - ] + mediaObject(from: media) } } return object } + private func mediaObject(from media: PlexMockServerPayload.Media) -> [String: Any] { + var object = compactObject([ + "bitrate": media.bitrate, + "videoCodec": media.videoCodec, + "audioCodec": media.audioCodec, + "container": media.container, + "width": media.width, + "height": media.height, + "audioChannels": media.audioChannels, + "videoResolution": media.videoResolution, + "videoFrameRate": media.videoFrameRate, + "videoProfile": media.videoProfile, + ]) + object["Part"] = (media.part ?? []).map(partObject(from:)) + return object + } + + private func mediaObject(from media: PlexMedia) -> [String: Any] { + var object = compactObject([ + "bitrate": media.bitrate, + "videoCodec": media.videoCodec, + "audioCodec": media.audioCodec, + "container": media.container, + "width": media.width, + "height": media.height, + ]) + object["Part"] = (media.part ?? []).map(partObject(from:)) + return object + } + + private func partObject(from part: PlexMockServerPayload.Part) -> [String: Any] { + var object = compactObject([ + "decision": part.decision, + "bitrate": part.bitrate, + "videoCodec": part.videoCodec, + "audioCodec": part.audioCodec, + "container": part.container, + "width": part.width, + "height": part.height, + "videoProfile": part.videoProfile, + ]) + object["Stream"] = (part.stream ?? []).map(streamObject(from:)) + return object + } + + private func partObject(from part: PlexPart) -> [String: Any] { + var object = compactObject([ + "decision": part.decision, + "bitrate": part.bitrate, + "videoCodec": part.videoCodec, + "audioCodec": part.audioCodec, + "container": part.container, + "width": part.width, + "height": part.height, + ]) + object["Stream"] = (part.stream ?? []).map(streamObject(from:)) + return object + } + + private func streamObject(from stream: PlexMockServerPayload.Stream) -> [String: Any] { + compactObject([ + "id": stream.id, + "streamType": stream.streamType, + "codec": stream.codec, + "bitrate": stream.bitrate, + "width": stream.width, + "height": stream.height, + "displayTitle": stream.displayTitle, + "extendedDisplayTitle": stream.extendedDisplayTitle, + "decision": stream.decision, + "location": stream.location, + "channels": stream.channels, + "language": stream.language, + "title": stream.title, + "selected": stream.selected, + ]) + } + + private func streamObject(from stream: PlexStream) -> [String: Any] { + compactObject([ + "id": stream.id, + "streamType": stream.streamType, + "codec": stream.codec, + "bitrate": stream.bitrate, + "width": stream.width, + "height": stream.height, + "selected": stream.selected, + ]) + } + + private func transcodeSessionObject(from transcodeSession: PlexMockServerPayload.TranscodeSession) -> [String: Any] { + compactObject([ + "key": transcodeSession.key, + "videoDecision": transcodeSession.videoDecision, + "audioDecision": transcodeSession.audioDecision, + "protocol": transcodeSession.protocolName, + "container": transcodeSession.container, + "videoCodec": transcodeSession.videoCodec, + "audioCodec": transcodeSession.audioCodec, + "sourceVideoCodec": transcodeSession.sourceVideoCodec, + "sourceAudioCodec": transcodeSession.sourceAudioCodec, + "width": transcodeSession.width, + "height": transcodeSession.height, + "progress": transcodeSession.progress, + "speed": transcodeSession.speed, + "throttled": transcodeSession.throttled, + "transcodeHwRequested": transcodeSession.transcodeHwRequested, + "transcodeHwFullPipeline": transcodeSession.transcodeHwFullPipeline, + ]) + } + private func historyItemObject(from item: PlexHistoryItem) -> [String: Any] { compactObject([ "historyKey": item.historyKey, @@ -752,7 +856,9 @@ private struct PlexDebugMockFixture { audiobooksByID: audiobooksByID ) let mediaParts: [PlexMedia]? - if session.mediaDecision != nil || session.audioStream != nil { + if let mockMedia = session.media { + mediaParts = mockMedia.map(materializeMedia(_:)) + } else if session.mediaDecision != nil || session.audioStream != nil { let stream = session.audioStream.map { PlexStream( id: $0.id, @@ -796,6 +902,48 @@ private struct PlexDebugMockFixture { ) } + private static func materializeMedia(_ media: PlexMockServerPayload.Media) -> PlexMedia { + PlexMedia( + bitrate: media.bitrate, + videoCodec: media.videoCodec, + audioCodec: media.audioCodec, + container: media.container, + width: media.width, + height: media.height, + part: media.part?.map(materializePart(_:)) + ) + } + + private static func materializePart(_ part: PlexMockServerPayload.Part) -> PlexPart { + PlexPart( + decision: part.decision, + bitrate: part.bitrate, + videoCodec: part.videoCodec, + audioCodec: part.audioCodec, + container: part.container, + width: part.width, + height: part.height, + stream: part.stream?.map(materializeStream(_:)) + ) + } + + private static func materializeStream(_ stream: PlexMockServerPayload.Stream) -> PlexStream { + PlexStream( + id: stream.id, + streamType: stream.streamType, + codec: stream.codec, + bitrate: stream.bitrate, + width: stream.width, + height: stream.height, + displayTitle: stream.displayTitle, + extendedDisplayTitle: stream.extendedDisplayTitle, + channels: stream.channels, + language: stream.language, + title: stream.title, + selected: stream.selected + ) + } + private static func materializeHistoryItem( _ event: PlexMockServerPayload.HistoryEvent, referenceDate: Date, diff --git a/Sources/PlexBar/Support/PlexMockServerPayload.swift b/Sources/PlexBar/Support/PlexMockServerPayload.swift index 740708c..0028bb9 100644 --- a/Sources/PlexBar/Support/PlexMockServerPayload.swift +++ b/Sources/PlexBar/Support/PlexMockServerPayload.swift @@ -148,9 +148,53 @@ extension PlexMockServerPayload { let session: PlaybackSession? let transcodeSession: TranscodeSession? let mediaDecision: String? + let media: [Media]? let audioStream: AudioStream? } + struct Media: Decodable { + let bitrate: Int? + let videoCodec: String? + let audioCodec: String? + let container: String? + let width: Int? + let height: Int? + let audioChannels: Int? + let videoResolution: String? + let videoFrameRate: String? + let videoProfile: String? + let part: [Part]? + } + + struct Part: Decodable { + let decision: String? + let bitrate: Int? + let videoCodec: String? + let audioCodec: String? + let container: String? + let width: Int? + let height: Int? + let videoProfile: String? + let stream: [Stream]? + } + + struct Stream: Decodable { + let id: Int? + let streamType: Int? + let codec: String? + let bitrate: Int? + let width: Int? + let height: Int? + let displayTitle: String? + let extendedDisplayTitle: String? + let decision: String? + let location: String? + let channels: Int? + let language: String? + let title: String? + let selected: Bool? + } + struct AudioStream: Decodable { let id: Int let streamType: Int @@ -230,6 +274,40 @@ extension PlexMockServerPayload { struct TranscodeSession: Decodable { let key: String? + let videoDecision: String? + let audioDecision: String? + let protocolName: String? + let container: String? + let videoCodec: String? + let audioCodec: String? + let sourceVideoCodec: String? + let sourceAudioCodec: String? + let width: Int? + let height: Int? + let progress: Double? + let speed: Double? + let throttled: Bool? + let transcodeHwRequested: Bool? + let transcodeHwFullPipeline: Bool? + + enum CodingKeys: String, CodingKey { + case key + case videoDecision + case audioDecision + case protocolName = "protocol" + case container + case videoCodec + case audioCodec + case sourceVideoCodec + case sourceAudioCodec + case width + case height + case progress + case speed + case throttled + case transcodeHwRequested + case transcodeHwFullPipeline + } func materialize() -> PlexTranscodeSession { PlexTranscodeSession(key: key) diff --git a/Sources/PlexBar/Views/MenuBarContentView.swift b/Sources/PlexBar/Views/MenuBarContentView.swift index c79548d..e433bb6 100644 --- a/Sources/PlexBar/Views/MenuBarContentView.swift +++ b/Sources/PlexBar/Views/MenuBarContentView.swift @@ -14,6 +14,7 @@ struct MenuBarContentView: View { @State private var historyContentHeight: CGFloat = 0 @State private var usersContentHeight: CGFloat = 0 @State private var libraryContentHeight: CGFloat = 0 + @State private var expandedStreamID: String? @State private var terminatePrompt: TerminatePlaybackPrompt? @State private var terminateMessage = "" @@ -27,6 +28,7 @@ struct MenuBarContentView: View { .padding(16) .frame(width: 420) .animation(.snappy(duration: 0.18), value: terminatePrompt?.id) + .animation(.snappy(duration: 0.18), value: expandedStreamID) } private var header: some View { @@ -209,8 +211,11 @@ struct MenuBarContentView: View { serverURL: connectionStore.resolvedServerURL, settingsStore: settingsStore, snapshotDate: sessionStore.lastUpdated, - resolvedLocation: sessionStore.resolvedLocation(for: session) - ) + resolvedLocation: sessionStore.resolvedLocation(for: session), + isExpanded: expandedStreamID == session.id + ) { + toggleExpandedStream(session) + } } } .background { @@ -444,6 +449,10 @@ struct MenuBarContentView: View { terminatePrompt = TerminatePlaybackPrompt(session: session) } + private func toggleExpandedStream(_ session: PlexSession) { + expandedStreamID = expandedStreamID == session.id ? nil : session.id + } + private func dismissTerminatePrompt() { terminatePrompt = nil terminateMessage = "" diff --git a/Sources/PlexBar/Views/StreamCardView.swift b/Sources/PlexBar/Views/StreamCardView.swift index e69e334..569228d 100644 --- a/Sources/PlexBar/Views/StreamCardView.swift +++ b/Sources/PlexBar/Views/StreamCardView.swift @@ -71,6 +71,8 @@ struct StreamCardView: View { let settingsStore: PlexSettingsStore let snapshotDate: Date? let resolvedLocation: String? + let isExpanded: Bool + let onToggleDiagnostics: () -> Void @State private var artwork: PlexArtworkPresentationState @State private var isShowingActionsPopover = false @@ -85,7 +87,9 @@ struct StreamCardView: View { serverURL: URL?, settingsStore: PlexSettingsStore, snapshotDate: Date?, - resolvedLocation: String? + resolvedLocation: String?, + isExpanded: Bool, + onToggleDiagnostics: @escaping () -> Void ) { self.session = session self.sessionStore = sessionStore @@ -98,6 +102,8 @@ struct StreamCardView: View { self.settingsStore = settingsStore self.snapshotDate = snapshotDate self.resolvedLocation = resolvedLocation + self.isExpanded = isExpanded + self.onToggleDiagnostics = onToggleDiagnostics let posterURL = Self.posterURL(serverURL: serverURL, posterPath: session.posterPath) let transcodedPosterURL = Self.transcodedPosterURL( @@ -120,7 +126,7 @@ struct StreamCardView: View { var body: some View { let theme = StreamCardTheme.make(for: colorScheme, hasPalette: artwork.palette != nil) let isTerminating = sessionStore.isTerminating(session) - defaultCardContent(isTerminating: isTerminating) + cardContent(isTerminating: isTerminating) .opacity(isShowingTerminatePrompt ? 0.38 : 1) .padding(12) .frame(maxWidth: .infinity, minHeight: 132, alignment: .topLeading) @@ -149,6 +155,15 @@ struct StreamCardView: View { .allowsHitTesting(false) } } + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .onTapGesture { + guard !isShowingTerminatePrompt, !isTerminating else { + return + } + + onToggleDiagnostics() + } + .accessibilityHint(isExpanded ? "Click to hide stream diagnostics." : "Click to show stream diagnostics.") .task(id: requestKey) { await artwork.load( primaryImageURL: posterURL, @@ -204,6 +219,17 @@ struct StreamCardView: View { } @ViewBuilder + private func cardContent(isTerminating: Bool) -> some View { + VStack(alignment: .leading, spacing: 12) { + defaultCardContent(isTerminating: isTerminating) + + if isExpanded { + StreamDiagnosticsPanel(diagnostics: session.streamDiagnostics) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + private func defaultCardContent(isTerminating: Bool) -> some View { HStack(alignment: .top, spacing: 12) { StreamArtworkView( @@ -266,6 +292,7 @@ struct StreamCardView: View { .monospacedDigit() .lineLimit(1) } + } VStack(alignment: .leading, spacing: 6) { @@ -311,6 +338,7 @@ struct StreamCardView: View { } .buttonStyle(.plain) .disabled(isTerminating || isShowingTerminatePrompt) + .help("Stream actions") .popover(isPresented: $isShowingActionsPopover, arrowEdge: .top) { SessionActionsPopover { isShowingActionsPopover = false @@ -423,6 +451,71 @@ struct StreamCardView: View { } +private struct StreamDiagnosticsPanel: View { + let diagnostics: PlexStreamDiagnostics + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Rectangle() + .fill(.white.opacity(0.08)) + .frame(height: 1) + + VStack(alignment: .leading, spacing: 8) { + if let decisionItem = diagnostics.decisionItem { + StreamDiagnosticRow(item: decisionItem) + } + + StreamDiagnosticRows(items: diagnostics.mediaRows) + + if !diagnostics.mediaRows.isEmpty && !diagnostics.connectionRows.isEmpty { + Rectangle() + .fill(.white.opacity(0.07)) + .frame(height: 1) + .padding(.vertical, 2) + } + + StreamDiagnosticRows(items: diagnostics.connectionRows) + } + } + .padding(.top, 2) + } +} + +private struct StreamDiagnosticRows: View { + let items: [PlexStreamDiagnostics.Item] + + var body: some View { + if !items.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(items) { item in + StreamDiagnosticRow(item: item) + } + } + } + } +} + +private struct StreamDiagnosticRow: View { + let item: PlexStreamDiagnostics.Item + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(item.label) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 78, alignment: .trailing) + + Text(item.value) + .font(item.isWarning ? .callout.weight(.semibold) : .callout) + .foregroundStyle(item.isWarning ? .orange : .primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .padding(.vertical, 2) + } +} + private struct SessionActionsPopover: View { let onRequestTerminate: () -> Void diff --git a/Tests/PlexBarTests/PlexDebugMockServerTests.swift b/Tests/PlexBarTests/PlexDebugMockServerTests.swift index eca1ca5..64da09e 100644 --- a/Tests/PlexBarTests/PlexDebugMockServerTests.swift +++ b/Tests/PlexBarTests/PlexDebugMockServerTests.swift @@ -162,6 +162,29 @@ import Testing #expect(tommySession.audioStreamID == 3_103_001) } +@Test func mockServerReturnsRealisticStreamDiagnosticsShape() async throws { + let client = PlexAPIClient(session: PlexDebugMockServer.makeSession()) + let configuration = PlexConnectionConfiguration( + serverURL: URL(string: "https://demo.plexbar.local:32400")!, + token: "plexbar-debug-mock-server-token", + clientContext: PlexClientContext(clientIdentifier: "tests") + ) + + let sessions = try await client.fetchSessions(using: configuration) + let directPlaySession = try #require(sessions.first(where: { $0.canonicalSessionKey == "stream-1" })) + let transcodeSession = try #require(sessions.first(where: { $0.canonicalSessionKey == "stream-2" })) + + #expect(directPlaySession.streamDiagnostics.playbackItems.map(\.value).contains("Direct Play")) + #expect(directPlaySession.streamDiagnostics.mediaRows.map(\.value).contains("1920x1080 H264, 11 Mbps")) + #expect(directPlaySession.streamDiagnostics.mediaRows.map(\.value).contains("English AAC Stereo, 320 Kbps")) + + #expect(transcodeSession.streamDiagnostics.playbackItems.map(\.value).contains("Transcode")) + #expect(transcodeSession.streamDiagnostics.mediaRows.map(\.value).contains("3840x2160 HEVC, 7.8 Mbps")) + #expect(transcodeSession.streamDiagnostics.mediaRows.map(\.value).contains("English AC3 5.1, 640 Kbps")) + #expect(transcodeSession.streamDiagnostics.mediaRows.map(\.value).contains("English (SRT)")) + #expect(transcodeSession.streamDiagnostics.connectionItems.map(\.value).contains("WAN")) +} + @Test func mockServerReturnsAudiobookStreamLevels() async throws { let client = PlexAPIClient(session: PlexDebugMockServer.makeSession()) let configuration = PlexConnectionConfiguration( diff --git a/Tests/PlexBarTests/PlexSessionTests.swift b/Tests/PlexBarTests/PlexSessionTests.swift index e80399b..172e728 100644 --- a/Tests/PlexBarTests/PlexSessionTests.swift +++ b/Tests/PlexBarTests/PlexSessionTests.swift @@ -323,7 +323,7 @@ import Testing ) #expect(session.isPaused) - #expect(session.playbackLine == "Directplay • LAN") + #expect(session.playbackLine == "Direct Play • LAN") } @Test func playbackLineRetainsNonDefaultStateText() async throws { @@ -355,6 +355,78 @@ import Testing #expect(session.playbackLine == "Buffering • Transcode • WAN") } +@Test func streamDiagnosticsSummarizeConnectionAndMediaDetails() async throws { + let json = #""" + { + "sessionKey": "77", + "ratingKey": "42", + "key": "/library/metadata/42", + "type": "movie", + "title": "Heat", + "duration": 1020000, + "viewOffset": 255000, + "Player": { + "title": "Apple TV", + "state": "playing", + "local": false, + "relayed": true, + "secure": false, + "remotePublicAddress": "203.0.113.7" + }, + "Session": { + "id": "session-77", + "bandwidth": 12000, + "location": "wan" + }, + "Media": [ + { + "bitrate": "8800", + "videoCodec": "h264", + "audioCodec": "aac", + "container": "mkv", + "width": 1920, + "height": 1080, + "Part": [ + { + "decision": "directstream", + "Stream": [ + { + "id": 1, + "streamType": 1, + "codec": "h265", + "bitrate": 8700, + "width": 1920, + "height": 1080, + "selected": 1 + }, + { + "id": 2, + "streamType": 2, + "codec": "ac3", + "selected": 1 + } + ] + } + ] + } + ] + } + """# + + let data = try #require(json.data(using: .utf8)) + let session = try JSONDecoder().decode(PlexSession.self, from: data) + let diagnostics = session.streamDiagnostics + + #expect(diagnostics.playbackItems.map(\.value).contains("Direct Stream")) + #expect(diagnostics.mediaRows.map(\.value).contains("1920x1080 H265, 8.7 Mbps")) + #expect(diagnostics.mediaRows.map(\.value).contains("AC3")) + #expect(diagnostics.connectionItems.map(\.value).contains("WAN")) + #expect(diagnostics.connectionItems.map(\.value).contains("12 Mbps")) + #expect(diagnostics.connectionItems.map(\.value).contains("Relay")) + #expect(diagnostics.connectionItems.map(\.value).contains("Insecure")) + #expect(!diagnostics.connectionItems.map(\.label).contains("Public IP")) +} + @Test func playbackTimingSummaryFormatsRemainingMinutesAndEndTime() async throws { let session = PlexSession( ratingKey: "42", From b56ab644f100dbecf4d6132998273d824e1861fd Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Thu, 30 Apr 2026 02:11:22 -0700 Subject: [PATCH 2/3] Prevent stream diagnostics text from overlapping adjacent cards --- Sources/PlexBar/Views/StreamCardView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/PlexBar/Views/StreamCardView.swift b/Sources/PlexBar/Views/StreamCardView.swift index 569228d..cb51934 100644 --- a/Sources/PlexBar/Views/StreamCardView.swift +++ b/Sources/PlexBar/Views/StreamCardView.swift @@ -133,6 +133,7 @@ struct StreamCardView: View { .background { StreamCardBackground(palette: artwork.palette, theme: theme) } + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay { if isShowingTerminatePrompt { terminatePromptOverlay @@ -225,7 +226,7 @@ struct StreamCardView: View { if isExpanded { StreamDiagnosticsPanel(diagnostics: session.streamDiagnostics) - .transition(.opacity.combined(with: .move(edge: .top))) + .transition(.opacity) } } } From f7b85f6e7fc891d74f46d97f33180a89ef1f3b67 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Thu, 30 Apr 2026 10:11:25 -0700 Subject: [PATCH 3/3] improve mock data for audio --- .../Models/PlexStreamDiagnostics.swift | 2 +- .../Resources/MockServer/mock-server.json | 36 ++++++++++++++----- .../PlexBar/Support/PlexDebugMockServer.swift | 19 ++++++++++ .../Support/PlexMockServerPayload.swift | 19 ++++++++++ .../PlexDebugMockServerTests.swift | 5 +++ 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/Sources/PlexBar/Models/PlexStreamDiagnostics.swift b/Sources/PlexBar/Models/PlexStreamDiagnostics.swift index 44bbc88..3bcdd5d 100644 --- a/Sources/PlexBar/Models/PlexStreamDiagnostics.swift +++ b/Sources/PlexBar/Models/PlexStreamDiagnostics.swift @@ -142,7 +142,7 @@ struct PlexStreamDiagnostics: Equatable { private static func formatVideoSummary(resolution: String?, codec: String?, bitrate: String?) -> String? { let base = [resolution, codec].compactMap { $0?.nilIfBlank }.joined(separator: " ") guard !base.isEmpty else { - return bitrate + return nil } guard let bitrate = bitrate?.nilIfBlank else { diff --git a/Sources/PlexBar/Resources/MockServer/mock-server.json b/Sources/PlexBar/Resources/MockServer/mock-server.json index c70d5ee..14ca221 100644 --- a/Sources/PlexBar/Resources/MockServer/mock-server.json +++ b/Sources/PlexBar/Resources/MockServer/mock-server.json @@ -430,25 +430,43 @@ "mediaDecision": "direct play", "media": [ { - "bitrate": 256, + "id": "3103-media", + "bitrate": 65, "audioCodec": "aac", - "container": "m4b", - "audioChannels": 2, + "audioProfile": "lc", + "container": "mp4", + "duration": 1314000, + "audioChannels": 1, + "has64bitOffsets": false, + "hasVoiceActivity": "0", + "optimizedForStreaming": false, + "selected": true, "part": [ { + "id": "3103-part", "decision": "directplay", - "container": "m4b", + "container": "mp4", + "duration": 1314000, + "file": "\\\\mock-server\\plex\\Audiobooks\\H. G. Wells\\The War of the Worlds\\The War of the Worlds.m4b", + "key": "/library/parts/3103/file.m4b", + "size": 10984000, + "has64bitOffsets": false, + "optimizedForStreaming": false, + "selected": true, "stream": [ { "id": 3103001, "streamType": 2, "codec": "aac", - "bitrate": 256, - "displayTitle": "English (AAC Stereo)", - "extendedDisplayTitle": "English (AAC Stereo)", + "profile": "lc", + "bitrate": 65, + "displayTitle": "AAC (Mono)", + "extendedDisplayTitle": "AAC (Mono)", "location": "direct", - "channels": 2, - "language": "English", + "channels": 1, + "audioChannelLayout": "mono", + "samplingRate": 44100, + "default": true, "selected": true } ] diff --git a/Sources/PlexBar/Support/PlexDebugMockServer.swift b/Sources/PlexBar/Support/PlexDebugMockServer.swift index 8bd45cd..1927381 100644 --- a/Sources/PlexBar/Support/PlexDebugMockServer.swift +++ b/Sources/PlexBar/Support/PlexDebugMockServer.swift @@ -651,16 +651,23 @@ private struct PlexDebugMockFixture { private func mediaObject(from media: PlexMockServerPayload.Media) -> [String: Any] { var object = compactObject([ + "id": media.id, "bitrate": media.bitrate, "videoCodec": media.videoCodec, "audioCodec": media.audioCodec, + "audioProfile": media.audioProfile, "container": media.container, + "duration": media.duration, "width": media.width, "height": media.height, "audioChannels": media.audioChannels, "videoResolution": media.videoResolution, "videoFrameRate": media.videoFrameRate, "videoProfile": media.videoProfile, + "has64bitOffsets": media.has64bitOffsets, + "hasVoiceActivity": media.hasVoiceActivity, + "optimizedForStreaming": media.optimizedForStreaming, + "selected": media.selected, ]) object["Part"] = (media.part ?? []).map(partObject(from:)) return object @@ -681,14 +688,22 @@ private struct PlexDebugMockFixture { private func partObject(from part: PlexMockServerPayload.Part) -> [String: Any] { var object = compactObject([ + "id": part.id, "decision": part.decision, "bitrate": part.bitrate, "videoCodec": part.videoCodec, "audioCodec": part.audioCodec, "container": part.container, + "duration": part.duration, + "file": part.file, + "key": part.key, + "size": part.size, "width": part.width, "height": part.height, "videoProfile": part.videoProfile, + "has64bitOffsets": part.has64bitOffsets, + "optimizedForStreaming": part.optimizedForStreaming, + "selected": part.selected, ]) object["Stream"] = (part.stream ?? []).map(streamObject(from:)) return object @@ -713,6 +728,7 @@ private struct PlexDebugMockFixture { "id": stream.id, "streamType": stream.streamType, "codec": stream.codec, + "profile": stream.profile, "bitrate": stream.bitrate, "width": stream.width, "height": stream.height, @@ -721,6 +737,9 @@ private struct PlexDebugMockFixture { "decision": stream.decision, "location": stream.location, "channels": stream.channels, + "audioChannelLayout": stream.audioChannelLayout, + "samplingRate": stream.samplingRate, + "default": stream.default, "language": stream.language, "title": stream.title, "selected": stream.selected, diff --git a/Sources/PlexBar/Support/PlexMockServerPayload.swift b/Sources/PlexBar/Support/PlexMockServerPayload.swift index 0028bb9..6e96b60 100644 --- a/Sources/PlexBar/Support/PlexMockServerPayload.swift +++ b/Sources/PlexBar/Support/PlexMockServerPayload.swift @@ -153,28 +153,43 @@ extension PlexMockServerPayload { } struct Media: Decodable { + let id: String? let bitrate: Int? let videoCodec: String? let audioCodec: String? + let audioProfile: String? let container: String? + let duration: Int? let width: Int? let height: Int? let audioChannels: Int? let videoResolution: String? let videoFrameRate: String? let videoProfile: String? + let has64bitOffsets: Bool? + let hasVoiceActivity: String? + let optimizedForStreaming: Bool? + let selected: Bool? let part: [Part]? } struct Part: Decodable { + let id: String? let decision: String? let bitrate: Int? let videoCodec: String? let audioCodec: String? let container: String? + let duration: Int? + let file: String? + let key: String? + let size: Int? let width: Int? let height: Int? let videoProfile: String? + let has64bitOffsets: Bool? + let optimizedForStreaming: Bool? + let selected: Bool? let stream: [Stream]? } @@ -182,6 +197,7 @@ extension PlexMockServerPayload { let id: Int? let streamType: Int? let codec: String? + let profile: String? let bitrate: Int? let width: Int? let height: Int? @@ -190,6 +206,9 @@ extension PlexMockServerPayload { let decision: String? let location: String? let channels: Int? + let audioChannelLayout: String? + let samplingRate: Int? + let `default`: Bool? let language: String? let title: String? let selected: Bool? diff --git a/Tests/PlexBarTests/PlexDebugMockServerTests.swift b/Tests/PlexBarTests/PlexDebugMockServerTests.swift index 64da09e..11aefd0 100644 --- a/Tests/PlexBarTests/PlexDebugMockServerTests.swift +++ b/Tests/PlexBarTests/PlexDebugMockServerTests.swift @@ -173,6 +173,7 @@ import Testing let sessions = try await client.fetchSessions(using: configuration) let directPlaySession = try #require(sessions.first(where: { $0.canonicalSessionKey == "stream-1" })) let transcodeSession = try #require(sessions.first(where: { $0.canonicalSessionKey == "stream-2" })) + let audiobookSession = try #require(sessions.first(where: { $0.canonicalSessionKey == "stream-4" })) #expect(directPlaySession.streamDiagnostics.playbackItems.map(\.value).contains("Direct Play")) #expect(directPlaySession.streamDiagnostics.mediaRows.map(\.value).contains("1920x1080 H264, 11 Mbps")) @@ -183,6 +184,10 @@ import Testing #expect(transcodeSession.streamDiagnostics.mediaRows.map(\.value).contains("English AC3 5.1, 640 Kbps")) #expect(transcodeSession.streamDiagnostics.mediaRows.map(\.value).contains("English (SRT)")) #expect(transcodeSession.streamDiagnostics.connectionItems.map(\.value).contains("WAN")) + + #expect(audiobookSession.streamDiagnostics.playbackItems.map(\.value).contains("Direct Play")) + #expect(audiobookSession.streamDiagnostics.mediaRows.map(\.value).contains("AAC Mono, 65 Kbps")) + #expect(!audiobookSession.streamDiagnostics.mediaRows.map(\.label).contains("Video")) } @Test func mockServerReturnsAudiobookStreamLevels() async throws {