The Top Shelf extension now fetches Continue Watching directly from Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its last foreground Discover pass. The app publishes per-profile server descriptors on every shelf sync (`updateSources`): token-free metadata in the app group, tokens in an app-group-shared keychain item, both wiped by `clear`. On success the extension rewrites the cached payload as the offline fallback; any fetch failure falls back to the previous cache-replay behavior. Poster images are passed as remote URLs, so the extension no longer depends on app-side artwork downloads. Episodes now render season/series poster art (2:3, `.poster` shape) instead of 16:9 episode stills, and labels lead with the S/E marker so long titles no longer hide it behind the focused-item marquee. Shelf schema v3 (Dart, Android, tvOS envelopes bumped together) discards stale wide-art caches instead of letterboxing them into poster slots. close #1474 close #1835
410 lines
14 KiB
Swift
410 lines
14 KiB
Swift
import Foundation
|
|
import XCTest
|
|
|
|
@testable import Runner
|
|
|
|
// Contract tests for the Top Shelf live-fetch pipeline. The extension
|
|
// sources under test (ShelfSources/ShelfItemMapper/ShelfFetcher) also compile
|
|
// into the Runner app target, which this hosted bundle imports testably.
|
|
// No network is touched.
|
|
final class TopShelfFetchContractTests: XCTestCase {
|
|
private let token = "secret-token-123"
|
|
private let baseUrl = "https://media.example.com:32400"
|
|
private let serverId = "srv-1"
|
|
|
|
// MARK: - Plex mapping
|
|
|
|
private static let plexContinueWatchingFixture = """
|
|
{
|
|
"MediaContainer": {
|
|
"Metadata": [
|
|
{
|
|
"ratingKey": "101",
|
|
"type": "episode",
|
|
"title": "The Beach",
|
|
"grandparentTitle": "Lost",
|
|
"parentIndex": 2,
|
|
"index": 5,
|
|
"duration": 3600000,
|
|
"viewOffset": 900000,
|
|
"lastViewedAt": 1700000100,
|
|
"parentThumb": "/library/metadata/55/thumb/111",
|
|
"grandparentThumb": "/library/metadata/50/thumb/99",
|
|
"thumb": "/library/metadata/101/thumb/33"
|
|
},
|
|
{
|
|
"ratingKey": "202",
|
|
"type": "movie",
|
|
"title": "Heat",
|
|
"duration": 10200000,
|
|
"viewOffset": 5100000,
|
|
"lastViewedAt": 1700000200,
|
|
"thumb": "/library/metadata/202/thumb/77"
|
|
}
|
|
]
|
|
}
|
|
}
|
|
"""
|
|
|
|
func testPlexEpisodeMappingUsesSeriesTitleAndSeasonPoster() throws {
|
|
let items = ShelfItemMapper.plexItems(
|
|
fromResponse: try fixture(Self.plexContinueWatchingFixture),
|
|
serverId: serverId,
|
|
baseUrl: baseUrl,
|
|
token: token
|
|
)
|
|
XCTAssertEqual(items.count, 2)
|
|
|
|
let episode = items[0]
|
|
XCTAssertEqual(episode.contentId, "plezy_srv-1_101")
|
|
XCTAssertEqual(episode.title, "Lost")
|
|
XCTAssertEqual(episode.episodeTitle, "The Beach")
|
|
XCTAssertEqual(episode.seasonNumber, 2)
|
|
XCTAssertEqual(episode.episodeNumber, 5)
|
|
XCTAssertEqual(episode.durationMilliseconds, 3_600_000)
|
|
XCTAssertEqual(episode.lastPlaybackPositionMilliseconds, 900_000)
|
|
XCTAssertEqual(try XCTUnwrap(episode.playbackProgress), 0.25, accuracy: 0.0001)
|
|
XCTAssertEqual(episode.recency, 1_700_000_100)
|
|
// Season poster (parentThumb) wins the chain.
|
|
XCTAssertEqual(
|
|
episode.posterUrl,
|
|
"\(baseUrl)/photo/:/transcode?width=600&height=900&minSize=1&upscale=1"
|
|
+ "&url=%2Flibrary%2Fmetadata%2F55%2Fthumb%2F111&X-Plex-Token=\(token)"
|
|
)
|
|
|
|
let movie = items[1]
|
|
XCTAssertEqual(movie.contentId, "plezy_srv-1_202")
|
|
XCTAssertEqual(movie.title, "Heat")
|
|
XCTAssertNil(movie.episodeTitle)
|
|
XCTAssertNil(movie.seasonNumber)
|
|
XCTAssertNil(movie.episodeNumber)
|
|
XCTAssertEqual(try XCTUnwrap(movie.playbackProgress), 0.5, accuracy: 0.0001)
|
|
XCTAssertEqual(
|
|
movie.posterUrl,
|
|
"\(baseUrl)/photo/:/transcode?width=600&height=900&minSize=1&upscale=1"
|
|
+ "&url=%2Flibrary%2Fmetadata%2F202%2Fthumb%2F77&X-Plex-Token=\(token)"
|
|
)
|
|
}
|
|
|
|
func testPlexPosterChainFallsBackThroughSeriesThenOwnThumb() {
|
|
let base: [String: Any] = ["ratingKey": "1", "type": "episode", "title": "Ep", "grandparentTitle": "Show"]
|
|
|
|
var metadata = base
|
|
metadata["grandparentThumb"] = "/library/metadata/50/thumb/99"
|
|
metadata["thumb"] = "/library/metadata/1/thumb/1"
|
|
let seriesPoster = ShelfItemMapper.plexItem(metadata, serverId: serverId, baseUrl: baseUrl, token: token)
|
|
XCTAssertEqual(
|
|
seriesPoster?.posterUrl?.contains("url=%2Flibrary%2Fmetadata%2F50%2Fthumb%2F99"),
|
|
true
|
|
)
|
|
|
|
metadata = base
|
|
metadata["thumb"] = "/library/metadata/1/thumb/1"
|
|
let ownThumb = ShelfItemMapper.plexItem(metadata, serverId: serverId, baseUrl: baseUrl, token: token)
|
|
XCTAssertEqual(ownThumb?.posterUrl?.contains("url=%2Flibrary%2Fmetadata%2F1%2Fthumb%2F1"), true)
|
|
|
|
let bare = ShelfItemMapper.plexItem(base, serverId: serverId, baseUrl: baseUrl, token: token)
|
|
XCTAssertNotNil(bare)
|
|
XCTAssertNil(bare?.posterUrl)
|
|
}
|
|
|
|
func testPlexHubWrappedResponseIsTolerated() throws {
|
|
let wrapped = """
|
|
{
|
|
"MediaContainer": {
|
|
"Hub": [
|
|
{"Metadata": [{"ratingKey": "7", "type": "movie", "title": "Alien", "thumb": "/library/metadata/7/thumb/1"}]},
|
|
{"Metadata": [{"ratingKey": "8", "type": "movie", "title": "Aliens"}]}
|
|
]
|
|
}
|
|
}
|
|
"""
|
|
let items = ShelfItemMapper.plexItems(
|
|
fromResponse: try fixture(wrapped),
|
|
serverId: serverId,
|
|
baseUrl: baseUrl,
|
|
token: token
|
|
)
|
|
XCTAssertEqual(items.map(\.contentId), ["plezy_srv-1_7", "plezy_srv-1_8"])
|
|
}
|
|
|
|
// MARK: - MediaBrowser mapping
|
|
|
|
private static let mediaBrowserFixture = """
|
|
{
|
|
"Items": [
|
|
{
|
|
"Id": "ep1",
|
|
"Type": "Episode",
|
|
"Name": "Winter Is Coming",
|
|
"SeriesName": "Game of Thrones",
|
|
"ParentIndexNumber": 1,
|
|
"IndexNumber": 3,
|
|
"RunTimeTicks": 36000000000,
|
|
"SeasonId": "season-9",
|
|
"SeriesId": "series-4",
|
|
"UserData": {
|
|
"PlaybackPositionTicks": 9000000000,
|
|
"LastPlayedDate": "2023-11-14T22:13:20.0000000Z"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
|
|
func testMediaBrowserEpisodeMappingConvertsTicksAndPrefersSeasonPoster() throws {
|
|
let items = ShelfItemMapper.mediaBrowserItems(
|
|
fromResponse: try fixture(Self.mediaBrowserFixture),
|
|
serverId: serverId,
|
|
baseUrl: baseUrl,
|
|
token: token
|
|
)
|
|
let episode = try XCTUnwrap(items.first)
|
|
XCTAssertEqual(episode.contentId, "plezy_srv-1_ep1")
|
|
XCTAssertEqual(episode.title, "Game of Thrones")
|
|
XCTAssertEqual(episode.episodeTitle, "Winter Is Coming")
|
|
XCTAssertEqual(episode.seasonNumber, 1)
|
|
XCTAssertEqual(episode.episodeNumber, 3)
|
|
// RunTimeTicks / PlaybackPositionTicks are 100 ns ticks; divide by 10_000
|
|
// for milliseconds.
|
|
XCTAssertEqual(episode.durationMilliseconds, 3_600_000)
|
|
XCTAssertEqual(episode.lastPlaybackPositionMilliseconds, 900_000)
|
|
XCTAssertEqual(try XCTUnwrap(episode.playbackProgress), 0.25, accuracy: 0.0001)
|
|
XCTAssertEqual(episode.recency, 1_700_000_000)
|
|
XCTAssertEqual(
|
|
episode.posterUrl,
|
|
"\(baseUrl)/Items/season-9/Images/Primary?maxWidth=600&maxHeight=900&api_key=\(token)"
|
|
)
|
|
}
|
|
|
|
func testMediaBrowserPosterFallsBackToSeriesThenOwnImage() {
|
|
let base: [String: Any] = ["Id": "ep1", "Type": "Episode", "Name": "Ep", "SeriesName": "Show"]
|
|
|
|
var item = base
|
|
item["SeriesId"] = "series-4"
|
|
let seriesPoster = ShelfItemMapper.mediaBrowserItem(item, serverId: serverId, baseUrl: baseUrl, token: token)
|
|
XCTAssertEqual(seriesPoster?.posterUrl?.contains("/Items/series-4/Images/Primary"), true)
|
|
|
|
let ownPoster = ShelfItemMapper.mediaBrowserItem(base, serverId: serverId, baseUrl: baseUrl, token: token)
|
|
XCTAssertEqual(ownPoster?.posterUrl?.contains("/Items/ep1/Images/Primary"), true)
|
|
}
|
|
|
|
func testMediaBrowserDateParsingToleratesSevenDigitFractions() {
|
|
XCTAssertEqual(ShelfItemMapper.parseMediaBrowserDate("2023-11-14T22:13:20.0000000Z"), 1_700_000_000)
|
|
XCTAssertEqual(ShelfItemMapper.parseMediaBrowserDate("2023-11-14T22:13:20Z"), 1_700_000_000)
|
|
XCTAssertNil(ShelfItemMapper.parseMediaBrowserDate("not a date"))
|
|
}
|
|
|
|
// MARK: - Source descriptors
|
|
|
|
private static let sourcesFixture = """
|
|
{
|
|
"schemaVersion": 3,
|
|
"ownerId": "profile-a",
|
|
"maxItems": 20,
|
|
"servers": [
|
|
{"serverId": "srv-plex", "kind": "plex", "name": "Den", "baseUrl": "https://plex.example.com"},
|
|
{"serverId": "srv-jelly", "kind": "jellyfin", "name": "Attic", "baseUrl": "https://jf.example.com", "userId": "user-1"}
|
|
]
|
|
}
|
|
"""
|
|
|
|
func testSourcesResolveJoinsKeychainTokens() throws {
|
|
let sources = try XCTUnwrap(
|
|
ShelfSourceStore.resolve(
|
|
payloadData: Data(Self.sourcesFixture.utf8),
|
|
tokensByServerId: ["srv-plex": "tok-a", "srv-jelly": "tok-b"]
|
|
)
|
|
)
|
|
XCTAssertEqual(sources.ownerId, "profile-a")
|
|
XCTAssertEqual(sources.maxItems, 20)
|
|
XCTAssertEqual(sources.servers.count, 2)
|
|
XCTAssertEqual(sources.servers[0].descriptor.kind, .plex)
|
|
XCTAssertEqual(sources.servers[0].token, "tok-a")
|
|
XCTAssertEqual(sources.servers[1].descriptor.userId, "user-1")
|
|
}
|
|
|
|
func testSourcesResolveRejectsWrongSchemaAndEmptyOwner() {
|
|
let wrongSchema = Self.sourcesFixture.replacingOccurrences(
|
|
of: "\"schemaVersion\": 3",
|
|
with: "\"schemaVersion\": 2"
|
|
)
|
|
XCTAssertNil(
|
|
ShelfSourceStore.resolve(
|
|
payloadData: Data(wrongSchema.utf8),
|
|
tokensByServerId: ["srv-plex": "tok-a"]
|
|
)
|
|
)
|
|
|
|
let emptyOwner = Self.sourcesFixture.replacingOccurrences(
|
|
of: "\"ownerId\": \"profile-a\"",
|
|
with: "\"ownerId\": \"\""
|
|
)
|
|
XCTAssertNil(
|
|
ShelfSourceStore.resolve(
|
|
payloadData: Data(emptyOwner.utf8),
|
|
tokensByServerId: ["srv-plex": "tok-a"]
|
|
)
|
|
)
|
|
}
|
|
|
|
func testSourcesResolveSkipsServersWithoutTokensAndMalformedSiblings() throws {
|
|
// A server with no keychain token is dropped; all dropped means nil.
|
|
let partial = try XCTUnwrap(
|
|
ShelfSourceStore.resolve(
|
|
payloadData: Data(Self.sourcesFixture.utf8),
|
|
tokensByServerId: ["srv-jelly": "tok-b"]
|
|
)
|
|
)
|
|
XCTAssertEqual(partial.servers.map(\.descriptor.serverId), ["srv-jelly"])
|
|
XCTAssertNil(
|
|
ShelfSourceStore.resolve(payloadData: Data(Self.sourcesFixture.utf8), tokensByServerId: [:])
|
|
)
|
|
|
|
// An unknown kind only sinks its own entry, not the whole payload.
|
|
let malformedSibling = Self.sourcesFixture.replacingOccurrences(
|
|
of: "\"kind\": \"plex\"",
|
|
with: "\"kind\": \"unknown\""
|
|
)
|
|
let survivors = try XCTUnwrap(
|
|
ShelfSourceStore.resolve(
|
|
payloadData: Data(malformedSibling.utf8),
|
|
tokensByServerId: ["srv-plex": "tok-a", "srv-jelly": "tok-b"]
|
|
)
|
|
)
|
|
XCTAssertEqual(survivors.servers.map(\.descriptor.serverId), ["srv-jelly"])
|
|
}
|
|
|
|
// MARK: - Merge
|
|
|
|
func testMergeOrdersByRecencyDescendingDeduplicatesAndCaps() {
|
|
let merged = ShelfItemMapper.merge(
|
|
[
|
|
[makeItem("a", recency: 10), makeItem("b", recency: 5)],
|
|
[makeItem("c", recency: 20), makeItem("a", recency: 99)],
|
|
],
|
|
maxItems: 2
|
|
)
|
|
// "a" keeps its first (resume-ordered) occurrence, then recency sorts.
|
|
XCTAssertEqual(merged.map(\.contentId), ["c", "a"])
|
|
XCTAssertEqual(merged[1].recency, 10)
|
|
|
|
let uncapped = ShelfItemMapper.merge([[makeItem("b", recency: 0), makeItem("a", recency: 0)]], maxItems: 20)
|
|
// Equal recency keeps input order.
|
|
XCTAssertEqual(uncapped.map(\.contentId), ["b", "a"])
|
|
}
|
|
|
|
// MARK: - Request construction
|
|
|
|
func testPlexContinueWatchingRequestCarriesTokenOnlyAsHeader() throws {
|
|
let request = try XCTUnwrap(
|
|
ShelfFetcher.plexContinueWatchingRequest(baseUrl: baseUrl + "/", token: token, maxItems: 20)
|
|
)
|
|
let url = try XCTUnwrap(request.url?.absoluteString)
|
|
XCTAssertEqual(url, "\(baseUrl)/hubs/continueWatching?count=20&includeGuids=1")
|
|
XCTAssertFalse(url.contains(token))
|
|
let headers = request.allHTTPHeaderFields ?? [:]
|
|
XCTAssertEqual(headers["X-Plex-Token"], token)
|
|
XCTAssertEqual(headers["Accept"], "application/json")
|
|
XCTAssertEqual(headers.values.filter { $0.contains(token) }.count, 1)
|
|
}
|
|
|
|
func testMediaBrowserRequestsCarryTokenOnlyAsHeader() throws {
|
|
let resume = try XCTUnwrap(
|
|
ShelfFetcher.mediaBrowserResumeRequest(baseUrl: baseUrl, token: token, userId: "user-1", maxItems: 20)
|
|
)
|
|
XCTAssertEqual(
|
|
resume.url?.absoluteString,
|
|
"\(baseUrl)/UserItems/Resume?userId=user-1&Limit=20&MediaTypes=Video"
|
|
+ "&Recursive=true&EnableTotalRecordCount=false"
|
|
)
|
|
|
|
let nextUp = try XCTUnwrap(
|
|
ShelfFetcher.mediaBrowserNextUpRequest(baseUrl: baseUrl, token: token, userId: "user-1", maxItems: 20)
|
|
)
|
|
XCTAssertEqual(
|
|
nextUp.url?.absoluteString,
|
|
"\(baseUrl)/Shows/NextUp?userId=user-1&Limit=20"
|
|
+ "&EnableResumable=false&EnableTotalRecordCount=false"
|
|
)
|
|
|
|
for request in [resume, nextUp] {
|
|
let url = try XCTUnwrap(request.url?.absoluteString)
|
|
XCTAssertFalse(url.contains(token))
|
|
let headers = request.allHTTPHeaderFields ?? [:]
|
|
XCTAssertEqual(headers["X-Emby-Token"], token)
|
|
XCTAssertEqual(headers.values.filter { $0.contains(token) }.count, 1)
|
|
}
|
|
}
|
|
|
|
func testPosterUrlsCarryTokenAsQueryParameterExactlyOnce() throws {
|
|
let plex = try XCTUnwrap(
|
|
ShelfItemMapper.plexPosterUrl(path: "/library/metadata/55/thumb/1", baseUrl: baseUrl, token: token)
|
|
)
|
|
XCTAssertEqual(occurrences(of: token, in: plex), 1)
|
|
XCTAssertEqual(occurrences(of: "X-Plex-Token=", in: plex), 1)
|
|
|
|
let mediaBrowser = try XCTUnwrap(
|
|
ShelfItemMapper.mediaBrowserPosterUrl(itemId: "season-9", baseUrl: baseUrl, token: token)
|
|
)
|
|
XCTAssertEqual(occurrences(of: token, in: mediaBrowser), 1)
|
|
XCTAssertEqual(occurrences(of: "api_key=", in: mediaBrowser), 1)
|
|
}
|
|
|
|
// MARK: - Token redaction
|
|
|
|
func testTokenNeverAppearsInDescriptionOrDebugStrings() throws {
|
|
let source = ShelfServerSource(
|
|
descriptor: ShelfServerDescriptor(
|
|
serverId: "srv-plex",
|
|
kind: .plex,
|
|
name: "Den",
|
|
baseUrl: baseUrl,
|
|
userId: nil
|
|
),
|
|
token: token
|
|
)
|
|
XCTAssertFalse(String(describing: source).contains(token))
|
|
XCTAssertFalse(String(reflecting: source).contains(token))
|
|
XCTAssertFalse(source.description.contains(token))
|
|
XCTAssertFalse(source.debugDescription.contains(token))
|
|
|
|
let item = try XCTUnwrap(
|
|
ShelfItemMapper.plexItem(
|
|
["ratingKey": "1", "title": "Heat", "thumb": "/library/metadata/1/thumb/1"],
|
|
serverId: serverId,
|
|
baseUrl: baseUrl,
|
|
token: token
|
|
)
|
|
)
|
|
XCTAssertEqual(item.posterUrl?.contains(token), true)
|
|
XCTAssertFalse(String(describing: item).contains(token))
|
|
XCTAssertFalse(String(reflecting: item).contains(token))
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func fixture(_ json: String) throws -> Any {
|
|
try JSONSerialization.jsonObject(with: Data(json.utf8))
|
|
}
|
|
|
|
private func makeItem(_ contentId: String, recency: Double) -> ShelfFetchedItem {
|
|
ShelfFetchedItem(
|
|
contentId: contentId,
|
|
title: contentId,
|
|
episodeTitle: nil,
|
|
type: "movie",
|
|
seasonNumber: nil,
|
|
episodeNumber: nil,
|
|
durationMilliseconds: nil,
|
|
lastPlaybackPositionMilliseconds: nil,
|
|
posterUrl: nil,
|
|
recency: recency
|
|
)
|
|
}
|
|
|
|
private func occurrences(of needle: String, in haystack: String) -> Int {
|
|
haystack.components(separatedBy: needle).count - 1
|
|
}
|
|
}
|