From 2efa0708c8315518f445038ceed6388794ed8c23 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 17 Apr 2026 06:47:14 +0200 Subject: [PATCH] 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. --- Yattee/Services/Downloads/Download.swift | 26 ++++++++++++++-- .../ImageLoading/ImageLoadingService.swift | 9 ++++++ Yattee/Views/Downloads/DownloadRowView.swift | 2 +- .../Downloads/DownloadsStorageView.swift | 2 +- Yattee/Views/Downloads/DownloadsView.swift | 11 ++++--- Yattee/Views/Home/HomeView.swift | 3 +- Yattee/Views/Video/VideoInfoView.swift | 31 ++++++++++++++----- 7 files changed, 67 insertions(+), 17 deletions(-) diff --git a/Yattee/Services/Downloads/Download.swift b/Yattee/Services/Downloads/Download.swift index 16aa2b1b..da0efcc3 100644 --- a/Yattee/Services/Downloads/Download.swift +++ b/Yattee/Services/Downloads/Download.swift @@ -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 diff --git a/Yattee/Services/ImageLoading/ImageLoadingService.swift b/Yattee/Services/ImageLoading/ImageLoadingService.swift index bf1f4b93..525baa05 100644 --- a/Yattee/Services/ImageLoading/ImageLoadingService.swift +++ b/Yattee/Services/ImageLoading/ImageLoadingService.swift @@ -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 diff --git a/Yattee/Views/Downloads/DownloadRowView.swift b/Yattee/Views/Downloads/DownloadRowView.swift index a67a067f..3f1a148c 100644 --- a/Yattee/Views/Downloads/DownloadRowView.swift +++ b/Yattee/Views/Downloads/DownloadRowView.swift @@ -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. diff --git a/Yattee/Views/Downloads/DownloadsStorageView.swift b/Yattee/Views/Downloads/DownloadsStorageView.swift index 09aa5fcc..2610e067 100644 --- a/Yattee/Views/Downloads/DownloadsStorageView.swift +++ b/Yattee/Views/Downloads/DownloadsStorageView.swift @@ -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( diff --git a/Yattee/Views/Downloads/DownloadsView.swift b/Yattee/Views/Downloads/DownloadsView.swift index b17ca693..e4b2b1e1 100644 --- a/Yattee/Views/Downloads/DownloadsView.swift +++ b/Yattee/Views/Downloads/DownloadsView.swift @@ -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", diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index f35cc06b..649bc2bb 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -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") { diff --git a/Yattee/Views/Video/VideoInfoView.swift b/Yattee/Views/Video/VideoInfoView.swift index f3a35f9e..caca8421 100644 --- a/Yattee/Views/Video/VideoInfoView.swift +++ b/Yattee/Views/Video/VideoInfoView.swift @@ -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 {