Files
plezy/tvos/TopShelfExtension/TopShelfProvider.swift
T
edde746 291a22a4a4 feat(tvos): fetch Top Shelf content live and show poster art
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
2026-08-09 10:59:16 +02:00

254 lines
10 KiB
Swift

import CryptoKit
import Foundation
import TVServices
private enum TopShelfShared {
static let schemaVersion = 3
static let appGroupIdentifier = "group.com.edde746.plezy"
static let cacheDataKey = "PlezySystemShelfCacheData"
static let artworkDirectoryName = "SystemShelfArtwork"
static var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupIdentifier) }
static var artworkRoot: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
.appendingPathComponent(artworkDirectoryName, isDirectory: true)
}
}
private struct TopShelfCachePayload: Decodable {
struct Section: Decodable {
let id: String
let title: String
let items: [Item]
}
struct Item: Decodable {
let contentId: String
let title: String
let episodeTitle: String?
let description: String?
let artworkKey: String?
let posterUrl: String?
let type: String?
let duration: Double?
let lastPlaybackPosition: Double?
let seasonNumber: Int?
let episodeNumber: Int?
private enum CodingKeys: String, CodingKey {
case contentId
case title
case episodeTitle
case description
case artworkKey
case posterUrl
case type
case duration
case lastPlaybackPosition
case seasonNumber
case episodeNumber
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
contentId = try container.decode(String.self, forKey: .contentId)
title = try container.decode(String.self, forKey: .title)
episodeTitle = try container.decodeIfPresent(String.self, forKey: .episodeTitle)
description = try container.decodeIfPresent(String.self, forKey: .description)
artworkKey = try container.decodeIfPresent(String.self, forKey: .artworkKey)
posterUrl = try container.decodeIfPresent(String.self, forKey: .posterUrl)
type = try container.decodeIfPresent(String.self, forKey: .type)
duration = container.decodeFlexibleDoubleIfPresent(.duration)
lastPlaybackPosition = container.decodeFlexibleDoubleIfPresent(.lastPlaybackPosition)
seasonNumber = container.decodeFlexibleIntIfPresent(.seasonNumber)
episodeNumber = container.decodeFlexibleIntIfPresent(.episodeNumber)
}
init(fetched: ShelfFetchedItem) {
contentId = fetched.contentId
title = fetched.title
episodeTitle = fetched.episodeTitle
description = nil
artworkKey = nil
posterUrl = fetched.posterUrl
type = fetched.type
duration = fetched.durationMilliseconds
lastPlaybackPosition = fetched.lastPlaybackPositionMilliseconds
seasonNumber = fetched.seasonNumber
episodeNumber = fetched.episodeNumber
}
}
let schemaVersion: Int
let ownerId: String
let sections: [Section]
}
private extension KeyedDecodingContainer {
func decodeFlexibleDoubleIfPresent(_ key: Key) -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) }
return nil
}
func decodeFlexibleIntIfPresent(_ key: Key) -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) }
return nil
}
}
final class TopShelfProvider: TVTopShelfContentProvider {
override func loadTopShelfContent() async -> (any TVTopShelfContent)? {
// Prefer a live fetch when the app has published server sources; any
// failure falls back to replaying the last committed cache unchanged.
if let sources = ShelfSourceStore.load(),
let items = await ShelfFetcher.fetchContinueWatching(sources: sources)
{
persistLiveSnapshot(items, ownerId: sources.ownerId)
return buildLiveContent(items, ownerId: sources.ownerId)
}
return buildContent()
}
private func buildLiveContent(_ items: [ShelfFetchedItem], ownerId: String) -> TVTopShelfContent? {
let sectionItems = items.compactMap {
makeTopShelfItem(TopShelfCachePayload.Item(fetched: $0), ownerId: ownerId)
}
guard !sectionItems.isEmpty else { return nil }
let collection = TVTopShelfItemCollection(items: sectionItems)
collection.title = "Continue Watching"
return TVTopShelfSectionedContent(sections: [collection])
}
/// Rewrites the shared cache with the live result so offline replay stays
/// fresh. Token-bearing poster URLs stay inside the app group container.
private func persistLiveSnapshot(_ items: [ShelfFetchedItem], ownerId: String) {
guard let defaults = TopShelfShared.sharedDefaults else { return }
let itemDicts = items.map { item -> [String: Any] in
var dict: [String: Any] = ["contentId": item.contentId, "title": item.title]
if let episodeTitle = item.episodeTitle { dict["episodeTitle"] = episodeTitle }
if let type = item.type { dict["type"] = type }
if let duration = item.durationMilliseconds { dict["duration"] = duration }
if let position = item.lastPlaybackPositionMilliseconds {
dict["lastPlaybackPosition"] = position
}
if let seasonNumber = item.seasonNumber { dict["seasonNumber"] = seasonNumber }
if let episodeNumber = item.episodeNumber { dict["episodeNumber"] = episodeNumber }
if let posterUrl = item.posterUrl { dict["posterUrl"] = posterUrl }
if item.recency > 0 { dict["lastEngagementTime"] = item.recency }
return dict
}
let payload: [String: Any] = [
"schemaVersion": TopShelfShared.schemaVersion,
"ownerId": ownerId,
"updatedAt": Date().timeIntervalSince1970,
"sections": [["id": "continue_watching", "title": "Continue Watching", "items": itemDicts]],
]
guard JSONSerialization.isValidJSONObject(payload),
let data = try? JSONSerialization.data(withJSONObject: payload)
else { return }
defaults.set(data, forKey: TopShelfShared.cacheDataKey)
defaults.synchronize()
}
private func buildContent() -> TVTopShelfContent? {
guard let defaults = TopShelfShared.sharedDefaults,
let data = defaults.data(forKey: TopShelfShared.cacheDataKey)
else { return nil }
guard let payload = try? JSONDecoder().decode(TopShelfCachePayload.self, from: data),
payload.schemaVersion == TopShelfShared.schemaVersion,
!payload.ownerId.isEmpty
else { return nil }
let sections = payload.sections.compactMap { section -> TVTopShelfItemCollection<TVTopShelfSectionedItem>? in
let items = section.items.compactMap { makeTopShelfItem($0, ownerId: payload.ownerId) }
guard !items.isEmpty else { return nil }
let collection = TVTopShelfItemCollection(items: items)
collection.title = section.title
return collection
}
guard !sections.isEmpty else { return nil }
return TVTopShelfSectionedContent(sections: sections)
}
private func makeTopShelfItem(_ cacheItem: TopShelfCachePayload.Item, ownerId: String) -> TVTopShelfSectionedItem? {
guard !cacheItem.contentId.isEmpty else { return nil }
let item = TVTopShelfSectionedItem(identifier: cacheItem.contentId)
item.title = displayTitle(for: cacheItem)
item.imageShape = .poster
if let duration = cacheItem.duration, duration > 0,
let position = cacheItem.lastPlaybackPosition, position > 0
{
item.playbackProgress = min(max(position / duration, 0), 1)
}
if let url = deepLinkURL(contentId: cacheItem.contentId) {
let action = TVTopShelfAction(url: url)
item.displayAction = action
item.playAction = action
}
if let remoteURL = remoteArtworkURL(cacheItem.posterUrl) {
item.setImageURL(remoteURL, for: .screenScale1x)
item.setImageURL(remoteURL, for: .screenScale2x)
} else if let key = cacheItem.artworkKey, let localURL = localArtworkURL(ownerId: ownerId, key: key) {
item.setImageURL(localURL, for: .screenScale1x)
item.setImageURL(localURL, for: .screenScale2x)
}
return item
}
private func remoteArtworkURL(_ posterUrl: String?) -> URL? {
guard let posterUrl, let url = URL(string: posterUrl),
["http", "https"].contains(url.scheme?.lowercased() ?? "")
else { return nil }
return url
}
private func localArtworkURL(ownerId: String, key: String) -> URL? {
guard key.range(of: "^[a-f0-9]{32}\\.art$", options: .regularExpression) != nil,
let root = TopShelfShared.artworkRoot
else { return nil }
let ownerHash = SHA256.hash(data: Data(ownerId.utf8)).map { String(format: "%02x", $0) }.joined()
let canonicalRoot = root.standardizedFileURL.resolvingSymlinksInPath()
let ownerDirectory = canonicalRoot.appendingPathComponent(ownerHash, isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath()
guard ownerDirectory.deletingLastPathComponent() == canonicalRoot else { return nil }
let candidate = ownerDirectory.appendingPathComponent(key, isDirectory: false)
.standardizedFileURL.resolvingSymlinksInPath()
guard candidate.deletingLastPathComponent() == ownerDirectory else { return nil }
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory), !isDirectory.boolValue
else {
return nil
}
return candidate
}
private func displayTitle(for item: TopShelfCachePayload.Item) -> String {
guard let episodeTitle = item.episodeTitle, !episodeTitle.isEmpty else { return item.title }
let episodePrefix: String? = {
if let seasonNumber = item.seasonNumber, let episodeNumber = item.episodeNumber {
return "S\(seasonNumber) E\(episodeNumber)"
}
if let episodeNumber = item.episodeNumber { return "E\(episodeNumber)" }
return nil
}()
// S/E leads (official Plex app format) so it is readable immediately
// instead of after the focused-item marquee scrolls a long series name;
// the poster artwork already identifies the series. Without numbers the
// series name is the only usable context, so it stays.
if let episodePrefix { return "\(episodePrefix) - \(episodeTitle)" }
return "\(item.title) - \(episodeTitle)"
}
private func deepLinkURL(contentId: String) -> URL? {
var components = URLComponents()
components.scheme = "plezy"
components.host = "play"
components.queryItems = [URLQueryItem(name: "content_id", value: contentId)]
return components.url
}
}