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 var cloudPath: String let fileType: Int var pathDepth: Int { cloudPath.split(separator: "/").count } 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) ?? "" 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 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 title: String let originalTitle: String let mediaKind: TMDBMediaKind? let releaseDate: String let overview: String let posterData: Data? let backdropData: Data? var id: String { file.id } var year: String { String(releaseDate.prefix(4)) } var isMatched: Bool { mediaKind != nil } } 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] = [] 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() } } }