mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
Metadata was only fetched after adding a share (mobile) or from the Settings buttons (desktop), so a show or episode that turned up in an existing share stayed bare. Every crawl is now followed by a match run when enrichment is on; `plan` makes no requests for a library that is already matched. A stored episode guide is fetched again when a file newer than it has no named entry, and daily for two weeks while an aired episode is still unnamed. Removing a share now carries its watch state to any other share holding the same file, `pstr remove` drops the share's catalog rows, and the app forgets catalog rows of shares no longer configured — an older `pstr remove` left them, and every episode showed twice. iOS, for a slow iPad: - swipe back on a settings page uses its own edge strip; on the whole page it also pressed the row under the finger and opened the metadata sheet - "Change match" from the tile menu opens once the menu has dismissed, not after a guessed 350 ms that a slow device missed - the activity banner tracks each running line by itself (also Android), so overlapping work no longer leaves it spinning forever - buttons, tiles and rows draw their press; marking watched shows at once; remove/repair share, sign out and deletions show the banner; the player says "Opening…" while the stream opens - posters are read into a reserved buffer instead of a byte at a time
323 lines
13 KiB
Swift
323 lines
13 KiB
Swift
import CryptoKit
|
|
import ImageIO
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
/// One file to pull Proton's own thumbnail from when no provider poster exists.
|
|
///
|
|
/// Proton renders a thumbnail per file, so any episode of a show is a frame of
|
|
/// the right show — the desktop client picks the first for the same reason.
|
|
struct ThumbnailSource: Hashable, Sendable {
|
|
let shareId: String
|
|
let volumeId: String
|
|
let linkId: String
|
|
|
|
var key: String { "\(shareId)/\(linkId)" }
|
|
}
|
|
|
|
extension TitleRecord {
|
|
/// What the first episode of a title offers as a poster. Metadata lookups
|
|
/// are off by default, so for a freshly crawled library this is the only
|
|
/// artwork there is.
|
|
var thumbnailSource: ThumbnailSource? {
|
|
seasons.lazy.compactMap(\.episodes.first).first.map(\.thumbnailSource)
|
|
}
|
|
}
|
|
|
|
extension EpisodeRecord {
|
|
var thumbnailSource: ThumbnailSource { ThumbnailSource(shareId: shareId, volumeId: volumeId, linkId: linkId) }
|
|
}
|
|
|
|
/// HTTPS-only artwork, cropped to whatever frame its parent gives it.
|
|
///
|
|
/// `fallback` is used only when there is no provider artwork or it fails to
|
|
/// load, which — with metadata off — is the common case rather than the rare
|
|
/// one. Until either answers, the box pulses as a skeleton; when neither has
|
|
/// anything, it shows `ArtworkPlaceholder` rather than an empty card — without
|
|
/// initials when `labelled` is false, for art with the name already over it.
|
|
struct RemoteArtwork: View {
|
|
let url: String?
|
|
let description: String
|
|
var fallback: ThumbnailSource?
|
|
var labelled = true
|
|
@State private var load: ArtworkLoad
|
|
|
|
init(_ url: String?, _ description: String, fallback: ThumbnailSource? = nil, labelled: Bool = true) {
|
|
self.url = url
|
|
self.description = description
|
|
self.fallback = fallback
|
|
self.labelled = labelled
|
|
// A poster already decoded draws in the first frame, so scrolling back
|
|
// over the grid does not flash a skeleton per tile.
|
|
_load = State(initialValue: ArtworkLoader.shared.cached(url: url, fallback: fallback).map(ArtworkLoad.loaded) ?? .pending)
|
|
}
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.overlay {
|
|
switch load {
|
|
case .pending:
|
|
Skeleton()
|
|
case .missing:
|
|
ArtworkPlaceholder(name: description, labelled: labelled)
|
|
case let .loaded(image):
|
|
Image(uiImage: image).resizable().scaledToFill()
|
|
}
|
|
}
|
|
.clipped()
|
|
.accessibilityElement()
|
|
.accessibilityLabel(description)
|
|
.task(id: Request(url: url, fallback: fallback)) {
|
|
if case .loaded = load, ArtworkLoader.shared.cached(url: url, fallback: fallback) != nil { return }
|
|
let loaded = await ArtworkLoader.shared.artwork(url: url, fallback: fallback)
|
|
if Task.isCancelled { return }
|
|
load = loaded.map(ArtworkLoad.loaded) ?? .missing
|
|
}
|
|
}
|
|
|
|
private struct Request: Hashable {
|
|
let url: String?
|
|
let fallback: ThumbnailSource?
|
|
}
|
|
}
|
|
|
|
private enum ArtworkLoad {
|
|
case pending
|
|
case missing
|
|
case loaded(UIImage)
|
|
}
|
|
|
|
/// Something being loaded: the surface tint, breathing.
|
|
///
|
|
/// A spinner per tile is a grid of spinners; a shape where the poster will be
|
|
/// says what is coming and where, and settles the layout before it arrives.
|
|
struct Skeleton: View {
|
|
@Environment(\.scheme) private var scheme
|
|
@State private var dim = false
|
|
|
|
var body: some View {
|
|
scheme.surfaceVariant
|
|
.opacity(dim ? 0.55 : 1)
|
|
.onAppear {
|
|
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)) { dim = true }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Art for a title that has none: the accent as a gradient, and its initials.
|
|
///
|
|
/// Each name sets where on the accent ramp its gradient starts, so neighbours
|
|
/// differ while every one stays in the theme's colours — and since the hash is
|
|
/// Java's, a title gets the same colour here as on Android.
|
|
struct ArtworkPlaceholder: View {
|
|
let name: String
|
|
var labelled = true
|
|
@Environment(\.scheme) private var scheme
|
|
|
|
var body: some View {
|
|
let start = mix(scheme.primary, scheme.secondary, Double(javaHash(name).modulo(5)) / 4)
|
|
GeometryReader { geometry in
|
|
LinearGradient(colors: [start, mix(start, .black, 0.55)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
.overlay {
|
|
if labelled {
|
|
Text(initials(name))
|
|
.font(.inter(size: min(geometry.size.width, geometry.size.height) * 0.3, semibold: true))
|
|
.foregroundStyle(Color.white.opacity(0.9))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two
|
|
/// capitalised words, so particles and articles in a romanised name do not
|
|
/// take a slot. A name with no capitals uses its first two words.
|
|
func initials(_ name: String) -> String {
|
|
let words = name
|
|
.split(whereSeparator: { " -_.:".contains($0) })
|
|
.filter { $0.first.map { $0.isLetter || $0.isNumber } ?? false }
|
|
let capitalised = words.filter { $0.first!.isUppercase || $0.first!.isNumber }
|
|
return (capitalised.isEmpty ? words : capitalised).prefix(2).map { $0.first!.uppercased() }.joined()
|
|
}
|
|
|
|
/// `String.hashCode()` from the JVM, over UTF-16 code units.
|
|
func javaHash(_ text: String) -> Int32 {
|
|
text.utf16.reduce(Int32(0)) { hash, unit in hash &* 31 &+ Int32(unit) }
|
|
}
|
|
|
|
private extension Int32 {
|
|
/// Kotlin's `mod`: never negative.
|
|
func modulo(_ divisor: Int32) -> Int32 {
|
|
let remainder = self % divisor
|
|
return remainder < 0 ? remainder + divisor : remainder
|
|
}
|
|
}
|
|
|
|
/// The HTTPS-only fetch behind every poster, its disk cache under
|
|
/// `Caches/metadata-art`, and Proton's own thumbnails as the fallback.
|
|
final class ArtworkLoader: @unchecked Sendable {
|
|
static let shared = ArtworkLoader()
|
|
|
|
/// Decoded posters, so scrolling the grid re-reads neither disk nor
|
|
/// decoder. Keyed by artwork URL or by `share/link` for a Proton
|
|
/// thumbnail; the two cannot collide, since one is always an `https:` URL.
|
|
private let decoded: NSCache<NSString, UIImage> = {
|
|
let cache = NSCache<NSString, UIImage>()
|
|
cache.totalCostLimit = 32 * 1024 * 1024
|
|
return cache
|
|
}()
|
|
|
|
/// Files known to have no Proton thumbnail, so the answer is paid for once.
|
|
/// Deliberately not persisted: a re-upload can add one.
|
|
private var missing = Set<String>()
|
|
private let lock = NSLock()
|
|
|
|
private let session: URLSession = {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.timeoutIntervalForRequest = 10
|
|
configuration.urlCache = nil
|
|
return URLSession(configuration: configuration)
|
|
}()
|
|
|
|
private static let maxBytes = 12 * 1024 * 1024
|
|
private static let maxCacheBytes = 48 * 1024 * 1024
|
|
/// Longest edge worth decoding: a poster on a phone, or lock-screen art.
|
|
private static let maxEdge = 1024
|
|
|
|
func cached(url: String?, fallback: ThumbnailSource?) -> UIImage? {
|
|
if let url, let image = decoded.object(forKey: url as NSString) { return image }
|
|
if let key = fallback?.key { return decoded.object(forKey: key as NSString) }
|
|
return nil
|
|
}
|
|
|
|
func artwork(url: String?, fallback: ThumbnailSource?) async -> UIImage? {
|
|
if let image = await remote(url) { return image }
|
|
return await thumbnail(fallback)
|
|
}
|
|
|
|
/// Provider artwork, from the disk cache or the network.
|
|
func remote(_ requested: String?) async -> UIImage? {
|
|
guard let requested else { return nil }
|
|
if let image = decoded.object(forKey: requested as NSString) { return image }
|
|
guard let url = URL(string: requested), url.scheme?.lowercased() == "https" else { return nil }
|
|
let directory = Self.directory
|
|
let digest = SHA256.hash(data: Data(requested.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
let cachedFile = directory.appendingPathComponent("\(digest).img")
|
|
let files = FileManager.default
|
|
let fresh = !files.fileExists(atPath: cachedFile.path)
|
|
let bytes: Data
|
|
if fresh {
|
|
guard let downloaded = await download(url) else { return nil }
|
|
bytes = downloaded
|
|
} else {
|
|
guard let read = try? Data(contentsOf: cachedFile) else { return nil }
|
|
bytes = read
|
|
}
|
|
guard let image = Self.decodeSampled(bytes) else { return nil }
|
|
if fresh {
|
|
// Written only once it has decoded: caching the bytes first is what
|
|
// turns one hostile poster into a failure on every launch.
|
|
let temporary = directory.appendingPathComponent("\(digest).part")
|
|
if (try? bytes.write(to: temporary)) != nil {
|
|
try? files.moveItem(at: temporary, to: cachedFile)
|
|
Self.prune(directory)
|
|
}
|
|
} else {
|
|
try? files.setAttributes([.modificationDate: Date()], ofItemAtPath: cachedFile.path)
|
|
}
|
|
decoded.setObject(image, forKey: requested as NSString, cost: image.cost)
|
|
return image
|
|
}
|
|
|
|
/// Proton's own thumbnail for one file, decoded. A failed call is not
|
|
/// remembered — an unreachable share is transient — but a file with no
|
|
/// thumbnail is, for the session.
|
|
func thumbnail(_ source: ThumbnailSource?) async -> UIImage? {
|
|
guard let source else { return nil }
|
|
let key = source.key
|
|
if let image = decoded.object(forKey: key as NSString) { return image }
|
|
if lock.withLock({ missing.contains(key) }) { return nil }
|
|
let fetched: Data?
|
|
do {
|
|
fetched = try await NativeRuntime.engine().thumbnail(shareId: source.shareId, volumeId: source.volumeId, linkId: source.linkId)
|
|
} catch {
|
|
return nil
|
|
}
|
|
guard let bytes = fetched, let image = Self.decodeSampled(bytes) else {
|
|
lock.withLock { _ = missing.insert(key) }
|
|
return nil
|
|
}
|
|
decoded.setObject(image, forKey: key as NSString, cost: image.cost)
|
|
return image
|
|
}
|
|
|
|
private func download(_ url: URL) async -> Data? {
|
|
guard let (stream, response) = try? await session.bytes(from: url),
|
|
let http = response as? HTTPURLResponse,
|
|
(200 ... 299).contains(http.statusCode),
|
|
// A redirect must not leave HTTPS.
|
|
http.url?.scheme?.lowercased() == "https"
|
|
else { return nil }
|
|
// Refused before reading when the server says up front it is too big.
|
|
if response.expectedContentLength > Int64(Self.maxBytes) { return nil }
|
|
// An array sized once, not a `Data` grown a byte at a time: on an old
|
|
// iPad, a screen of posters read that way was seconds of CPU.
|
|
var bytes = [UInt8]()
|
|
bytes.reserveCapacity(Int(min(max(response.expectedContentLength, 0), Int64(Self.maxBytes))))
|
|
do {
|
|
for try await byte in stream {
|
|
bytes.append(byte)
|
|
if bytes.count > Self.maxBytes { return nil }
|
|
}
|
|
} catch {
|
|
return nil
|
|
}
|
|
return Data(bytes)
|
|
}
|
|
|
|
/// Decode within a bound the display can actually use: the compressed size
|
|
/// says nothing about what an image costs decoded.
|
|
private static func decodeSampled(_ bytes: Data) -> UIImage? {
|
|
guard let source = CGImageSourceCreateWithData(bytes as CFData, nil) else { return nil }
|
|
let options: [CFString: Any] = [
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceShouldCacheImmediately: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
|
|
]
|
|
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
|
|
return UIImage(cgImage: image)
|
|
}
|
|
|
|
private static var directory: URL {
|
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
let directory = caches.appendingPathComponent("metadata-art", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
return directory
|
|
}
|
|
|
|
/// Hold the cache directory under `maxCacheBytes`, oldest first.
|
|
private static func prune(_ directory: URL) {
|
|
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
|
|
guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys) else { return }
|
|
let entries = files.compactMap { file -> (URL, Date, Int)? in
|
|
guard let values = try? file.resourceValues(forKeys: Set(keys)) else { return nil }
|
|
return (file, values.contentModificationDate ?? .distantPast, values.fileSize ?? 0)
|
|
}.sorted { $0.1 < $1.1 }
|
|
var total = entries.reduce(0) { $0 + $1.2 }
|
|
for (file, _, size) in entries {
|
|
if total <= maxCacheBytes { return }
|
|
total -= size
|
|
try? FileManager.default.removeItem(at: file)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UIImage {
|
|
var cost: Int {
|
|
guard let image = cgImage else { return 0 }
|
|
return image.bytesPerRow * image.height
|
|
}
|
|
}
|