mirror of
https://github.com/yattee/yattee.git
synced 2026-06-05 06:14:18 +00:00
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.
404 lines
17 KiB
Swift
404 lines
17 KiB
Swift
//
|
|
// Download.swift
|
|
// Yattee
|
|
//
|
|
// Represents a video download.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Represents a video download.
|
|
struct Download: Identifiable, Codable, Sendable, Equatable {
|
|
let id: UUID
|
|
let videoID: VideoID
|
|
let title: String
|
|
let channelName: String
|
|
let channelID: String
|
|
let channelThumbnailURL: URL?
|
|
let channelSubscriberCount: Int?
|
|
/// Channel/author URL for external sources (e.g., bilibili channel page)
|
|
let channelURL: URL?
|
|
let thumbnailURL: URL?
|
|
let duration: TimeInterval
|
|
let description: String?
|
|
let viewCount: Int?
|
|
let likeCount: Int?
|
|
let dislikeCount: Int?
|
|
let publishedAt: Date?
|
|
let publishedText: String?
|
|
let streamURL: URL
|
|
|
|
/// Optional separate audio stream URL (for video-only streams)
|
|
let audioStreamURL: URL?
|
|
/// Optional caption URL for subtitle download
|
|
let captionURL: URL?
|
|
/// Language code for the audio track (e.g., "en", "ja")
|
|
let audioLanguage: String?
|
|
/// Language code for the caption (e.g., "en", "de")
|
|
let captionLanguage: String?
|
|
/// Custom HTTP headers required for downloading (cookies, referer, etc.)
|
|
let httpHeaders: [String: String]?
|
|
/// Storyboard metadata for offline seek preview
|
|
let storyboard: Storyboard?
|
|
|
|
var status: DownloadStatus
|
|
var progress: Double
|
|
var totalBytes: Int64
|
|
var downloadedBytes: Int64
|
|
var quality: String
|
|
var formatID: String
|
|
var localVideoPath: String?
|
|
var localAudioPath: String?
|
|
/// Path to downloaded caption file
|
|
var localCaptionPath: String?
|
|
/// Directory containing downloaded storyboard sprite sheets
|
|
var localStoryboardPath: String?
|
|
/// Path to downloaded video thumbnail for offline Now Playing artwork
|
|
var localThumbnailPath: String?
|
|
/// Path to downloaded channel thumbnail for offline display
|
|
var localChannelThumbnailPath: String?
|
|
var startedAt: Date?
|
|
var completedAt: Date?
|
|
var error: String?
|
|
var priority: DownloadPriority
|
|
var autoDelete: Bool
|
|
var resumeData: Data?
|
|
/// Resume data for audio download
|
|
var audioResumeData: Data?
|
|
var retryCount: Int
|
|
/// Non-fatal warnings during download (e.g., "Subtitles failed to download")
|
|
var warnings: [String]
|
|
|
|
/// Current phase of multi-file download
|
|
var downloadPhase: DownloadPhase
|
|
/// Progress for video file (0.0 to 1.0)
|
|
var videoProgress: Double
|
|
/// Progress for audio file (0.0 to 1.0)
|
|
var audioProgress: Double
|
|
/// Total bytes for video file
|
|
var videoTotalBytes: Int64
|
|
/// Total bytes for audio file
|
|
var audioTotalBytes: Int64
|
|
|
|
/// Video codec (e.g., "avc1", "vp9", "av01")
|
|
let videoCodec: String?
|
|
/// Audio codec (e.g., "mp4a", "opus")
|
|
let audioCodec: String?
|
|
/// Video bitrate in bits per second
|
|
let videoBitrate: Int?
|
|
/// Audio bitrate in bits per second
|
|
let audioBitrate: Int?
|
|
|
|
/// Current download speed in bytes per second (not persisted)
|
|
var downloadSpeed: Int64 = 0
|
|
/// Last time bytes were recorded for speed calculation (not persisted)
|
|
var lastSpeedUpdateTime: Date?
|
|
/// Last bytes count for speed calculation (not persisted)
|
|
var lastSpeedBytes: Int64 = 0
|
|
|
|
/// Per-stream speed tracking (not persisted)
|
|
var videoDownloadSpeed: Int64 = 0
|
|
var audioDownloadSpeed: Int64 = 0
|
|
var captionDownloadSpeed: Int64 = 0
|
|
/// Caption progress (0.0 to 1.0)
|
|
var captionProgress: Double = 0
|
|
/// Total bytes for caption file
|
|
var captionTotalBytes: Int64 = 0
|
|
/// Storyboard download speed (not persisted)
|
|
var storyboardDownloadSpeed: Int64 = 0
|
|
/// Storyboard progress (0.0 to 1.0)
|
|
var storyboardProgress: Double = 0
|
|
/// Total bytes for all storyboard files
|
|
var storyboardTotalBytes: Int64 = 0
|
|
|
|
/// Bytes downloaded for video (for indeterminate progress display, not persisted)
|
|
var videoDownloadedBytes: Int64 = 0
|
|
/// Bytes downloaded for audio (for indeterminate progress display, not persisted)
|
|
var audioDownloadedBytes: Int64 = 0
|
|
/// Bytes downloaded for caption (for indeterminate progress display, not persisted)
|
|
var captionDownloadedBytes: Int64 = 0
|
|
|
|
// MARK: - Size Unknown Detection
|
|
|
|
/// Whether video stream size is unknown (server didn't provide Content-Length)
|
|
var videoSizeUnknown: Bool {
|
|
videoTotalBytes <= 0 && videoProgress < 1.0
|
|
}
|
|
|
|
/// Whether audio stream size is unknown
|
|
var audioSizeUnknown: Bool {
|
|
audioStreamURL != nil && audioTotalBytes <= 0 && audioProgress < 1.0
|
|
}
|
|
|
|
/// Whether caption stream size is unknown
|
|
var captionSizeUnknown: Bool {
|
|
captionURL != nil && captionTotalBytes <= 0 && captionProgress < 1.0
|
|
}
|
|
|
|
/// Whether any active stream has unknown size (for overall indeterminate display)
|
|
var hasIndeterminateProgress: Bool {
|
|
let videoIndeterminate = videoProgress < 1.0 && videoTotalBytes <= 0
|
|
let audioIndeterminate = audioStreamURL != nil && audioProgress < 1.0 && audioTotalBytes <= 0
|
|
return videoIndeterminate || audioIndeterminate
|
|
}
|
|
|
|
// Don't persist speed-related fields
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, videoID, title, channelName, channelID, channelThumbnailURL, channelSubscriberCount, channelURL
|
|
case thumbnailURL, duration, description, viewCount, likeCount, dislikeCount
|
|
case publishedAt, publishedText, streamURL, audioStreamURL, captionURL
|
|
case audioLanguage, captionLanguage, httpHeaders, storyboard, status, progress, totalBytes, downloadedBytes
|
|
case quality, formatID, localVideoPath, localAudioPath, localCaptionPath, localStoryboardPath, localThumbnailPath, localChannelThumbnailPath
|
|
case startedAt, completedAt, error, priority, autoDelete, resumeData, audioResumeData
|
|
case retryCount, warnings, downloadPhase, videoProgress, audioProgress, videoTotalBytes, audioTotalBytes
|
|
case videoCodec, audioCodec, videoBitrate, audioBitrate
|
|
case storyboardProgress, storyboardTotalBytes
|
|
}
|
|
|
|
// Custom decoder for backwards compatibility with downloads saved before 'warnings' was added
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
|
|
id = try container.decode(UUID.self, forKey: .id)
|
|
videoID = try container.decode(VideoID.self, forKey: .videoID)
|
|
title = try container.decode(String.self, forKey: .title)
|
|
channelName = try container.decode(String.self, forKey: .channelName)
|
|
channelID = try container.decode(String.self, forKey: .channelID)
|
|
channelThumbnailURL = try container.decodeIfPresent(URL.self, forKey: .channelThumbnailURL)
|
|
channelSubscriberCount = try container.decodeIfPresent(Int.self, forKey: .channelSubscriberCount)
|
|
channelURL = try container.decodeIfPresent(URL.self, forKey: .channelURL)
|
|
thumbnailURL = try container.decodeIfPresent(URL.self, forKey: .thumbnailURL)
|
|
duration = try container.decode(TimeInterval.self, forKey: .duration)
|
|
description = try container.decodeIfPresent(String.self, forKey: .description)
|
|
viewCount = try container.decodeIfPresent(Int.self, forKey: .viewCount)
|
|
likeCount = try container.decodeIfPresent(Int.self, forKey: .likeCount)
|
|
dislikeCount = try container.decodeIfPresent(Int.self, forKey: .dislikeCount)
|
|
publishedAt = try container.decodeIfPresent(Date.self, forKey: .publishedAt)
|
|
publishedText = try container.decodeIfPresent(String.self, forKey: .publishedText)
|
|
streamURL = try container.decode(URL.self, forKey: .streamURL)
|
|
audioStreamURL = try container.decodeIfPresent(URL.self, forKey: .audioStreamURL)
|
|
captionURL = try container.decodeIfPresent(URL.self, forKey: .captionURL)
|
|
audioLanguage = try container.decodeIfPresent(String.self, forKey: .audioLanguage)
|
|
captionLanguage = try container.decodeIfPresent(String.self, forKey: .captionLanguage)
|
|
httpHeaders = try container.decodeIfPresent([String: String].self, forKey: .httpHeaders)
|
|
storyboard = try container.decodeIfPresent(Storyboard.self, forKey: .storyboard)
|
|
|
|
status = try container.decode(DownloadStatus.self, forKey: .status)
|
|
progress = try container.decode(Double.self, forKey: .progress)
|
|
totalBytes = try container.decode(Int64.self, forKey: .totalBytes)
|
|
downloadedBytes = try container.decode(Int64.self, forKey: .downloadedBytes)
|
|
quality = try container.decode(String.self, forKey: .quality)
|
|
formatID = try container.decode(String.self, forKey: .formatID)
|
|
localVideoPath = try container.decodeIfPresent(String.self, forKey: .localVideoPath)
|
|
localAudioPath = try container.decodeIfPresent(String.self, forKey: .localAudioPath)
|
|
localCaptionPath = try container.decodeIfPresent(String.self, forKey: .localCaptionPath)
|
|
localStoryboardPath = try container.decodeIfPresent(String.self, forKey: .localStoryboardPath)
|
|
localThumbnailPath = try container.decodeIfPresent(String.self, forKey: .localThumbnailPath)
|
|
localChannelThumbnailPath = try container.decodeIfPresent(String.self, forKey: .localChannelThumbnailPath)
|
|
startedAt = try container.decodeIfPresent(Date.self, forKey: .startedAt)
|
|
completedAt = try container.decodeIfPresent(Date.self, forKey: .completedAt)
|
|
error = try container.decodeIfPresent(String.self, forKey: .error)
|
|
priority = try container.decode(DownloadPriority.self, forKey: .priority)
|
|
autoDelete = try container.decode(Bool.self, forKey: .autoDelete)
|
|
resumeData = try container.decodeIfPresent(Data.self, forKey: .resumeData)
|
|
audioResumeData = try container.decodeIfPresent(Data.self, forKey: .audioResumeData)
|
|
retryCount = try container.decode(Int.self, forKey: .retryCount)
|
|
|
|
// Backwards compatibility: 'warnings' was added later, default to empty array
|
|
warnings = try container.decodeIfPresent([String].self, forKey: .warnings) ?? []
|
|
|
|
downloadPhase = try container.decode(DownloadPhase.self, forKey: .downloadPhase)
|
|
videoProgress = try container.decode(Double.self, forKey: .videoProgress)
|
|
audioProgress = try container.decode(Double.self, forKey: .audioProgress)
|
|
videoTotalBytes = try container.decode(Int64.self, forKey: .videoTotalBytes)
|
|
audioTotalBytes = try container.decode(Int64.self, forKey: .audioTotalBytes)
|
|
|
|
videoCodec = try container.decodeIfPresent(String.self, forKey: .videoCodec)
|
|
audioCodec = try container.decodeIfPresent(String.self, forKey: .audioCodec)
|
|
videoBitrate = try container.decodeIfPresent(Int.self, forKey: .videoBitrate)
|
|
audioBitrate = try container.decodeIfPresent(Int.self, forKey: .audioBitrate)
|
|
|
|
// Backwards compatibility: storyboard progress fields were added later
|
|
storyboardProgress = try container.decodeIfPresent(Double.self, forKey: .storyboardProgress) ?? 0
|
|
storyboardTotalBytes = try container.decodeIfPresent(Int64.self, forKey: .storyboardTotalBytes) ?? 0
|
|
}
|
|
|
|
init(
|
|
video: Video,
|
|
quality: String,
|
|
formatID: String,
|
|
streamURL: URL,
|
|
audioStreamURL: URL? = nil,
|
|
captionURL: URL? = nil,
|
|
audioLanguage: String? = nil,
|
|
captionLanguage: String? = nil,
|
|
httpHeaders: [String: String]? = nil,
|
|
storyboard: Storyboard? = nil,
|
|
dislikeCount: Int? = nil,
|
|
priority: DownloadPriority = .normal,
|
|
autoDelete: Bool = false,
|
|
videoCodec: String? = nil,
|
|
audioCodec: String? = nil,
|
|
videoBitrate: Int? = nil,
|
|
audioBitrate: Int? = nil
|
|
) {
|
|
self.id = UUID()
|
|
self.videoID = video.id
|
|
self.title = video.title
|
|
self.channelName = video.author.name
|
|
self.channelID = video.author.id
|
|
self.channelThumbnailURL = video.author.thumbnailURL
|
|
self.channelSubscriberCount = video.author.subscriberCount
|
|
self.channelURL = video.author.url
|
|
self.thumbnailURL = video.bestThumbnail?.url
|
|
self.duration = video.duration
|
|
self.description = video.description
|
|
self.viewCount = video.viewCount
|
|
self.likeCount = video.likeCount
|
|
self.dislikeCount = dislikeCount
|
|
self.publishedAt = video.publishedAt
|
|
self.publishedText = video.publishedText
|
|
self.streamURL = streamURL
|
|
self.audioStreamURL = audioStreamURL
|
|
self.captionURL = captionURL
|
|
self.audioLanguage = audioLanguage
|
|
self.captionLanguage = captionLanguage
|
|
self.httpHeaders = httpHeaders
|
|
self.storyboard = storyboard
|
|
|
|
self.status = .queued
|
|
self.progress = 0
|
|
self.totalBytes = 0
|
|
self.downloadedBytes = 0
|
|
self.quality = quality
|
|
self.formatID = formatID
|
|
self.localVideoPath = nil
|
|
self.localAudioPath = nil
|
|
self.localCaptionPath = nil
|
|
self.localStoryboardPath = nil
|
|
self.localThumbnailPath = nil
|
|
self.localChannelThumbnailPath = nil
|
|
self.startedAt = nil
|
|
self.completedAt = nil
|
|
self.error = nil
|
|
self.priority = priority
|
|
self.autoDelete = autoDelete
|
|
self.resumeData = nil
|
|
self.audioResumeData = nil
|
|
self.retryCount = 0
|
|
self.warnings = []
|
|
self.downloadPhase = .video
|
|
self.videoProgress = 0
|
|
self.audioProgress = 0
|
|
self.videoTotalBytes = 0
|
|
self.audioTotalBytes = 0
|
|
self.videoCodec = videoCodec
|
|
self.audioCodec = audioCodec
|
|
self.videoBitrate = videoBitrate
|
|
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.
|
|
/// - 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,
|
|
author: Author(
|
|
id: channelID,
|
|
name: channelName,
|
|
thumbnailURL: channelThumbnailURL,
|
|
subscriberCount: channelSubscriberCount,
|
|
url: channelURL
|
|
),
|
|
duration: duration,
|
|
publishedAt: publishedAt,
|
|
publishedText: publishedText,
|
|
viewCount: viewCount,
|
|
likeCount: likeCount,
|
|
thumbnails: resolvedThumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
|
|
isLive: false,
|
|
isUpcoming: false,
|
|
scheduledStartTime: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview Data
|
|
|
|
extension Download {
|
|
/// A sample completed download for SwiftUI previews.
|
|
static var preview: Download {
|
|
var download = Download(
|
|
video: .preview,
|
|
quality: "1080p",
|
|
formatID: "137",
|
|
streamURL: URL(string: "https://example.com/video.mp4")!,
|
|
audioStreamURL: URL(string: "https://example.com/audio.m4a")!,
|
|
captionURL: URL(string: "https://example.com/captions.vtt")!,
|
|
audioLanguage: "en",
|
|
captionLanguage: "en",
|
|
videoCodec: "avc1",
|
|
audioCodec: "mp4a",
|
|
videoBitrate: 5_000_000,
|
|
audioBitrate: 128_000
|
|
)
|
|
download.status = .completed
|
|
download.progress = 1.0
|
|
download.videoProgress = 1.0
|
|
download.audioProgress = 1.0
|
|
download.videoTotalBytes = 150_000_000
|
|
download.audioTotalBytes = 5_000_000
|
|
download.totalBytes = 155_000_000
|
|
download.downloadedBytes = 155_000_000
|
|
download.localVideoPath = "/Downloads/video.mp4"
|
|
download.localAudioPath = "/Downloads/audio.m4a"
|
|
download.localCaptionPath = "/Downloads/captions.vtt"
|
|
download.completedAt = Date()
|
|
return download
|
|
}
|
|
|
|
/// A sample muxed download (no separate audio) for SwiftUI previews.
|
|
static var muxedPreview: Download {
|
|
var download = Download(
|
|
video: .preview,
|
|
quality: "720p",
|
|
formatID: "22",
|
|
streamURL: URL(string: "https://example.com/muxed.mp4")!,
|
|
videoCodec: "avc1",
|
|
audioCodec: "mp4a",
|
|
videoBitrate: 2_500_000,
|
|
audioBitrate: 128_000
|
|
)
|
|
download.status = .completed
|
|
download.progress = 1.0
|
|
download.videoProgress = 1.0
|
|
download.videoTotalBytes = 80_000_000
|
|
download.totalBytes = 80_000_000
|
|
download.downloadedBytes = 80_000_000
|
|
download.localVideoPath = "/Downloads/muxed.mp4"
|
|
download.completedAt = Date()
|
|
return download
|
|
}
|
|
}
|