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:
Arkadiusz Fal
2026-07-31 23:21:38 +02:00
parent fd1029d3e0
commit 2c0c2e853a
19 changed files with 126 additions and 88 deletions

View File

@@ -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) } ?? []
)
}

View File

@@ -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

View File

@@ -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() } ?? []
)
}

View File

@@ -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

View File

@@ -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
)
}

View File

@@ -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,

View File

@@ -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