Files
yattee/Yattee/Services/Downloads/Download.swift
2026-02-08 18:33:56 +01:00

384 lines
16 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
}
/// Convert to a Video model for display purposes.
func toVideo() -> Video {
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: thumbnailURL.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
}
}