Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 66 additions & 16 deletions Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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<String>()
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
}
Expand Down
24 changes: 24 additions & 0 deletions LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions PMSKit/Sources/PMSKit/Playback/PlaybackProbeSelection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
21 changes: 21 additions & 0 deletions PMSKit/Tests/PMSKitTests/PlaybackProbeSelectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
28 changes: 28 additions & 0 deletions docs/MEDIA-CORPUS-TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.