mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
Apply thumbnail fallback everywhere a single URL was rendered
The previous fix rebuilt the quality chain when converting persisted models back to videos, but many consumers discarded it again by rendering bestThumbnail (usually a maxresdefault.jpg that 404s for older videos) as a single URL with no fallback. Extract the retry logic from VideoThumbnailView into FallbackLazyImage and use it at every in-app site that can iterate: player thumbnails (mini bar, expanded sheet loaders, autoplay previews, tvOS audio-mode artwork - previously a silent black screen), video info card and tvOS header, and the tvOS playlist cover. Sites that fetch or send exactly one URL are rewritten to the always-available hqdefault variant via Thumbnail.reliableURL (exposed as Video.reliableThumbnailURL): Now Playing artwork, Top Shelf snapshots, remote control state, the frozen transition thumbnail, blurred info background, navigation covers, and playlist covers derived from a video's first thumbnail in Invidious/Yattee Server responses. Also invert RecentPlaylist's upgrade helper, which rewrote covers *to* maxresdefault, walk the quality chain when caching download thumbnails for offline artwork instead of giving up after one 404, and expand the remaining single-thumbnail Piped conversions into full chains.
This commit is contained in:
@@ -763,7 +763,7 @@ private struct InvidiousAuthPlaylist: Decodable, Sendable {
|
||||
description: description,
|
||||
author: author.map { Author(id: "", name: $0) },
|
||||
videoCount: videoCount,
|
||||
thumbnailURL: videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL),
|
||||
thumbnailURL: Thumbnail.reliableURL(for: videos?.first?.videoThumbnails?.first?.thumbnailURL(baseURL: baseURL)),
|
||||
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
|
||||
)
|
||||
}
|
||||
@@ -1551,7 +1551,7 @@ private struct InvidiousPlaylist: Decodable, Sendable {
|
||||
description: description,
|
||||
author: authorId.map { Author(id: $0, name: author ?? "") },
|
||||
videoCount: videoCount,
|
||||
thumbnailURL: validVideos.first?.thumbnails.first?.url,
|
||||
thumbnailURL: Thumbnail.reliableURL(for: validVideos.first?.thumbnails.first?.url),
|
||||
videos: validVideos
|
||||
)
|
||||
}
|
||||
@@ -1683,7 +1683,7 @@ private struct InvidiousSearchPlaylist: Decodable, Sendable {
|
||||
title: title,
|
||||
author: authorId.map { Author(id: $0, name: author ?? "") },
|
||||
videoCount: videoCount,
|
||||
thumbnailURL: thumbnailURL,
|
||||
thumbnailURL: Thumbnail.reliableURL(for: thumbnailURL),
|
||||
videos: videos?.map { $0.toVideo(baseURL: baseURL) } ?? []
|
||||
)
|
||||
}
|
||||
|
||||
@@ -532,9 +532,7 @@ private struct PipedVideo: Decodable, Sendable {
|
||||
publishedText: uploadedDate,
|
||||
viewCount: views.map { Int($0) },
|
||||
likeCount: nil,
|
||||
thumbnails: thumbnail.flatMap { URL(string: $0) }.map {
|
||||
[Thumbnail(url: $0, quality: .high)]
|
||||
} ?? [],
|
||||
thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
|
||||
isLive: duration == -1,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
@@ -771,9 +769,7 @@ private struct PipedSearchItem: Decodable, Sendable {
|
||||
publishedText: uploadedDate,
|
||||
viewCount: views.map { Int($0) },
|
||||
likeCount: nil,
|
||||
thumbnails: thumbnail.flatMap { URL(string: $0) }.map {
|
||||
[Thumbnail(url: $0, quality: .high)]
|
||||
} ?? [],
|
||||
thumbnails: Thumbnail.fallbackChain(for: thumbnail.flatMap { URL(string: $0) }),
|
||||
isLive: duration == -1,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
|
||||
@@ -1363,7 +1363,7 @@ private struct YatteePlaylist: Decodable, Sendable {
|
||||
description: description,
|
||||
author: authorId.map { Author(id: $0, name: author ?? "") },
|
||||
videoCount: videoCount,
|
||||
thumbnailURL: validVideos.first?.thumbnails.first?.url,
|
||||
thumbnailURL: Thumbnail.reliableURL(for: validVideos.first?.thumbnails.first?.url),
|
||||
videos: validVideos
|
||||
)
|
||||
}
|
||||
@@ -1440,7 +1440,7 @@ private struct YatteeSearchPlaylist: Decodable, Sendable {
|
||||
title: title,
|
||||
author: authorId.map { Author(id: $0, name: author ?? "") },
|
||||
videoCount: videoCount,
|
||||
thumbnailURL: thumbnailURL,
|
||||
thumbnailURL: Thumbnail.reliableURL(for: thumbnailURL),
|
||||
videos: videos?.map { $0.toVideo() } ?? []
|
||||
)
|
||||
}
|
||||
|
||||
@@ -346,12 +346,17 @@ extension DownloadManager {
|
||||
var thumbnailPath: String?
|
||||
var channelThumbnailPath: String?
|
||||
|
||||
// Download video thumbnail (best quality) - best-effort, ignore failures
|
||||
// Download video thumbnail - best-effort, ignore failures. The stored URL
|
||||
// is the best advertised variant (often maxresdefault, which 404s for
|
||||
// older videos), so walk the quality chain until one succeeds.
|
||||
if let thumbnailURL = download.thumbnailURL {
|
||||
thumbnailPath = await downloadThumbnail(
|
||||
from: thumbnailURL,
|
||||
filename: "\(videoID)_thumbnail.jpg"
|
||||
)
|
||||
for candidate in Thumbnail.fallbackChain(for: thumbnailURL) {
|
||||
thumbnailPath = await downloadThumbnail(
|
||||
from: candidate.url,
|
||||
filename: "\(videoID)_thumbnail.jpg"
|
||||
)
|
||||
if thumbnailPath != nil { break }
|
||||
}
|
||||
}
|
||||
|
||||
// Download channel thumbnail - best-effort, ignore failures
|
||||
|
||||
@@ -522,8 +522,10 @@ final class PlayerService {
|
||||
let localThumbnailPath = download.localThumbnailPath {
|
||||
localThumbnailURL = downloadManager.downloadsDirectory().appendingPathComponent(localThumbnailPath)
|
||||
}
|
||||
// Reliable (hqdefault) variant: artwork is a single fetch with no
|
||||
// fallback, and the best advertised variant often 404s.
|
||||
await nowPlayingService.loadArtwork(
|
||||
from: videoForNowPlaying.bestThumbnail?.url,
|
||||
from: videoForNowPlaying.reliableThumbnailURL,
|
||||
localPath: localThumbnailURL
|
||||
)
|
||||
}
|
||||
|
||||
@@ -466,7 +466,7 @@ final class RemoteControlCoordinator {
|
||||
networkService.updateAdvertisement(
|
||||
videoTitle: state.currentVideo?.title,
|
||||
channelName: state.currentVideo?.author.name,
|
||||
thumbnailURL: state.currentVideo?.bestThumbnail?.url,
|
||||
thumbnailURL: state.currentVideo?.reliableThumbnailURL,
|
||||
isPlaying: state.playbackState == .playing
|
||||
)
|
||||
}
|
||||
@@ -494,7 +494,7 @@ final class RemoteControlCoordinator {
|
||||
videoID: state.currentVideo?.id.videoID,
|
||||
videoTitle: state.currentVideo?.title,
|
||||
channelName: state.currentVideo?.author.name,
|
||||
thumbnailURL: state.currentVideo?.bestThumbnail?.url,
|
||||
thumbnailURL: state.currentVideo?.reliableThumbnailURL,
|
||||
currentTime: state.currentTime,
|
||||
duration: state.duration,
|
||||
isPlaying: state.playbackState == .playing,
|
||||
|
||||
@@ -100,7 +100,7 @@ private extension TopShelfSnapshotWriter {
|
||||
title: bookmark.title,
|
||||
authorName: bookmark.authorName,
|
||||
duration: bookmark.duration,
|
||||
thumbnailURL: bookmark.thumbnailURLString,
|
||||
thumbnailURL: reliableThumbnailURLString(bookmark.thumbnailURLString),
|
||||
deepLinkURL: deepLink,
|
||||
progressSeconds: nil
|
||||
)
|
||||
@@ -118,7 +118,7 @@ private extension TopShelfSnapshotWriter {
|
||||
title: entry.title,
|
||||
authorName: entry.authorName,
|
||||
duration: entry.duration,
|
||||
thumbnailURL: entry.thumbnailURLString,
|
||||
thumbnailURL: reliableThumbnailURLString(entry.thumbnailURLString),
|
||||
deepLinkURL: deepLink,
|
||||
progressSeconds: entry.watchedSeconds
|
||||
)
|
||||
@@ -190,8 +190,17 @@ private extension TopShelfSnapshotWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Top Shelf extension sets a single image URL with no failure fallback,
|
||||
/// so rewrite to the always-available `hqdefault` variant: the best advertised
|
||||
/// variant is often `maxresdefault`, which 404s for many older videos and
|
||||
/// would leave an empty tile.
|
||||
static func bestThumbnailURL(from thumbnails: [Thumbnail]) -> String? {
|
||||
thumbnails.max(by: { $0.quality < $1.quality })?.url.absoluteString
|
||||
Thumbnail.reliableURL(for: thumbnails.max(by: { $0.quality < $1.quality })?.url)?.absoluteString
|
||||
}
|
||||
|
||||
static func reliableThumbnailURLString(_ urlString: String?) -> String? {
|
||||
guard let urlString else { return nil }
|
||||
return Thumbnail.reliableURL(for: URL(string: urlString))?.absoluteString ?? urlString
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user