Refresh expired thumbnail URLs for downloads and video info

Proxied thumbnail URLs from Invidious/Piped/Yattee server expire over
time. Two paths were left holding stale URLs: the Video Info carousel
kept the original list copy even after fresh details arrived, and
downloaded videos rendered from the remote URL snapshot taken at
download time while the local thumbnail on disk was ignored.

Evict stale URLs from the Nuke cache when fresh video details load,
pass the fresh details through to the videoCard thumbnail, and resolve
downloads' thumbnails from the local file when localThumbnailPath is
set.
This commit is contained in:
Arkadiusz Fal
2026-04-17 06:47:14 +02:00
parent abd432fd0e
commit 2efa0708c8
7 changed files with 67 additions and 17 deletions

View File

@@ -298,9 +298,29 @@ struct Download: Identifiable, Codable, Sendable, Equatable {
self.audioBitrate = audioBitrate
}
/// Resolves the local thumbnail file URL, if the thumbnail has been
/// downloaded to disk. Remote `thumbnailURL`s for proxied sources
/// (Invidious / Piped / Yattee server) expire, so prefer the local copy
/// whenever it is available.
func localThumbnailURL(in downloadsDirectory: URL) -> URL? {
guard let localThumbnailPath else { return nil }
return downloadsDirectory.appendingPathComponent(localThumbnailPath)
}
/// Convert to a Video model for display purposes.
func toVideo() -> Video {
Video(
/// - Parameter downloadsDirectory: When provided and a local thumbnail
/// exists on disk, the resulting `Video` uses a `file://` URL pointing
/// at the local thumbnail instead of the (potentially expired) remote
/// `thumbnailURL` captured at download time.
func toVideo(downloadsDirectory: URL? = nil) -> Video {
let resolvedThumbnailURL: URL? = {
if let downloadsDirectory, let localURL = localThumbnailURL(in: downloadsDirectory) {
return localURL
}
return thumbnailURL
}()
return Video(
id: videoID,
title: title,
description: description,
@@ -316,7 +336,7 @@ struct Download: Identifiable, Codable, Sendable, Equatable {
publishedText: publishedText,
viewCount: viewCount,
likeCount: likeCount,
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
thumbnails: resolvedThumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
isLive: false,
isUpcoming: false,
scheduledStartTime: nil

View File

@@ -63,6 +63,15 @@ final class ImageLoadingService: Sendable {
)
}
/// Remove a specific URL from both the memory and disk image caches.
/// Use when a previously cached URL is known to return stale or broken
/// data (e.g. an expired proxied thumbnail URL).
func removeCachedImage(for url: URL) {
let request = ImageRequest(url: url)
ImagePipeline.shared.cache.removeCachedImage(for: request)
ImagePipeline.shared.cache.removeCachedData(for: request)
}
/// Clear all image caches (memory and disk).
func clearCache() {
// Clear memory cache

View File

@@ -32,7 +32,7 @@ struct DownloadRowView: View {
@State private var hasLoadedWatchData = false
private var video: Video {
download.toVideo()
download.toVideo(downloadsDirectory: appEnvironment?.downloadManager.downloadsDirectory())
}
/// Watch progress for this video (0.0 to 1.0), or nil if not watched.

View File

@@ -161,7 +161,7 @@ struct DownloadsStorageView: View {
@ViewBuilder
private func storageRow(_ download: Download, isLast: Bool) -> some View {
let video = download.toVideo()
let video = download.toVideo(downloadsDirectory: downloadManager?.downloadsDirectory())
let isWatched = dataManager?.watchEntry(for: download.videoID.videoID)?.isFinished ?? false
VideoListRow(

View File

@@ -375,7 +375,8 @@ private struct CompletedDownloadsSectionContentView: View {
private func groupedByChannelContent(_ downloads: [Download]) -> some View {
let grouped = settings.groupedByChannel(downloads)
let allDownloadsInOrder = grouped.flatMap { $0.downloads }
let videoList = allDownloadsInOrder.map { $0.toVideo() }
let downloadsDir = manager.downloadsDirectory()
let videoList = allDownloadsInOrder.map { $0.toVideo(downloadsDirectory: downloadsDir) }
var runningIndex = 0
ForEach(Array(grouped.enumerated()), id: \.element.channelID) { groupIndex, group in
@@ -420,7 +421,8 @@ private struct CompletedDownloadsSectionContentView: View {
let sortedDownloads = settings.sorted(downloads)
let groupedByLetter = groupDownloadsByFirstLetter(sortedDownloads, ascending: settings.sortDirection == .ascending)
let allDownloadsInOrder = groupedByLetter.flatMap { $0.downloads }
let videoList = allDownloadsInOrder.map { $0.toVideo() }
let downloadsDir = manager.downloadsDirectory()
let videoList = allDownloadsInOrder.map { $0.toVideo(downloadsDirectory: downloadsDir) }
var runningIndex = 0
ForEach(Array(groupedByLetter.enumerated()), id: \.element.letter) { groupIndex, group in
@@ -463,7 +465,8 @@ private struct CompletedDownloadsSectionContentView: View {
@ViewBuilder
private func flatListContent(_ downloads: [Download]) -> some View {
let sortedDownloads = settings.sorted(downloads)
let videoList = sortedDownloads.map { $0.toVideo() }
let downloadsDir = manager.downloadsDirectory()
let videoList = sortedDownloads.map { $0.toVideo(downloadsDirectory: downloadsDir) }
// Flat list content wrapped in its own card
VideoListContent(listStyle: listStyle) {
@@ -530,7 +533,7 @@ private struct CompletedDownloadsSectionContentView: View {
)
}
.videoSwipeActions(
video: download.toVideo(),
video: download.toVideo(downloadsDirectory: manager.downloadsDirectory()),
fixedActions: [
SwipeAction(
symbolImage: "trash.fill",

View File

@@ -1142,7 +1142,8 @@ struct HomeView: View {
let limitedDownloads = Array(downloads.prefix(sectionItemsLimit))
// Use toVideo() instead of videoAndStream() to avoid O(n²) file I/O on main thread
// Downloads are looked up by video.id at playback time in PlayerService.playPreferringDownloaded()
let videoList = limitedDownloads.map { $0.toVideo() }
let downloadsDir = downloadManager?.downloadsDirectory()
let videoList = limitedDownloads.map { $0.toVideo(downloadsDirectory: downloadsDir) }
return VStack(alignment: .leading, spacing: 0) {
sectionHeader(title: "home.recentDownloads.title") {

View File

@@ -855,9 +855,10 @@ struct VideoInfoView: View {
if let videos = allVideos {
ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in
let isCurrent = index == currentVideoIndex
// Use original video for thumbnail (stable), get author from detailed video for avatar
let authorSource = loadedVideoDetails[video.id.videoID]
videoCard(for: video, authorFrom: authorSource, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent)
// Prefer freshly loaded details for thumbnail (so expired proxy URLs get replaced) and avatar.
// Keep `video` as the card's stable identity so title/channel don't flicker.
let detailedVideo = loadedVideoDetails[video.id.videoID]
videoCard(for: video, thumbnailFrom: detailedVideo, authorFrom: detailedVideo, isLoadingMore: isLoadingMoreVideos && isCurrent, showTitle: true, isCurrent: isCurrent)
.opacity(isCurrent ? 1.0 : peekOpacity)
.containerRelativeFrame(.horizontal)
.id(index)
@@ -914,14 +915,16 @@ struct VideoInfoView: View {
/// A single video card with thumbnail, title, and channel info
/// - Parameters:
/// - video: The video to display (used for thumbnail - stable reference)
/// - video: The video to display (used for title/channel - stable reference across carousel scrolls)
/// - thumbnailFrom: Optional video to source the thumbnail URL from (use the freshly loaded details so expired proxy URLs get replaced)
/// - authorFrom: Optional video to get author info from (for avatar URL from detailed video)
/// - isLoadingMore: Whether to show loading overlay for continuation loading
/// - showTitle: Whether to show the title and channel (animates in/out)
/// - isCurrent: Whether this is the currently selected video (thumbnail tap plays video)
private func videoCard(for video: Video, authorFrom: Video? = nil, isLoadingMore: Bool, showTitle: Bool, isCurrent: Bool) -> some View {
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: video)
let bestThumb = video.bestThumbnail
private func videoCard(for video: Video, thumbnailFrom: Video? = nil, authorFrom: Video? = nil, isLoadingMore: Bool, showTitle: Bool, isCurrent: Bool) -> some View {
let thumbnailSource = thumbnailFrom ?? video
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: thumbnailSource)
let bestThumb = thumbnailSource.bestThumbnail
let thumbnailURL = deArrowURL ?? bestThumb?.url
return VStack(spacing: 12) {
// Thumbnail with loading overlay
@@ -2035,6 +2038,7 @@ struct VideoInfoView: View {
do {
// Use extractURL method - just use the video part
let (fullVideo, _, _) = try await contentService.extractURL(originalURL, instance: instance)
invalidateStaleThumbnails(old: base, new: fullVideo)
loadedVideoDetails[videoID] = fullVideo
CachedChannelData.cacheAuthor(fullVideo.author)
} catch {
@@ -2059,6 +2063,7 @@ struct VideoInfoView: View {
id: videoID,
instance: instance
)
invalidateStaleThumbnails(old: base, new: fullVideo)
loadedVideoDetails[videoID] = fullVideo
CachedChannelData.cacheAuthor(fullVideo.author)
} catch {
@@ -2067,6 +2072,18 @@ struct VideoInfoView: View {
isLoadingVideoDetails = false
}
/// Evicts image cache entries for thumbnails whose URLs changed between
/// the queued/list copy of the video and the freshly fetched details, so
/// other views holding the old URLs re-fetch instead of reusing broken
/// payloads from Nuke's cache.
@MainActor
private func invalidateStaleThumbnails(old: Video, new: Video) {
let newURLs = Set(new.thumbnails.map(\.url))
for thumb in old.thumbnails where !newURLs.contains(thumb.url) {
ImageLoadingService.shared.removeCachedImage(for: thumb.url)
}
}
/// Load initial video from API (for videoID init mode).
private func loadInitialVideoIfNeeded() async {