mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 10:25:02 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user