mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
384 lines
16 KiB
Swift
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
|
|
}
|
|
}
|