diff --git a/Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift b/Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift index 24a7387b..80697a80 100644 --- a/Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift +++ b/Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift @@ -66,10 +66,31 @@ enum DebugPlexPlaybackProbe { server = restoredServer } + if arguments.contains("--vp-probe-plex-discover") { + do { + try await discover(query: query, appModel: appModel, server: server, token: token) + log.notice("probe.discovery_written playback_started=false") + } catch { + log.error("probe.discovery_failed") + } + return + } + guard let ratingKey = DebugPlaybackProbeSupport.value(after: "--vp-probe-rating-key", in: arguments), + !ratingKey.isEmpty, !ratingKey.hasPrefix("--"), + let mediaID = DebugPlaybackProbeSupport.intValue(after: "--vp-probe-media-id", in: arguments), + let partID = DebugPlaybackProbeSupport.intValue(after: "--vp-probe-part-id", in: arguments) else { + log.error("probe.fail reason=missing_exact_source_binding") + DebugPlaybackScenario.blocked(arguments, reason: .invalidOptions) + return + } var controller: PlaybackController? var scenarioOwnsCleanup = false do { - let item = try await resolveItem(query: query, appModel: appModel, server: server, token: token) + let item = try await resolveItem(ratingKey: ratingKey, appModel: appModel, server: server, token: token) + guard let mediaIndex = PlaybackProbeSelection.plexMediaIndex(item: item, query: query, + ratingKey: ratingKey, mediaID: mediaID, partID: partID) else { + throw DebugPlaybackProbeSupport.ProbeError.playbackFailed("source_binding_mismatch", nil) + } log.notice("probe.item_resolved type=\(item.type, privacy: .public) duration_ms=\(item.duration ?? 0, privacy: .public)") let playback = PlaybackController(item: item, @@ -81,7 +102,7 @@ enum DebugPlexPlaybackProbe { client: appModel.client, maxVideoBitrateKbps: bitrateKbps, qualityDefaultsKey: appModel.activeStreamingQualityDefaultsKey, - mediaIndex: 0) + mediaIndex: mediaIndex) controller = playback scenarioOwnsCleanup = true controller = nil // The named scenario owns cleanup from this point, including errors. @@ -99,23 +120,52 @@ enum DebugPlexPlaybackProbe { } } - private static func resolveItem(query: String, appModel: AppModel, - server: URL, token: String) async throws -> MediaItem { - let searchReq = BrowseAPI.search(server: server, token: token, - identity: appModel.identity, query: query) - let response = try await appModel.client.send(searchReq, as: HubsResponse.self) - let matches = response.mediaContainer.hub.flatMap(\.metadata).filter { !$0.isContainer && !$0.isMusic } - let skinny = matches.first { $0.title.localizedCaseInsensitiveCompare(query) == .orderedSame } - ?? matches.first { $0.title.localizedCaseInsensitiveContains(query) } - ?? matches.first - guard let skinny else { throw DebugPlaybackProbeSupport.ProbeError.itemNotFound(query) } + /// Explicit read-only discovery. Private source references stay in the app container, + /// never diagnostics. A search is bounded and never starts playback or selects a source. + private static func discover(query: String, appModel: AppModel, server: URL, token: String) async throws { + let directory = URL.documentsDirectory.appendingPathComponent("ProbeDiscovery", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let output = directory.appendingPathComponent("plex.json") + try? FileManager.default.removeItem(at: output) // stale results cannot masquerade as this run + let request = BrowseAPI.search(server: server, token: token, identity: appModel.identity, query: query) + let response = try await appModel.client.send(request, as: HubsResponse.self) + let hits = response.mediaContainer.hub.flatMap(\.metadata).filter { + ($0.type == "movie" || $0.type == "episode") && + $0.title.localizedCaseInsensitiveCompare(query) == .orderedSame + } + var seen = Set() + let keys = hits.map(\.ratingKey).filter { seen.insert($0).inserted } + guard keys.count <= 5 else { + throw DebugPlaybackProbeSupport.ProbeError.playbackFailed("discovery_limit", nil) + } + var rows: [[String: Any]] = [] + for key in keys { + let item = try await resolveItem(ratingKey: key, appModel: appModel, server: server, token: token) + for media in item.media ?? [] { + rows.append([ + "ratingKey": item.ratingKey, "title": item.title, "type": item.type, + "mediaID": media.id, "durationMs": media.duration ?? item.duration ?? 0, + "videoCodec": media.videoCodec ?? "unknown", + "audioCodec": media.audioCodec ?? "unknown", + "width": media.width ?? 0, "height": media.height ?? 0, + "parts": media.part.map { ["partID": $0.id, "file": $0.file ?? "", + "size": $0.size ?? 0] as [String: Any] } + ]) + } + } + let data = try JSONSerialization.data(withJSONObject: ["sources": rows], options: [.prettyPrinted, .sortedKeys]) + try data.write(to: output, options: .atomic) + } - // Search hits are skinny; the player needs full metadata (Media/Part/chapters). + private static func resolveItem(ratingKey: String, appModel: AppModel, + server: URL, token: String) async throws -> MediaItem { + // Fetch the manifest's exact item; never guess from a search result. let metadataReq = BrowseAPI.metadata(server: server, token: token, - identity: appModel.identity, ratingKey: skinny.ratingKey) + identity: appModel.identity, ratingKey: ratingKey) let metadata = try await appModel.client.send(metadataReq, as: MetadataResponse.self) - guard let item = metadata.mediaContainer.metadata.first else { - throw DebugPlaybackProbeSupport.ProbeError.itemNotFound(query) + guard metadata.mediaContainer.metadata.count == 1, + let item = metadata.mediaContainer.metadata.first else { + throw DebugPlaybackProbeSupport.ProbeError.playbackFailed("ambiguous_metadata", nil) } return item } diff --git a/LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift b/LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift index 258ecad7..047d24c8 100644 --- a/LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift +++ b/LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift @@ -3,6 +3,30 @@ import XCTest /// Explicitly opted-in live authentication using the ordinary app UI. No token injection. /// Keep the xctestrun configuration, screenshots and result bundle in ignored local storage. final class LabstreamMobileLiveAuthUITests: XCTestCase { + @MainActor + func testPlexLink() throws { + guard ProcessInfo.processInfo.environment["LABSTREAM_LIVE_PLEX_AUTH_ALLOWED"] == "1" else { + throw XCTSkip("Live authentication requires explicit local opt-in.") + } + continueAfterFailure = false + let app = XCUIApplication() + app.launchEnvironment["LABSTREAM_UNIT_TEST_HOST"] = "0" + app.launchArguments = ["--vp-probe-backend", "plex"] + app.launch() + let connect = app.buttons["Sign in with Plex"] + XCTAssertTrue(connect.waitForExistence(timeout: 20), "Expected signed-out app; never sign out automatically.") + connect.tap() + let prompt = app.buttons["Copy Plex pairing code"] + XCTAssertTrue(prompt.waitForExistence(timeout: 30)) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "private-plex-link-code" + attachment.lifetime = .keepAlways + add(attachment) + XCTAssertTrue(app.tabBars.buttons["Home"].waitForExistence(timeout: 240), + "Browser authorization and server selection did not complete.") + XCTAssertFalse(prompt.exists) + } + @MainActor func testJellyfinQuickConnect() throws { let environment = ProcessInfo.processInfo.environment diff --git a/PMSKit/Sources/PMSKit/Playback/PlaybackProbeSelection.swift b/PMSKit/Sources/PMSKit/Playback/PlaybackProbeSelection.swift index 8857519a..d84ac89c 100644 --- a/PMSKit/Sources/PMSKit/Playback/PlaybackProbeSelection.swift +++ b/PMSKit/Sources/PMSKit/Playback/PlaybackProbeSelection.swift @@ -14,4 +14,18 @@ public enum PlaybackProbeSelection { } return matches.count == 1 ? matches[0] : nil } + /// Bind a previously verified private corpus manifest to fresh full Plex metadata. + /// IDs, not array positions, identify the source. Multipart playback is not covered. + public static func plexMediaIndex(item: MediaItem, query: String, ratingKey: String, + mediaID: Int, partID: Int) -> Int? { + guard !ratingKey.isEmpty, item.ratingKey == ratingKey, + item.title.localizedCaseInsensitiveCompare(query) == .orderedSame, + item.type == "movie" || item.type == "episode", + let media = item.media else { return nil } + let matches = media.indices.filter { media[$0].id == mediaID } + guard matches.count == 1, let index = matches.first, + media[index].part.count == 1, + media[index].part[0].id == partID else { return nil } + return index + } } diff --git a/PMSKit/Tests/PMSKitTests/PlaybackProbeSelectionTests.swift b/PMSKit/Tests/PMSKitTests/PlaybackProbeSelectionTests.swift index d75646dc..7ac65f02 100644 --- a/PMSKit/Tests/PMSKitTests/PlaybackProbeSelectionTests.swift +++ b/PMSKit/Tests/PMSKitTests/PlaybackProbeSelectionTests.swift @@ -16,4 +16,25 @@ struct PlaybackProbeSelectionTests { #expect(PlaybackProbeSelection.uniqueExactIndex(titles: ["Episode 42", "EPISODE 42"], query: "Episode 42") == nil) #expect(PlaybackProbeSelection.uniqueExactIndex(titles: [], query: "Episode 42") == nil) } + @Test func plexBindsExactItemAndVersionRatherThanFirstEncode() { + let first = Media(id: 10, part: [Part(id: 100, key: "/fixture/first")]) + let intended = Media(id: 20, part: [Part(id: 200, key: "/fixture/intended")]) + func select(_ media: [Media]?, title: String = "Fixture", key: String = "42", + type: String = "movie") -> Int? { + PlaybackProbeSelection.plexMediaIndex( + item: MediaItem(ratingKey: key, title: title, type: type, media: media), + query: "Fixture", ratingKey: "42", mediaID: 20, partID: 200) + } + #expect(select([first, intended]) == 1) + #expect(select([intended, first]) == 0) + #expect(select([first]) == nil) + #expect(select([intended], title: "Fixture Extended") == nil) + #expect(select([intended], key: "43") == nil) + #expect(select([intended], type: "clip") == nil) + #expect(select(nil) == nil) + #expect(select([intended, intended]) == nil) + #expect(select([Media(id: 20, part: [])]) == nil) + #expect(select([Media(id: 20, part: first.part)]) == nil) + #expect(select([Media(id: 20, part: intended.part + first.part)]) == nil) + } } diff --git a/docs/MEDIA-CORPUS-TESTING.md b/docs/MEDIA-CORPUS-TESTING.md index 78ba0916..0817d997 100644 --- a/docs/MEDIA-CORPUS-TESTING.md +++ b/docs/MEDIA-CORPUS-TESTING.md @@ -131,3 +131,31 @@ Smallest representatives can be studio logos or secondary encodes. Review durati source identity before selecting release cases. Starting a capped scenario at the same 8 Mbps value is a no-op, not a valid quality-transition test. Use a different initial quality. Record first failures even when later repeats pass. + +## Plex exact-source probes + +Plex playback probes require a private, previously verified binding in addition to the +exact `--vp-probe-query` title: `--vp-probe-rating-key`, `--vp-probe-media-id`, and +`--vp-probe-part-id`. Fresh full metadata must match that item, title, Media ID and +single Part ID before opening playback. Media array order is not an identity; the +probe resolves the current index from the ID. Missing/mismatched bindings, duplicate +Media IDs and multipart sources fail closed. Movie/episode items only are supported. +This removes the old fuzzy-search fallback and unconditional version-zero selection. + +For read-only mapping, add `--vp-probe-plex-discover` to the normal +`--vp-probe-plex-playback --vp-probe-allow-live --vp-probe-query "Exact fixture title"` +launch. It never opens playback. At most five unique exact-title search hits are +hydrated; larger results fail closed. Inspect the private +`Documents/ProbeDiscovery/plex.json` in the app container, then match its source path +and size to the inventory (server mount prefixes may differ). Review duration, codec, +resolution and alternate versions; titles alone cannot establish corpus identity. +An empty result is not a source match. Discovery removes its prior result before +requesting metadata; external runners must additionally require a fresh file timestamp. +Never publish this file: it contains media identifiers and source paths, but no token. + +The opt-in mobile `LabstreamMobileLiveAuthUITests/testPlexLink` test uses +`LABSTREAM_LIVE_PLEX_AUTH_ALLOWED=1`, taps ordinary **Sign in with Plex**, and waits +for the Home tab after browser linking. Approve only the current app-generated code +in the Codex in-app browser. The test never signs out a saved session or injects a +credential. Run without test flags afterward to verify session persistence. If server +selection requires interaction, Home remains an explicit gate rather than a false pass.