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
254 lines
10 KiB
Swift
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
|
|
}
|
|
}
|