Files
guangya_client/guangya_mac/Models.swift
T

501 lines
18 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
}
// 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 {
let id: String
let name: String
let isDirectory: Bool
var size: Int64?
var gcid: String?
var subDirectoryCount: Int?
var subFileCount: Int?
var modifiedAt: String
let fileType: Int
var icon: String {
if isDirectory { return "folder.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 "文件夹" }
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?(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) ?? ""
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 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
}
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?
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"])
}
var match: TMDBMatch { TMDBMatch(id: id, title: title, mediaType: mediaType.rawValue, releaseDate: releaseDate, overview: overview) }
}
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() }
}
}