mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
383
Yattee/Services/Downloads/Download.swift
Normal file
383
Yattee/Services/Downloads/Download.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user