0df8013d3e
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
936 lines
33 KiB
Swift
936 lines
33 KiB
Swift
import Foundation
|
|
|
|
enum JSONValue: Codable, Sendable, Equatable {
|
|
case string(String)
|
|
case number(Double)
|
|
case bool(Bool)
|
|
case object([String: JSONValue])
|
|
case array([JSONValue])
|
|
case null
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
if container.decodeNil() { self = .null; return }
|
|
if let value = try? container.decode(Bool.self) { self = .bool(value); return }
|
|
if let value = try? container.decode(Double.self) { self = .number(value); return }
|
|
if let value = try? container.decode(String.self) { self = .string(value); return }
|
|
if let value = try? container.decode([String: JSONValue].self) { self = .object(value); return }
|
|
if let value = try? container.decode([JSONValue].self) { self = .array(value); return }
|
|
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
switch self {
|
|
case .string(let value): try container.encode(value)
|
|
case .number(let value): try container.encode(value)
|
|
case .bool(let value): try container.encode(value)
|
|
case .object(let value): try container.encode(value)
|
|
case .array(let value): try container.encode(value)
|
|
case .null: try container.encodeNil()
|
|
}
|
|
}
|
|
|
|
static func object(_ values: [String: Any]) -> JSONValue {
|
|
.object(values.reduce(into: [:]) { result, item in result[item.key] = JSONValue(any: item.value) })
|
|
}
|
|
|
|
init(any value: Any) {
|
|
switch value {
|
|
case let value as JSONValue: self = value
|
|
case let value as String: self = .string(value)
|
|
case let value as Int: self = .number(Double(value))
|
|
case let value as Int64: self = .number(Double(value))
|
|
case let value as Double: self = .number(value)
|
|
case let value as Bool: self = .bool(value)
|
|
case let value as [Any]: self = .array(value.map(JSONValue.init(any:)))
|
|
case let value as [String: Any]: self = .object(value.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) })
|
|
default: self = .null
|
|
}
|
|
}
|
|
|
|
var objectValue: [String: JSONValue]? { if case .object(let value) = self { return value }; return nil }
|
|
var arrayValue: [JSONValue]? { if case .array(let value) = self { return value }; return nil }
|
|
var stringValue: String? { if case .string(let value) = self { return value }; return nil }
|
|
var boolValue: Bool? { if case .bool(let value) = self { return value }; return nil }
|
|
var doubleValue: Double? { if case .number(let value) = self { return value }; return nil }
|
|
var intValue: Int? { doubleValue.map(Int.init) }
|
|
var int64Value: Int64? { doubleValue.map(Int64.init) }
|
|
|
|
subscript(_ key: String) -> JSONValue? { objectValue?[key] }
|
|
|
|
func firstString(_ keys: [String]) -> String? {
|
|
for key in keys where self[key]?.stringValue != nil { return self[key]?.stringValue }
|
|
return nil
|
|
}
|
|
|
|
func firstID(_ keys: [String]) -> String? {
|
|
for key in keys {
|
|
if let value = self[key]?.stringValue, !value.isEmpty { return value }
|
|
if let int = self[key]?.int64Value { return String(int) }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Resource IDs are returned as either strings or JSON numbers by different
|
|
/// file endpoints. Preserve the integer representation when walking nested
|
|
/// payloads so parent comparisons do not trigger a same-folder move.
|
|
func firstIDDeep(_ keys: [String]) -> String? {
|
|
if let value = firstID(keys) { return value }
|
|
guard let object = objectValue else { return nil }
|
|
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
|
for key in preferredKeys {
|
|
if let value = object[key]?.firstIDDeep(keys) { return value }
|
|
}
|
|
for (key, value) in object where !preferredKeys.contains(key) {
|
|
if let found = value.firstIDDeep(keys) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// API responses are not consistent about whether payload fields are nested in `data`.
|
|
func firstStringDeep(_ keys: [String]) -> String? {
|
|
if let value = firstString(keys) { return value }
|
|
guard let object = objectValue else { return nil }
|
|
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
|
for key in preferredKeys {
|
|
if let value = object[key]?.firstStringDeep(keys) { return value }
|
|
}
|
|
for (key, value) in object where !preferredKeys.contains(key) {
|
|
if let found = value.firstStringDeep(keys) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstArrayDeep(_ keys: [String]) -> [JSONValue]? {
|
|
for key in keys { if let value = self[key]?.arrayValue { return value } }
|
|
guard let object = objectValue else { return nil }
|
|
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
|
for key in preferredKeys { if let value = object[key]?.firstArrayDeep(keys) { return value } }
|
|
for (key, value) in object where !preferredKeys.contains(key) {
|
|
if let found = value.firstArrayDeep(keys) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstInt(_ keys: [String]) -> Int? {
|
|
for key in keys where self[key]?.intValue != nil { return self[key]?.intValue }
|
|
return nil
|
|
}
|
|
|
|
func firstIntDeep(_ keys: [String]) -> Int? {
|
|
if let value = firstInt(keys) { return value }
|
|
guard let object = objectValue else { return nil }
|
|
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
|
for key in preferredKeys {
|
|
if let value = object[key]?.firstIntDeep(keys) { return value }
|
|
}
|
|
for (key, value) in object where !preferredKeys.contains(key) {
|
|
if let found = value.firstIntDeep(keys) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func firstInt64Deep(_ keys: [String]) -> Int64? {
|
|
if let value = keys.compactMap({ self[$0]?.int64Value }).first { return value }
|
|
guard let object = objectValue else { return nil }
|
|
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
|
for key in preferredKeys {
|
|
if let value = object[key]?.firstInt64Deep(keys) { return value }
|
|
}
|
|
for (key, value) in object where !preferredKeys.contains(key) {
|
|
if let found = value.firstInt64Deep(keys) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func formatFileDate(_ epoch: Int64) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "zh_CN")
|
|
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
|
return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch)))
|
|
}
|
|
|
|
struct CloudFile: Identifiable, Hashable, Sendable {
|
|
static let supportedVideoExtensions: Set<String> = [
|
|
"mp4", "m4v", "mkv", "mov", "avi", "ts", "m2ts", "mts", "webm",
|
|
"flv", "f4v", "wmv", "asf", "mpg", "mpeg", "vob", "iso", "rm", "rmvb", "3gp", "ogv"
|
|
]
|
|
let id: String
|
|
let name: String
|
|
let isDirectory: Bool
|
|
var size: Int64?
|
|
var gcid: String?
|
|
var subDirectoryCount: Int?
|
|
var subFileCount: Int?
|
|
var modifiedAt: String
|
|
var cloudPath: String
|
|
let fileType: Int
|
|
|
|
var pathDepth: Int { cloudPath.split(separator: "/").count }
|
|
var isVideo: Bool { !isDirectory && (fileType == 2 || Self.supportedVideoExtensions.contains((name as NSString).pathExtension.lowercased())) }
|
|
|
|
var icon: String {
|
|
if isDirectory { return "folder.fill" }
|
|
if isVideo { return "play.rectangle.fill" }
|
|
switch fileType {
|
|
case 1: return "photo.fill"
|
|
case 2: return "play.rectangle.fill"
|
|
case 3: return "music.note"
|
|
case 4: return "doc.text.fill"
|
|
case 5, 9: return "archivebox.fill"
|
|
default: return "doc.fill"
|
|
}
|
|
}
|
|
|
|
var typeName: String {
|
|
if isDirectory { return "文件夹" }
|
|
if isVideo { return "视频" }
|
|
return [1: "图片", 2: "视频", 3: "音频", 4: "文档", 5: "压缩包", 9: "BT种子"][fileType] ?? "文件"
|
|
}
|
|
|
|
var formattedSize: String {
|
|
guard let size else { return "--" }
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
return formatter.string(fromByteCount: size)
|
|
}
|
|
|
|
init(id: String, name: String, isDirectory: Bool, size: Int64? = nil, gcid: String? = nil, subDirectoryCount: Int? = nil, subFileCount: Int? = nil, modifiedAt: String = "", cloudPath: String = "", fileType: Int = 0) {
|
|
self.id = id
|
|
self.name = name
|
|
self.isDirectory = isDirectory
|
|
self.size = size
|
|
self.gcid = gcid
|
|
self.subDirectoryCount = subDirectoryCount
|
|
self.subFileCount = subFileCount
|
|
self.modifiedAt = modifiedAt
|
|
self.cloudPath = cloudPath
|
|
self.fileType = fileType
|
|
}
|
|
|
|
init?(json: JSONValue) {
|
|
guard let object = json.objectValue,
|
|
let name = json.firstString(["name", "fileName", "resName"]),
|
|
let stableID = json.firstID(["fileId", "resId", "id"]), !stableID.isEmpty else { return nil }
|
|
id = stableID
|
|
self.name = name
|
|
let resourceType = json.firstInt(["resType"])
|
|
let type = json.firstInt(["fileType", "type"]) ?? 0
|
|
if let explicitDirectory = json.firstInt(["isDir", "dir", "directoryType"]) {
|
|
isDirectory = explicitDirectory == 1
|
|
} else if let resourceType {
|
|
// dirType == 1 is also present on ordinary resources; resType == 2 identifies folders.
|
|
isDirectory = resourceType == 2
|
|
} else {
|
|
isDirectory = type == 0 && (object["dirName"] != nil || object["children"] != nil)
|
|
}
|
|
size = json.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"])
|
|
gcid = json.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"])
|
|
subDirectoryCount = json.firstIntDeep(["subDirCount"])
|
|
subFileCount = json.firstIntDeep(["subFileCount"])
|
|
// In get_file_list an absent directory size denotes a verified empty folder.
|
|
// Keep it as zero rather than “unknown”, so it sorts and renders correctly.
|
|
if isDirectory, size == nil { size = 0 }
|
|
modifiedAt = json.firstString(["updateTime", "updatedAt", "modifyTime", "createTime"]) ?? json.firstInt64Deep(["utime", "ctime"]).map(formatFileDate) ?? ""
|
|
cloudPath = json.firstString(["location", "path", "fullPath"]) ?? name
|
|
fileType = type
|
|
}
|
|
}
|
|
|
|
struct DuplicateGroup: Identifiable, Sendable {
|
|
let id: String
|
|
let files: [CloudFile]
|
|
}
|
|
|
|
struct TMDBMatch: Identifiable, Sendable {
|
|
let id: Int
|
|
let title: String
|
|
let mediaType: String
|
|
let releaseDate: String
|
|
let overview: String
|
|
|
|
init(id: Int, title: String, mediaType: String, releaseDate: String, overview: String) {
|
|
self.id = id; self.title = title; self.mediaType = mediaType; self.releaseDate = releaseDate; self.overview = overview
|
|
}
|
|
|
|
init?(json: JSONValue) {
|
|
guard let id = json.firstIntDeep(["id"]), let title = json.firstStringDeep(["title", "name"]) else { return nil }
|
|
self.id = id
|
|
self.title = title
|
|
mediaType = json.firstStringDeep(["media_type"]) ?? "movie"
|
|
releaseDate = json.firstStringDeep(["release_date", "first_air_date"]) ?? ""
|
|
overview = json.firstStringDeep(["overview"]) ?? ""
|
|
}
|
|
}
|
|
|
|
struct FolderPath: Identifiable, Hashable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
}
|
|
|
|
struct FastTransferEntry: Identifiable, Hashable, Sendable, Codable {
|
|
let id: UUID
|
|
let path: String
|
|
let size: Int64
|
|
let etag: String?
|
|
let gcid: String?
|
|
|
|
init(id: UUID = UUID(), path: String, size: Int64, etag: String? = nil, gcid: String? = nil) {
|
|
self.id = id
|
|
self.path = path
|
|
self.size = size
|
|
self.etag = etag
|
|
self.gcid = gcid
|
|
}
|
|
|
|
var name: String { (path as NSString).lastPathComponent }
|
|
var directoryPath: String { (path as NSString).deletingLastPathComponent }
|
|
var hashKind: String { etag?.isEmpty == false ? "MD5" : "GCID" }
|
|
}
|
|
|
|
struct FastTransferProgress: Sendable, Codable {
|
|
var total = 0
|
|
var completed = 0
|
|
var imported = 0
|
|
var skipped = 0
|
|
var failed = 0
|
|
var cancelled = 0
|
|
var currentName = ""
|
|
/// Entries undergoing target-folder preparation or an in-flight flash upload.
|
|
/// Keeping this in the progress model lets the queue show useful work before
|
|
/// a terminal result exists (for example while checking duplicate names).
|
|
var activeEntryIDs: Set<UUID> = []
|
|
|
|
var fraction: Double { total == 0 ? 0 : Double(completed + failed + cancelled) / Double(total) }
|
|
var processed: Int { completed + failed + cancelled }
|
|
}
|
|
|
|
struct FastTransferResult: Identifiable, Sendable, Codable {
|
|
let id: UUID
|
|
let entry: FastTransferEntry
|
|
let state: State
|
|
let message: String
|
|
let createdAt: Date
|
|
let taskID: String?
|
|
let targetID: String?
|
|
let details: [String]
|
|
/// Points to the failed record this attempt was manually retried from.
|
|
let retryOf: UUID?
|
|
|
|
enum State: String, Sendable, Codable {
|
|
case imported
|
|
case skipped
|
|
case failed
|
|
case cancelled
|
|
}
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
entry: FastTransferEntry,
|
|
state: State,
|
|
message: String,
|
|
createdAt: Date = Date(),
|
|
taskID: String?,
|
|
targetID: String?,
|
|
details: [String],
|
|
retryOf: UUID?
|
|
) {
|
|
self.id = id
|
|
self.entry = entry
|
|
self.state = state
|
|
self.message = message
|
|
self.createdAt = createdAt
|
|
self.taskID = taskID
|
|
self.targetID = targetID
|
|
self.details = details
|
|
self.retryOf = retryOf
|
|
}
|
|
}
|
|
|
|
struct UserProfile: Sendable {
|
|
let name: String
|
|
let phone: String
|
|
let avatarURL: URL?
|
|
let memberLevel: String
|
|
let capacity: Int64?
|
|
let usedCapacity: Int64?
|
|
|
|
init(json: JSONValue) {
|
|
name = json.firstStringDeep(["nickname", "name", "username", "phone_number"]) ?? "光鸭用户"
|
|
phone = json.firstStringDeep(["phone", "phoneNumber", "phone_number", "username"]) ?? ""
|
|
avatarURL = json.firstStringDeep(["avatar", "avatarUrl", "avatar_url"]).flatMap(URL.init(string:))
|
|
memberLevel = json.firstStringDeep(["vipName", "memberName", "memberLevelName", "levelName", "vipLevel"]) ?? "普通会员"
|
|
capacity = json.firstInt64Deep(["capacity", "totalCapacity", "totalSpace", "spaceTotal"])
|
|
usedCapacity = json.firstInt64Deep(["usedCapacity", "usedSpace", "usedSize", "spaceUsed"])
|
|
}
|
|
|
|
var capacityText: String {
|
|
let f = ByteCountFormatter(); f.countStyle = .file
|
|
guard let capacity else { return "空间信息暂不可用" }
|
|
return "\(usedCapacity.map { f.string(fromByteCount: $0) } ?? "0 bytes") / \(f.string(fromByteCount: capacity))"
|
|
}
|
|
}
|
|
|
|
struct AuthTokens: Sendable {
|
|
let accessToken: String
|
|
let refreshToken: String?
|
|
let expiresIn: Double?
|
|
}
|
|
|
|
enum WorkspaceSection: String, CaseIterable, Identifiable {
|
|
case files = "全部"
|
|
case recentViewed = "最近查看"
|
|
case recentRestored = "最近转存"
|
|
case photos = "图片"
|
|
case videos = "视频"
|
|
case audio = "音频"
|
|
case documents = "文档"
|
|
case cloud = "云下载"
|
|
case shares = "我的分享"
|
|
case recycle = "回收站"
|
|
|
|
var id: String { rawValue }
|
|
var icon: String {
|
|
switch self {
|
|
case .files: return "folder"
|
|
case .recentViewed: return "clock.arrow.circlepath"
|
|
case .recentRestored: return "arrow.uturn.backward.circle"
|
|
case .photos: return "photo"
|
|
case .videos: return "play.rectangle"
|
|
case .audio: return "music.note"
|
|
case .documents: return "doc.text"
|
|
case .cloud: return "arrow.down.circle"
|
|
case .shares: return "square.and.arrow.up"
|
|
case .recycle: return "trash"
|
|
}
|
|
}
|
|
}
|
|
import Foundation
|
|
|
|
enum ScanKind: String, CaseIterable, Identifiable, Sendable {
|
|
case emptyFolders
|
|
case duplicates
|
|
case similarFolders
|
|
|
|
var id: String { rawValue }
|
|
var title: String {
|
|
switch self {
|
|
case .emptyFolders: return "空文件夹"
|
|
case .duplicates: return "重复文件"
|
|
case .similarFolders: return "相似文件夹"
|
|
}
|
|
}
|
|
var icon: String {
|
|
switch self {
|
|
case .emptyFolders: return "folder.badge.minus"
|
|
case .duplicates: return "square.on.square"
|
|
case .similarFolders: return "rectangle.3.group"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ScanScope: String, CaseIterable, Identifiable, Sendable {
|
|
case currentFolder
|
|
case recursiveCurrentFolder
|
|
case customFolder
|
|
case wholeDrive
|
|
|
|
var id: String { rawValue }
|
|
var title: String {
|
|
switch self {
|
|
case .currentFolder: return "当前目录"
|
|
case .recursiveCurrentFolder: return "当前目录及子目录"
|
|
case .customFolder: return "手动选择文件夹"
|
|
case .wholeDrive: return "整个云盘"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ScanRequest: Sendable, Equatable {
|
|
let kind: ScanKind
|
|
let scope: ScanScope
|
|
let rootID: String?
|
|
let rootName: String
|
|
let excludeFilesSmallerThan: Int64?
|
|
}
|
|
|
|
struct SimilarFolderGroup: Identifiable, Sendable {
|
|
let id: String
|
|
let folders: [CloudFile]
|
|
}
|
|
|
|
enum ScanItem: Identifiable, Sendable {
|
|
case emptyFolder(CloudFile)
|
|
case duplicate(DuplicateGroup)
|
|
case similar(SimilarFolderGroup)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .emptyFolder(let file): return "empty-\(file.id)"
|
|
case .duplicate(let group): return "duplicate-\(group.id)"
|
|
case .similar(let group): return "similar-\(group.id)"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ScanResult: Identifiable, Sendable {
|
|
let id = UUID()
|
|
let request: ScanRequest
|
|
let items: [ScanItem]
|
|
let foldersScanned: Int
|
|
let filesScanned: Int
|
|
let skippedFolders: Int
|
|
|
|
var emptyFolders: [CloudFile] { items.compactMap { if case .emptyFolder(let file) = $0 { return file }; return nil } }
|
|
}
|
|
|
|
struct ScanProgress: Sendable {
|
|
var phase = "等待扫描"
|
|
var foldersVisited = 0
|
|
var filesVisited = 0
|
|
}
|
|
|
|
struct ScanDeletionProgress: Sendable {
|
|
var phase = ""
|
|
var completed = 0
|
|
var total = 0
|
|
}
|
|
|
|
struct BatchRenameChange: Identifiable, Sendable {
|
|
let file: CloudFile
|
|
let newName: String
|
|
var id: String { file.id }
|
|
}
|
|
|
|
struct BatchRenameProgress: Sendable {
|
|
var currentName = ""
|
|
var completed = 0
|
|
var total = 0
|
|
}
|
|
|
|
struct BatchRenameExecutionResult: Sendable {
|
|
let succeeded: Int
|
|
let failed: Int
|
|
}
|
|
|
|
enum FileSort: String, CaseIterable, Identifiable, Sendable {
|
|
case name, size, modifiedAt, createdAt, type
|
|
var id: String { rawValue }
|
|
var title: String { ["name": "名称", "size": "大小", "modifiedAt": "修改时间", "createdAt": "创建时间", "type": "类型"][rawValue]! }
|
|
// Guangya get_file_list orderBy mapping, confirmed from the web client.
|
|
var apiOrderBy: Int { switch self {
|
|
case .name: return 0
|
|
case .size: return 1
|
|
case .createdAt: return 2
|
|
case .modifiedAt: return 3
|
|
case .type: return 4
|
|
} }
|
|
var supportsServerSorting: Bool { true }
|
|
}
|
|
|
|
enum SortDirection: Int, Sendable { case ascending = 0, descending = 1 }
|
|
import Foundation
|
|
|
|
enum TMDBMediaKind: String, CaseIterable, Identifiable, Codable, Sendable {
|
|
case automatic, movie, tv
|
|
var id: String { rawValue }
|
|
var title: String { ["automatic": "自动识别", "movie": "电影", "tv": "剧集"][rawValue]! }
|
|
}
|
|
|
|
enum TMDBOperation: String, CaseIterable, Identifiable, Codable, Sendable {
|
|
case preview, organize
|
|
var id: String { rawValue }
|
|
var title: String { self == .preview ? "仅生成预览" : "整理并写入 NFO" }
|
|
}
|
|
|
|
struct TMDBWorkflowConfig: Codable, Sendable {
|
|
var mediaKind: TMDBMediaKind = .automatic
|
|
var operation: TMDBOperation = .preview
|
|
var sourceRecursive = true
|
|
var destinationAtRoot = false
|
|
var folderTemplate = "{title} ({year})"
|
|
var fileTemplate = "{title} ({year})"
|
|
var minimumSizeMB = 50
|
|
var allowedExtensions = "mkv, mp4, m4v, avi, mov, ts, webm"
|
|
var createNFO = true
|
|
var downloadPoster = true
|
|
var downloadBackdrop = true
|
|
var downloadActorImages = true
|
|
var cleanEmptyFolders = false
|
|
|
|
static func load() -> TMDBWorkflowConfig {
|
|
guard let data = UserDefaults.standard.data(forKey: "guangya.tmdbWorkflowConfig"), let config = try? JSONDecoder().decode(TMDBWorkflowConfig.self, from: data) else { return TMDBWorkflowConfig() }
|
|
return config
|
|
}
|
|
func save() { if let data = try? JSONEncoder().encode(self) { UserDefaults.standard.set(data, forKey: "guangya.tmdbWorkflowConfig") } }
|
|
}
|
|
|
|
enum TMDBJobState: String, Sendable { case pending, matched, skipped, failed, completed }
|
|
|
|
struct TMDBCandidate: Identifiable, Sendable {
|
|
let id: Int
|
|
let title: String
|
|
let originalTitle: String
|
|
let mediaType: TMDBMediaKind
|
|
let releaseDate: String
|
|
let overview: String
|
|
let posterPath: String?
|
|
let backdropPath: String?
|
|
|
|
init?(json: JSONValue, forcedKind: TMDBMediaKind? = nil) {
|
|
guard let id = json.firstInt(["id"]), let title = json.firstString(["title", "name"]) else { return nil }
|
|
let rawType = forcedKind?.rawValue ?? json.firstString(["media_type"])
|
|
guard rawType == "movie" || rawType == "tv" else { return nil }
|
|
self.id = id; self.title = title; originalTitle = json.firstString(["original_title", "original_name"]) ?? title
|
|
mediaType = rawType == "tv" ? .tv : .movie
|
|
releaseDate = json.firstString(["release_date", "first_air_date"]) ?? ""
|
|
overview = json.firstString(["overview"]) ?? ""; posterPath = json.firstString(["poster_path"]); backdropPath = json.firstString(["backdrop_path"])
|
|
}
|
|
|
|
var match: TMDBMatch { TMDBMatch(id: id, title: title, mediaType: mediaType.rawValue, releaseDate: releaseDate, overview: overview) }
|
|
}
|
|
|
|
struct MediaLibraryItem: Identifiable, Sendable {
|
|
let file: CloudFile
|
|
let tmdbID: Int?
|
|
let title: String
|
|
let originalTitle: String
|
|
let mediaKind: TMDBMediaKind?
|
|
let releaseDate: String
|
|
let overview: String
|
|
let posterData: Data?
|
|
let backdropData: Data?
|
|
let hasChineseAudio: Bool
|
|
let hasChineseSubtitle: Bool
|
|
let collectionID: Int?
|
|
let collectionName: String?
|
|
|
|
var id: String { file.id }
|
|
var year: String { String(releaseDate.prefix(4)) }
|
|
var isMatched: Bool { mediaKind != nil }
|
|
}
|
|
|
|
struct MediaLibrarySource: Identifiable, Hashable, Sendable {
|
|
let id: String
|
|
let rootID: String?
|
|
let path: String
|
|
|
|
init(id: String = UUID().uuidString, rootID: String?, path: String) {
|
|
self.id = id
|
|
self.rootID = rootID
|
|
self.path = path
|
|
}
|
|
}
|
|
|
|
enum MediaLibraryDestination: Equatable, Sendable {
|
|
case all
|
|
case library(String)
|
|
case management
|
|
case categories
|
|
case tasks
|
|
case tmdb
|
|
}
|
|
|
|
enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable {
|
|
case movies
|
|
case series
|
|
case mixed
|
|
|
|
var id: String { rawValue }
|
|
var title: String {
|
|
switch self {
|
|
case .movies: return "电影"
|
|
case .series: return "电视剧"
|
|
case .mixed: return "混合内容"
|
|
}
|
|
}
|
|
var icon: String {
|
|
switch self {
|
|
case .movies: return "film"
|
|
case .series: return "play.tv"
|
|
case .mixed: return "rectangle.stack"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MediaCategoryRule: Identifiable, Hashable, Codable, Sendable {
|
|
let id: String
|
|
var name: String
|
|
var mediaKind: TMDBMediaKind
|
|
var genreIDs: [Int]
|
|
var languages: [String]
|
|
var isFallback: Bool
|
|
|
|
init(id: String = UUID().uuidString, name: String, mediaKind: TMDBMediaKind, genreIDs: [Int] = [], languages: [String] = [], isFallback: Bool = false) {
|
|
self.id = id
|
|
self.name = name
|
|
self.mediaKind = mediaKind
|
|
self.genreIDs = genreIDs
|
|
self.languages = languages
|
|
self.isFallback = isFallback
|
|
}
|
|
|
|
/// Library organization is intentionally based on TMDB's original language.
|
|
/// Genre data is retained for compatibility with saved rules, but cannot
|
|
/// redirect a title into a category.
|
|
func matches(mediaKind: TMDBMediaKind, genreIDs _: Set<Int>, language: String) -> Bool {
|
|
guard self.mediaKind == mediaKind else { return false }
|
|
if isFallback { return true }
|
|
return !languages.isEmpty && languages.contains(language.lowercased())
|
|
}
|
|
|
|
static let defaults: [MediaCategoryRule] = [
|
|
MediaCategoryRule(id: "movie-chinese", name: "国产电影", mediaKind: .movie, languages: ["zh", "cn", "yue"]),
|
|
MediaCategoryRule(id: "movie-japanese", name: "日韩电影", mediaKind: .movie, languages: ["ja", "ko", "th"]),
|
|
MediaCategoryRule(id: "movie-western", name: "欧美电影", mediaKind: .movie, languages: ["en"]),
|
|
MediaCategoryRule(id: "movie-other", name: "其他电影", mediaKind: .movie, isFallback: true),
|
|
MediaCategoryRule(id: "tv-chinese", name: "国产剧集", mediaKind: .tv, languages: ["zh", "cn", "yue"]),
|
|
MediaCategoryRule(id: "tv-japanese", name: "日韩剧集", mediaKind: .tv, languages: ["ja", "ko", "th"]),
|
|
MediaCategoryRule(id: "tv-western", name: "欧美剧集", mediaKind: .tv, languages: ["en"]),
|
|
MediaCategoryRule(id: "tv-other", name: "其他剧集", mediaKind: .tv, isFallback: true)
|
|
]
|
|
|
|
static func load() -> [MediaCategoryRule] {
|
|
guard let data = UserDefaults.standard.data(forKey: "guangya.mediaCategoryRules"),
|
|
let rules = try? JSONDecoder().decode([MediaCategoryRule].self, from: data), !rules.isEmpty else { return defaults }
|
|
return rules
|
|
}
|
|
|
|
static func save(_ rules: [MediaCategoryRule]) {
|
|
guard let data = try? JSONEncoder().encode(rules) else { return }
|
|
UserDefaults.standard.set(data, forKey: "guangya.mediaCategoryRules")
|
|
}
|
|
}
|
|
|
|
struct MediaLibraryDefinition: Identifiable, Hashable, Sendable {
|
|
let id: String
|
|
var name: String
|
|
var sources: [MediaLibrarySource]
|
|
var kind: MediaLibraryKind
|
|
var recursive: Bool
|
|
var minimumSizeMB: Int
|
|
var updatedAt: Date?
|
|
|
|
var rootID: String? { sources.first?.rootID }
|
|
var rootPath: String {
|
|
if sources.count == 1 { return sources[0].path }
|
|
return sources.isEmpty ? "未配置目录" : "\(sources.count) 个媒体目录"
|
|
}
|
|
|
|
init(id: String, name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50, updatedAt: Date?) {
|
|
self.id = id
|
|
self.name = name
|
|
self.sources = sources
|
|
self.kind = kind
|
|
self.recursive = recursive
|
|
self.minimumSizeMB = max(0, minimumSizeMB)
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
init(id: String, name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50, updatedAt: Date?) {
|
|
self.init(id: id, name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB, updatedAt: updatedAt)
|
|
}
|
|
}
|
|
|
|
struct MediaLibraryScanProgress: Codable, Sendable {
|
|
var phase = ""
|
|
var completed = 0
|
|
var total = 0
|
|
}
|
|
|
|
enum MediaLibraryTaskState: String, Codable, Sendable {
|
|
case queued
|
|
case scanning
|
|
case writing
|
|
case completed
|
|
case cancelled
|
|
case failed
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .queued: return "等待开始"
|
|
case .scanning: return "正在刮削"
|
|
case .writing: return "正在写回"
|
|
case .completed: return "已完成"
|
|
case .cancelled: return "已停止"
|
|
case .failed: return "失败"
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .queued: return "clock"
|
|
case .scanning: return "magnifyingglass"
|
|
case .writing: return "square.and.arrow.down"
|
|
case .completed: return "checkmark.circle.fill"
|
|
case .cancelled: return "stop.circle.fill"
|
|
case .failed: return "exclamationmark.circle.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MediaLibraryTaskLog: Identifiable, Codable, Sendable {
|
|
let id: UUID
|
|
let date: Date
|
|
let message: String
|
|
|
|
init(date: Date = Date(), message: String) {
|
|
self.id = UUID()
|
|
self.date = date
|
|
self.message = message
|
|
}
|
|
}
|
|
|
|
struct MediaLibraryTaskCheckpoint: Codable, Sendable {
|
|
var discoveredFileIDs: Set<String>
|
|
var completedFileIDs: Set<String>
|
|
var writtenFileIDs: Set<String>
|
|
|
|
init(discoveredFileIDs: Set<String> = [], completedFileIDs: Set<String> = [], writtenFileIDs: Set<String> = []) {
|
|
self.discoveredFileIDs = discoveredFileIDs
|
|
self.completedFileIDs = completedFileIDs
|
|
self.writtenFileIDs = writtenFileIDs
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case discoveredFileIDs
|
|
case completedFileIDs
|
|
case writtenFileIDs
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
discoveredFileIDs = try container.decodeIfPresent(Set<String>.self, forKey: .discoveredFileIDs) ?? []
|
|
completedFileIDs = try container.decodeIfPresent(Set<String>.self, forKey: .completedFileIDs) ?? []
|
|
writtenFileIDs = try container.decodeIfPresent(Set<String>.self, forKey: .writtenFileIDs) ?? []
|
|
}
|
|
}
|
|
|
|
struct MediaLibraryTask: Identifiable, Codable, Sendable {
|
|
let id: UUID
|
|
let libraryID: String
|
|
let libraryName: String
|
|
let startedAt: Date
|
|
var updatedAt: Date
|
|
var state: MediaLibraryTaskState
|
|
var progress: MediaLibraryScanProgress
|
|
var currentScrapingName: String?
|
|
var currentStoringName: String?
|
|
var currentWritingName: String?
|
|
var failureReason: String?
|
|
var logs: [MediaLibraryTaskLog]
|
|
var checkpoint: MediaLibraryTaskCheckpoint?
|
|
|
|
init(library: MediaLibraryDefinition) {
|
|
id = UUID()
|
|
libraryID = library.id
|
|
libraryName = library.name
|
|
startedAt = Date()
|
|
updatedAt = startedAt
|
|
state = .queued
|
|
progress = MediaLibraryScanProgress(phase: "等待开始")
|
|
currentScrapingName = nil
|
|
currentStoringName = nil
|
|
currentWritingName = nil
|
|
failureReason = nil
|
|
logs = [MediaLibraryTaskLog(message: "任务已创建,等待扫描「\(library.name)」")]
|
|
checkpoint = MediaLibraryTaskCheckpoint()
|
|
}
|
|
|
|
var isActive: Bool { [.queued, .scanning, .writing].contains(state) }
|
|
|
|
static func loadHistory() -> [MediaLibraryTask] {
|
|
guard let data = UserDefaults.standard.data(forKey: "guangya.mediaLibraryTaskHistory"),
|
|
let tasks = try? JSONDecoder().decode([MediaLibraryTask].self, from: data) else { return [] }
|
|
return tasks.map { task in
|
|
var restored = task
|
|
if restored.isActive {
|
|
restored.state = .cancelled
|
|
restored.progress.phase = "应用重启,任务未继续执行"
|
|
restored.logs.append(MediaLibraryTaskLog(message: "应用重启,未完成任务已停止"))
|
|
restored.updatedAt = Date()
|
|
}
|
|
return restored
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MediaSidecarFiles: Sendable {
|
|
let parentID: String?
|
|
let mediaFolderName: String?
|
|
let nfo: CloudFile?
|
|
let poster: CloudFile?
|
|
let backdrop: CloudFile?
|
|
let subtitles: [CloudFile]
|
|
}
|
|
|
|
struct MediaScanSnapshot: Sendable {
|
|
let files: [CloudFile]
|
|
let sidecars: [String: MediaSidecarFiles]
|
|
}
|
|
|
|
struct ParsedMediaNFO: Sendable {
|
|
let tmdbID: Int?
|
|
let title: String
|
|
let originalTitle: String
|
|
let overview: String
|
|
let releaseDate: String
|
|
let mediaKind: TMDBMediaKind?
|
|
let hasChineseAudio: Bool
|
|
let hasChineseSubtitle: Bool
|
|
let collectionID: Int?
|
|
let collectionName: String?
|
|
}
|
|
|
|
struct ParsedMediaName: Sendable {
|
|
let title: String
|
|
let year: Int?
|
|
let season: Int?
|
|
let episode: Int?
|
|
let isEpisode: Bool
|
|
let resolution: String?
|
|
let source: String?
|
|
let videoCodec: String?
|
|
let audio: String?
|
|
let isDiscStructure: Bool
|
|
}
|
|
|
|
struct TMDBJob: Identifiable, Sendable {
|
|
let id: String
|
|
let file: CloudFile
|
|
let parsed: ParsedMediaName
|
|
var relatedSubtitles: [CloudFile] = []
|
|
var match: TMDBMatch?
|
|
var candidates: [TMDBCandidate] = []
|
|
var selectedCandidateID: Int?
|
|
var state: TMDBJobState
|
|
var note: String
|
|
var isApproved: Bool = false
|
|
|
|
var selectedCandidate: TMDBCandidate? { candidates.first { $0.id == selectedCandidateID } }
|
|
}
|
|
import Foundation
|
|
|
|
/// Small async semaphore used to keep speculative row-detail loading from
|
|
/// starving interactive API calls such as navigation, delete, and refresh.
|
|
actor DetailRequestGate {
|
|
private let limit: Int
|
|
private var active = 0
|
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
init(limit: Int) { self.limit = limit }
|
|
|
|
func acquire() async {
|
|
if active < limit { active += 1; return }
|
|
await withCheckedContinuation { waiters.append($0) }
|
|
active += 1
|
|
}
|
|
|
|
func release() {
|
|
active = max(0, active - 1)
|
|
if !waiters.isEmpty { waiters.removeFirst().resume() }
|
|
}
|
|
}
|