0df8013d3e
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
3921 lines
227 KiB
Swift
3921 lines
227 KiB
Swift
import AppKit
|
||
import Combine
|
||
import CryptoKit
|
||
import Foundation
|
||
|
||
/// Shared helper to extract CloudFile objects from a JSON response.
|
||
/// Used by both AppModel and PanelModel.
|
||
func extractCloudFiles(from value: JSONValue) -> [CloudFile] {
|
||
var result: [CloudFile] = []
|
||
var seen = Set<String>()
|
||
func visit(_ value: JSONValue) {
|
||
if let file = CloudFile(json: value), seen.insert(file.id).inserted { result.append(file) }
|
||
if let object = value.objectValue { object.values.forEach(visit) }
|
||
if let array = value.arrayValue { array.forEach(visit) }
|
||
}
|
||
visit(value)
|
||
return result
|
||
}
|
||
|
||
/// Shares successful TMDB lookups between episodes from the same release.
|
||
/// A typical series folder can contain hundreds of files; querying its show
|
||
/// once is both faster and much less likely to hit a remote rate limit.
|
||
private actor MediaTMDBCandidateCache {
|
||
private var values: [String: TMDBCandidate] = [:]
|
||
|
||
func candidate(for key: String) -> TMDBCandidate? { values[key] }
|
||
func store(_ candidate: TMDBCandidate, for key: String) { values[key] = candidate }
|
||
}
|
||
|
||
private struct MediaScrapeResult: Sendable {
|
||
let index: Int
|
||
let item: MediaLibraryItem
|
||
let note: String?
|
||
let reusedExisting: Bool
|
||
var needsWriteback = false
|
||
}
|
||
|
||
private struct MediaMetadataWriteEntry: Sendable {
|
||
let item: MediaLibraryItem
|
||
let files: MediaSidecarFiles
|
||
let organizeOnly: Bool
|
||
}
|
||
|
||
private struct LocalFastTransferCandidate: Sendable {
|
||
let url: URL
|
||
let path: String
|
||
}
|
||
|
||
private struct PreparedFastTransferEntry: Sendable {
|
||
let index: Int
|
||
let entry: FastTransferEntry
|
||
let targetID: String?
|
||
let details: [String]
|
||
let retryOf: UUID?
|
||
}
|
||
|
||
private actor FastTransferNameReservation {
|
||
private var names = Set<String>()
|
||
|
||
func reserve(_ key: String) -> Bool { names.insert(key).inserted }
|
||
}
|
||
|
||
private struct FastTransferSession: Codable, Sendable {
|
||
let entries: [FastTransferEntry]
|
||
let results: [FastTransferResult]
|
||
}
|
||
|
||
private enum FastTransferError: LocalizedError {
|
||
case invalidJSON(String)
|
||
case unsupportedHash(String)
|
||
case missingRemoteResource(String)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .invalidJSON(let message): return "秒传 JSON 无效:\(message)"
|
||
case .unsupportedHash(let path): return "\(path) 缺少有效的 etag 或 gcid"
|
||
case .missingRemoteResource(let path): return "云端未找到可秒传资源:\(path)"
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MediaArtworkAsset: Identifiable, Sendable {
|
||
let id: String
|
||
let label: String
|
||
let data: Data
|
||
}
|
||
|
||
struct MediaCastMember: Identifiable, Sendable {
|
||
let id: String
|
||
let name: String
|
||
let role: String
|
||
let imageData: Data?
|
||
}
|
||
|
||
@MainActor
|
||
final class AppModel: ObservableObject {
|
||
@Published var isSignedIn = false
|
||
@Published var user = UserProfile(json: .object([:]))
|
||
@Published var section: WorkspaceSection = .files
|
||
@Published var files: [CloudFile] = []
|
||
@Published var folderPath: [FolderPath] = []
|
||
@Published var isLoadingFiles = false
|
||
@Published var isLoadingFolderSizes = false
|
||
@Published var detailLoadingIDs: Set<String> = []
|
||
@Published var actionLoadingIDs: Set<String> = []
|
||
@Published var currentPage = 0
|
||
@Published var pageSize = 50
|
||
@Published var totalPages = 1
|
||
@Published var globalFileSearchResults: [CloudFile] = []
|
||
@Published var isGlobalFileSearching = false
|
||
@Published var serverSort: FileSort = .name
|
||
@Published var serverSortDirection: SortDirection = .ascending
|
||
@Published var sizeLoadingCompleted = 0
|
||
@Published var sizeLoadingTotal = 0
|
||
@Published var duplicateGroups: [DuplicateGroup] = []
|
||
@Published var similarFolders: [CloudFile] = []
|
||
@Published var detail: JSONValue?
|
||
@Published var detailFileID: String?
|
||
@Published var detailErrorMessage = ""
|
||
@Published var lastActionMessage = ""
|
||
@Published var tmdbAPIKey = UserDefaults.standard.string(forKey: "guangya.tmdbAPIKey") ?? ""
|
||
@Published var tmdbProxyHost = UserDefaults.standard.string(forKey: "guangya.tmdbProxyHost") ?? ""
|
||
@Published var tmdbProxyPort = UserDefaults.standard.string(forKey: "guangya.tmdbProxyPort") ?? ""
|
||
@Published var tmdbWorkflow = TMDBWorkflowConfig.load()
|
||
@Published var tmdbJobs: [TMDBJob] = []
|
||
@Published var tmdbTarget: CloudFile?
|
||
@Published var batchRenameTarget: CloudFile?
|
||
@Published var batchRenameSelection: [CloudFile] = []
|
||
@Published var mediaLibraryTarget: CloudFile?
|
||
@Published var mediaLibraries: [MediaLibraryDefinition] = []
|
||
@Published private(set) var mediaLibraryStatistics = MediaLibraryStatistics()
|
||
@Published private(set) var mediaLibraryStatisticsByLibraryID: [String: MediaLibraryStatistics] = [:]
|
||
@Published var mediaLibraryTasks: [MediaLibraryTask] = []
|
||
@Published var mediaLibraryLiveItemsByLibraryID: [String: [MediaLibraryItem]] = [:]
|
||
@Published var mediaLibraryDestination: MediaLibraryDestination = .all
|
||
@Published var mediaLibrarySearchText = ""
|
||
@Published private(set) var isBatchRecognizingMedia = false
|
||
@Published var isMediaLibraryBackupRunning = false
|
||
@Published var mediaLibraryBackupStatus = ""
|
||
@Published var mediaCategoryRules = MediaCategoryRule.load()
|
||
@Published var isRunningTMDBJob = false
|
||
@Published var isAnalyzing = false
|
||
@Published var activeScanRequest: ScanRequest?
|
||
@Published var scanResult: ScanResult?
|
||
@Published var liveScanItems: [ScanItem] = []
|
||
@Published var scanPickerFolders: [CloudFile] = []
|
||
@Published var isLoadingScanPicker = false
|
||
@Published var scanProgress = ScanProgress()
|
||
@Published var isScanning = false
|
||
@Published var isDeletingScanResults = false
|
||
@Published var scanDeletionProgress = ScanDeletionProgress()
|
||
@Published var isBatchRenaming = false
|
||
@Published var batchRenameProgress = BatchRenameProgress()
|
||
@Published var isFastTransferRunning = false
|
||
@Published private(set) var isFastTransferPaused = false
|
||
@Published private(set) var isFastTransferCancellationRequested = false
|
||
@Published private(set) var cancelledFastTransferEntryIDs: Set<UUID> = []
|
||
@Published var fastTransferProgress = FastTransferProgress()
|
||
@Published private(set) var fastTransferActiveEntries: [FastTransferEntry] = []
|
||
@Published var fastTransferEntries: [FastTransferEntry] = [] {
|
||
didSet { persistFastTransferSession() }
|
||
}
|
||
@Published var fastTransferResults: [FastTransferResult] = [] {
|
||
didSet { persistFastTransferSession() }
|
||
}
|
||
@Published var fastTransferConcurrency = 3 {
|
||
didSet {
|
||
let normalized = min(max(fastTransferConcurrency, 1), 20)
|
||
if normalized != fastTransferConcurrency { fastTransferConcurrency = normalized; return }
|
||
UserDefaults.standard.set(normalized, forKey: "guangya.fastTransferConcurrency")
|
||
if isFastTransferRunning { resetFastTransferEstimateSample() }
|
||
}
|
||
}
|
||
@Published var mediaScrapeConcurrency = 3 {
|
||
didSet {
|
||
let normalized = min(max(mediaScrapeConcurrency, 3), 20)
|
||
if normalized != mediaScrapeConcurrency { mediaScrapeConcurrency = normalized; return }
|
||
UserDefaults.standard.set(normalized, forKey: "guangya.mediaScrapeConcurrency")
|
||
}
|
||
}
|
||
@Published var isMediaWritebackEnabled = false {
|
||
didSet { UserDefaults.standard.set(isMediaWritebackEnabled, forKey: "guangya.mediaWritebackEnabled") }
|
||
}
|
||
@Published var isGeneratingFastTransferJSON = false
|
||
@Published var fastTransferGenerationProgress = FastTransferProgress()
|
||
@Published var isBusy = false
|
||
@Published var statusMessage = ""
|
||
@Published var errorMessage = ""
|
||
@Published var phoneNumber = "+86 "
|
||
@Published var verificationCode = ""
|
||
@Published var captchaToken = ""
|
||
@Published var verificationID = ""
|
||
@Published var qrPayload = ""
|
||
@Published var qrToken = ""
|
||
@Published var qrStatus = "等待生成二维码"
|
||
@Published var codeCountdown = 0
|
||
|
||
let api: GuangyaAPI
|
||
private var qrPollingTask: Task<Void, Never>?
|
||
private var countdownTask: Task<Void, Never>?
|
||
private var detailEnrichmentTask: Task<Void, Never>?
|
||
private var listResponseTask: Task<JSONValue, Error>?
|
||
private var activeListRequestID = UUID()
|
||
private var detailEnrichmentGeneration = UUID()
|
||
private var scanPickerRequestID = UUID()
|
||
private var detailCache: [String: (value: JSONValue, expiresAt: Date)] = [:]
|
||
private var detailRequestTasks: [String: Task<JSONValue, Error>] = [:]
|
||
private let detailRequestGate = DetailRequestGate(limit: 4)
|
||
private let metadataCache = FileMetadataCache.shared
|
||
private let mediaLibraryStore = MediaLibraryStore.shared
|
||
private var isHandlingAuthorizationExpiry = false
|
||
private var scanTask: Task<Void, Never>?
|
||
private var scanSessionID: UUID?
|
||
private var liveScannedFilesByID: [String: CloudFile] = [:]
|
||
private var globalFileSearchTask: Task<Void, Never>?
|
||
private var globalFileSearchID = UUID()
|
||
private var mediaLibraryScanTasks: [UUID: Task<Void, Never>] = [:]
|
||
private var mediaMetadataWriteTasks: [UUID: Task<Void, Never>] = [:]
|
||
private var mediaMetadataWriteQueues: [UUID: [MediaMetadataWriteEntry]] = [:]
|
||
private var mediaMetadataWrittenKeys: [UUID: Set<String>] = [:]
|
||
private var didAttemptCloudMediaLibraryRestore = false
|
||
private var normalizedMediaFolderIDs: [UUID: Set<String>] = [:]
|
||
private var organizedMediaFolderIDs: [UUID: Set<String>] = [:]
|
||
private var mediaCategoryFolderIDs: [UUID: [String: String]] = [:]
|
||
private var mediaArtworkAssetCache: [String: [MediaArtworkAsset]] = [:]
|
||
private var mediaCastCache: [String: [MediaCastMember]] = [:]
|
||
private var mediaMetadataWriteFailures: [UUID: Int] = [:]
|
||
private var resumedMediaLibraryTaskIDs: Set<UUID> = []
|
||
private var fastTransferSessionSaveTask: Task<Void, Never>?
|
||
private var fastTransferStartedAt: Date?
|
||
private var fastTransferElapsedBeforePause: TimeInterval = 0
|
||
private var fastTransferEstimateBaselineProcessed = 0
|
||
private var fastTransferEstimateBaselineElapsed: TimeInterval = 0
|
||
|
||
init() {
|
||
let defaults = UserDefaults.standard
|
||
fastTransferConcurrency = min(max(defaults.object(forKey: "guangya.fastTransferConcurrency") as? Int ?? 3, 1), 20)
|
||
mediaScrapeConcurrency = min(max(defaults.object(forKey: "guangya.mediaScrapeConcurrency") as? Int ?? 3, 3), 20)
|
||
isMediaWritebackEnabled = defaults.object(forKey: "guangya.mediaWritebackEnabled") as? Bool ?? false
|
||
let access = defaults.string(forKey: "guangya.accessToken") ?? ""
|
||
let refresh = defaults.string(forKey: "guangya.refreshToken")
|
||
api = GuangyaAPI(accessToken: access, refreshToken: refresh)
|
||
restoreFastTransferSession()
|
||
Task.detached(priority: .utility) { [weak self] in await self?.cleanupDeletedFiles() }
|
||
mediaLibraryTasks = MediaLibraryTask.loadHistory()
|
||
persistMediaLibraryTaskHistory()
|
||
if !access.isEmpty || refresh != nil {
|
||
isSignedIn = true
|
||
isLoadingFiles = true
|
||
Task { await loadAccount() }
|
||
}
|
||
}
|
||
|
||
func sendVerificationCode() async {
|
||
guard phoneNumber.filter({ $0.isNumber }).count >= 8 else { errorMessage = "请输入有效的手机号"; return }
|
||
await perform("正在发送验证码…") { [self] in
|
||
let initResult = try await api.loginSMSInit(phoneNumber: phoneNumber)
|
||
guard let captcha = initResult.firstString(["captcha_token", "captchaToken"]) else {
|
||
if initResult.firstString(["url", "verify_url", "verifyUrl"]) != nil { throw GuangyaAPIError.http(status: 403, message: "需要完成验证码验证后再发送短信") }
|
||
throw GuangyaAPIError.missingField("captcha_token")
|
||
}
|
||
captchaToken = captcha
|
||
let result = try await api.loginSMSSend(phoneNumber: phoneNumber, captchaToken: captcha)
|
||
guard let id = result.firstString(["verification_id", "verificationId", "id"]) else { throw GuangyaAPIError.missingField("verification_id") }
|
||
verificationID = id
|
||
startCountdown()
|
||
statusMessage = "验证码已发送"
|
||
}
|
||
}
|
||
|
||
func verifyAndSignIn() async {
|
||
guard !verificationID.isEmpty, !captchaToken.isEmpty else { errorMessage = "请先发送验证码"; return }
|
||
guard verificationCode.count >= 4 else { errorMessage = "请输入短信验证码"; return }
|
||
await perform("正在登录…") { [self] in
|
||
let verified = try await api.loginSMSVerify(verificationID: verificationID, verificationCode: verificationCode)
|
||
guard let token = verified.firstString(["verification_token", "verificationToken"]) else { throw GuangyaAPIError.missingField("verification_token") }
|
||
_ = try await api.loginSMSSignIn(code: verificationCode, verificationToken: token, username: phoneNumber, captchaToken: captchaToken)
|
||
completeLogin()
|
||
}
|
||
}
|
||
|
||
func startQRLogin() async {
|
||
qrPollingTask?.cancel()
|
||
qrPayload = ""
|
||
qrToken = ""
|
||
qrStatus = "正在生成二维码…"
|
||
await perform("正在生成二维码…") { [self] in
|
||
let result = try await api.loginQRInit()
|
||
qrToken = result.firstStringDeep(["device_code", "deviceCode", "qr_token", "qrToken", "token", "id"]) ?? ""
|
||
qrPayload = result.firstStringDeep(["verification_uri_complete", "verificationUriComplete", "qr_url", "qrUrl", "url", "verification_uri", "verificationUri", "qrcode", "qrCode", "code"]) ?? qrToken
|
||
guard !qrPayload.isEmpty else { throw GuangyaAPIError.missingField("二维码内容") }
|
||
qrStatus = "请使用光鸭 App 扫码"
|
||
let expiresIn = result.firstIntDeep(["expires_in", "expiresIn"]) ?? 120
|
||
let interval = max(2, result.firstIntDeep(["interval", "pollInterval", "poll_interval"]) ?? 3)
|
||
startQRPolling(expiresIn: expiresIn, interval: interval)
|
||
}
|
||
}
|
||
|
||
func stopQRLogin() { qrPollingTask?.cancel(); qrPollingTask = nil }
|
||
|
||
func refresh() async {
|
||
guard isSignedIn else { return }
|
||
// Refresh is an explicit recovery action: abandon a stalled first-page
|
||
// request and start a fresh one instead of leaving the button disabled.
|
||
listResponseTask?.cancel()
|
||
detailEnrichmentTask?.cancel()
|
||
activeListRequestID = UUID()
|
||
await loadFiles(force: true)
|
||
}
|
||
|
||
func searchFilesGlobally(_ query: String) {
|
||
globalFileSearchTask?.cancel()
|
||
let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let requestID = UUID()
|
||
globalFileSearchID = requestID
|
||
guard !normalizedQuery.isEmpty else {
|
||
globalFileSearchResults = []
|
||
isGlobalFileSearching = false
|
||
return
|
||
}
|
||
globalFileSearchResults = []
|
||
isGlobalFileSearching = true
|
||
globalFileSearchTask = Task { [weak self] in
|
||
guard let self else { return }
|
||
do {
|
||
try await Task.sleep(for: .milliseconds(260))
|
||
try Task.checkCancellation()
|
||
let files = try await self.allGlobalSearchFiles()
|
||
try Task.checkCancellation()
|
||
guard self.globalFileSearchID == requestID else { return }
|
||
let matches = files.filter {
|
||
$0.name.localizedCaseInsensitiveContains(normalizedQuery) ||
|
||
$0.cloudPath.localizedCaseInsensitiveContains(normalizedQuery)
|
||
}
|
||
self.globalFileSearchResults = matches.sorted {
|
||
if $0.isDirectory != $1.isDirectory { return $0.isDirectory }
|
||
return $0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||
}
|
||
self.isGlobalFileSearching = false
|
||
self.globalFileSearchTask = nil
|
||
} catch is CancellationError {
|
||
// A newer keystroke has superseded this query.
|
||
} catch {
|
||
guard self.globalFileSearchID == requestID else { return }
|
||
self.isGlobalFileSearching = false
|
||
self.globalFileSearchTask = nil
|
||
self.errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
func clearGlobalFileSearch() {
|
||
globalFileSearchTask?.cancel()
|
||
globalFileSearchTask = nil
|
||
globalFileSearchID = UUID()
|
||
globalFileSearchResults = []
|
||
isGlobalFileSearching = false
|
||
}
|
||
|
||
func reloadCurrentListSizes() {
|
||
schedulePageDetailEnrichment(force: true)
|
||
}
|
||
|
||
func loadVisibleItemDetails(_ file: CloudFile) {
|
||
guard needsMetadata(for: file) else { return }
|
||
guard !detailLoadingIDs.contains(file.id) else { return }
|
||
detailLoadingIDs.insert(file.id)
|
||
Task { [weak self] in
|
||
guard let self else { return }
|
||
let detail = try? await self.cachedDetail(for: file)
|
||
guard !Task.isCancelled, let index = self.files.firstIndex(where: { $0.id == file.id }) else { self.detailLoadingIDs.remove(file.id); return }
|
||
if let size = detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]) { self.files[index].size = size }
|
||
else if self.files[index].isDirectory { self.files[index].size = 0 }
|
||
if let gcid = detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]) { self.files[index].gcid = gcid }
|
||
if let epoch = detail?.firstInt64Deep(["utime", "ctime"]) { self.files[index].modifiedAt = Self.formatDate(epoch) }
|
||
if let count = detail?.firstIntDeep(["subDirCount"]) { self.files[index].subDirectoryCount = count }
|
||
if let count = detail?.firstIntDeep(["subFileCount"]) { self.files[index].subFileCount = count }
|
||
self.detailLoadingIDs.remove(file.id)
|
||
}
|
||
}
|
||
|
||
func openFolder(_ file: CloudFile) async {
|
||
guard file.isDirectory, !isLoadingFiles else { return }
|
||
currentPage = 0
|
||
folderPath.append(FolderPath(id: file.id, name: file.name))
|
||
await loadFiles()
|
||
}
|
||
|
||
func navigateToRoot() async {
|
||
guard !folderPath.isEmpty, !isLoadingFiles else { return }
|
||
folderPath.removeAll()
|
||
currentPage = 0
|
||
await loadFiles()
|
||
}
|
||
|
||
func navigateToFolder(at index: Int) async {
|
||
guard folderPath.indices.contains(index), !isLoadingFiles else { return }
|
||
folderPath.removeSubrange((index + 1)..<folderPath.count)
|
||
currentPage = 0
|
||
await loadFiles()
|
||
}
|
||
|
||
func createFolder(name: String) async {
|
||
guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||
await perform("正在创建文件夹…") { [self] in _ = try await api.fsCreateDir(name: name, parentID: folderPath.last?.id); await loadFiles() }
|
||
}
|
||
|
||
func deleteSelectedFiles(_ ids: Set<String>) async {
|
||
let visibleFiles = Array(Dictionary(grouping: files + globalFileSearchResults, by: \.id).compactMap { $0.value.first })
|
||
let targets = visibleFiles.filter { ids.contains($0.id) }
|
||
guard !targets.isEmpty else { return }
|
||
await perform("正在删除 \(targets.count) 项…") { [self] in
|
||
actionLoadingIDs.formUnion(ids)
|
||
defer { actionLoadingIDs.subtract(ids) }
|
||
// One request preserves the server's batch semantics and avoids partial UI updates.
|
||
_ = try await api.fsDelete(fileIDs: targets.map(\.id))
|
||
files.removeAll { ids.contains($0.id) }
|
||
globalFileSearchResults.removeAll { ids.contains($0.id) }
|
||
duplicateGroups = duplicateGroups.compactMap { group in
|
||
let kept = group.files.filter { !ids.contains($0.id) }
|
||
return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil
|
||
}
|
||
similarFolders.removeAll { ids.contains($0.id) }
|
||
}
|
||
}
|
||
|
||
func delete(_ file: CloudFile) async {
|
||
actionLoadingIDs.insert(file.id)
|
||
defer { actionLoadingIDs.remove(file.id) }
|
||
await perform("正在删除…") { [self] in
|
||
_ = try await api.fsDelete(fileIDs: [file.id])
|
||
// The list, its enriched size data, and folder counts are already local.
|
||
// Apply the successful deletion in place instead of reloading every row.
|
||
files.removeAll { $0.id == file.id }
|
||
globalFileSearchResults.removeAll { $0.id == file.id }
|
||
duplicateGroups = duplicateGroups.compactMap { group in
|
||
let kept = group.files.filter { $0.id != file.id }
|
||
return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil
|
||
}
|
||
similarFolders.removeAll { $0.id == file.id }
|
||
}
|
||
}
|
||
|
||
func goToPage(_ page: Int) async {
|
||
let target = max(0, min(page, totalPages - 1))
|
||
guard target != currentPage, !isLoadingFiles else { return }
|
||
currentPage = target
|
||
await loadFiles()
|
||
}
|
||
|
||
func setPageSize(_ size: Int) async {
|
||
guard size != pageSize else { return }
|
||
pageSize = size
|
||
currentPage = 0
|
||
await loadFiles()
|
||
}
|
||
|
||
func setServerSort(_ sort: FileSort) async {
|
||
if serverSort == sort { serverSortDirection = serverSortDirection == .ascending ? .descending : .ascending }
|
||
else { serverSort = sort; serverSortDirection = .ascending }
|
||
currentPage = 0
|
||
await loadFiles()
|
||
}
|
||
|
||
func setServerSort(_ sort: FileSort, direction: SortDirection) async {
|
||
guard serverSort != sort || serverSortDirection != direction else { return }
|
||
serverSort = sort
|
||
serverSortDirection = direction
|
||
currentPage = 0
|
||
await loadFiles()
|
||
}
|
||
|
||
var scanScopeLocationName: String {
|
||
if section == .files { return folderPath.isEmpty ? "云盘根目录" : folderPath.map(\.name).joined(separator: " / ") }
|
||
return section.rawValue
|
||
}
|
||
|
||
var currentScanRootID: String? { section == .files ? folderPath.last?.id : nil }
|
||
|
||
func upload(url: URL) async {
|
||
await perform("正在上传 \(url.lastPathComponent)…") { [self] in _ = try await api.fileUpload(url: url, parentID: folderPath.last?.id); await loadFiles() }
|
||
}
|
||
|
||
/// Upload a local file to a specific folder by name (finds or creates it).
|
||
func uploadBeta(fileAt localURL: URL, toFolder folderName: String) async {
|
||
await perform("正在上传 \(localURL.lastPathComponent)…") { [self] in
|
||
// List root to find or create target folder
|
||
let response = try await api.fsFiles(parentID: nil, page: 0, pageSize: 200)
|
||
let existing = extractFiles(from: response)
|
||
var targetID: String? = existing.first { $0.name == folderName && $0.isDirectory }?.id
|
||
if targetID == nil {
|
||
let created = try await api.fsCreateDir(name: folderName, parentID: nil)
|
||
let createdFiles = extractFiles(from: created)
|
||
targetID = createdFiles.first?.id
|
||
}
|
||
guard let parentID = targetID else {
|
||
lastActionMessage = "无法创建文件夹 \(folderName)"
|
||
return
|
||
}
|
||
_ = try await api.fileUpload(url: localURL, parentID: parentID)
|
||
lastActionMessage = "已上传 \(localURL.lastPathComponent) 到 \(folderName)/"
|
||
}
|
||
}
|
||
|
||
func parseFastTransferJSON(_ text: String) throws -> [FastTransferEntry] {
|
||
guard let data = text.data(using: .utf8) else { throw FastTransferError.invalidJSON("不是 UTF-8 文本") }
|
||
let value = try JSONSerialization.jsonObject(with: data)
|
||
let values: [[String: Any]]
|
||
if let object = value as? [String: Any], let files = object["files"] as? [[String: Any]] {
|
||
values = files
|
||
} else if let files = value as? [[String: Any]] {
|
||
values = files
|
||
} else {
|
||
throw FastTransferError.invalidJSON("顶层需要包含 files 数组")
|
||
}
|
||
var entries: [FastTransferEntry] = []
|
||
for (index, item) in values.enumerated() {
|
||
let name = Self.fastTransferString(in: item, keys: ["name", "fileName", "filename"])
|
||
let declaredPath = Self.fastTransferString(in: item, keys: ["path", "filePath", "filepath"])
|
||
let path = Self.normalizedFastTransferPath(declaredPath ?? name ?? "")
|
||
guard !path.isEmpty, path != "." else { throw FastTransferError.invalidJSON("第 \(index + 1) 项缺少 path 或 name") }
|
||
let size = Self.fastTransferSize(in: item)
|
||
guard size >= 0 else { throw FastTransferError.invalidJSON("第 \(index + 1) 项 size 无效") }
|
||
let rawETag = Self.fastTransferString(in: item, keys: ["etag", "eTag", "md5"])
|
||
let rawGCID = Self.fastTransferString(in: item, keys: ["gcid", "gcId"])
|
||
let rawHash = Self.fastTransferString(in: item, keys: ["hash"])
|
||
let etag = Self.validatedFastTransferMD5(rawETag ?? rawHash)
|
||
let gcid = Self.validatedFastTransferHash(rawGCID ?? rawHash, length: 40)
|
||
guard etag != nil || gcid != nil else { throw FastTransferError.unsupportedHash(path) }
|
||
entries.append(FastTransferEntry(path: path, size: size, etag: etag, gcid: gcid))
|
||
}
|
||
guard !entries.isEmpty else { throw FastTransferError.invalidJSON("files 不能为空") }
|
||
return entries
|
||
}
|
||
|
||
func fastTransferJSON(for entries: [FastTransferEntry]) throws -> String {
|
||
let files: [[String: Any]] = entries.map { entry in
|
||
var value: [String: Any] = ["path": entry.path, "size": entry.size]
|
||
if let gcid = entry.gcid { value["gcid"] = gcid }
|
||
if let etag = entry.etag { value["etag"] = etag }
|
||
return value
|
||
}
|
||
let data = try JSONSerialization.data(withJSONObject: ["files": files], options: [.prettyPrinted, .sortedKeys])
|
||
guard let text = String(data: data, encoding: .utf8) else { throw FastTransferError.invalidJSON("无法生成文本") }
|
||
return text
|
||
}
|
||
|
||
func generateFastTransferEntries(from urls: [URL]) async throws -> [FastTransferEntry] {
|
||
guard !urls.isEmpty, !isGeneratingFastTransferJSON else { return [] }
|
||
isGeneratingFastTransferJSON = true
|
||
fastTransferGenerationProgress = FastTransferProgress()
|
||
defer { isGeneratingFastTransferJSON = false }
|
||
let candidates = try await Task.detached(priority: .userInitiated) {
|
||
try Self.fastTransferLocalCandidates(from: urls)
|
||
}.value
|
||
fastTransferGenerationProgress.total = candidates.count
|
||
var entries: [FastTransferEntry] = []
|
||
for candidate in candidates {
|
||
try Task.checkCancellation()
|
||
fastTransferGenerationProgress.currentName = candidate.path
|
||
do {
|
||
let hashes = try await Task.detached(priority: .userInitiated) {
|
||
try Self.calculateLocalHashes(at: candidate.url)
|
||
}.value
|
||
entries.append(FastTransferEntry(path: candidate.path, size: Self.localFileSize(at: candidate.url), etag: hashes.md5, gcid: hashes.gcid))
|
||
fastTransferGenerationProgress.completed += 1
|
||
} catch {
|
||
fastTransferGenerationProgress.failed += 1
|
||
}
|
||
}
|
||
return entries
|
||
}
|
||
|
||
func importFastTransferEntries(
|
||
_ entries: [FastTransferEntry],
|
||
parentID: String?,
|
||
createDirectories: Bool,
|
||
skipExisting: Bool,
|
||
retryingResultIDs: [UUID: UUID] = [:]
|
||
) async -> [FastTransferResult] {
|
||
guard !entries.isEmpty, !isFastTransferRunning else { return [] }
|
||
isFastTransferRunning = true
|
||
isFastTransferPaused = false
|
||
isFastTransferCancellationRequested = false
|
||
cancelledFastTransferEntryIDs = []
|
||
fastTransferProgress = FastTransferProgress(total: entries.count)
|
||
fastTransferActiveEntries = []
|
||
fastTransferElapsedBeforePause = 0
|
||
fastTransferStartedAt = Date()
|
||
resetFastTransferEstimateSample()
|
||
defer {
|
||
if let startedAt = fastTransferStartedAt {
|
||
fastTransferElapsedBeforePause += Date().timeIntervalSince(startedAt)
|
||
}
|
||
fastTransferStartedAt = nil
|
||
clearFastTransferActiveEntries()
|
||
isFastTransferPaused = false
|
||
isFastTransferCancellationRequested = false
|
||
cancelledFastTransferEntryIDs.removeAll()
|
||
isFastTransferRunning = false
|
||
}
|
||
var indexedResults: [(Int, FastTransferResult)] = []
|
||
var nextEntryIndex = 0
|
||
var shouldStopSubmitting = false
|
||
let nameReservations = FastTransferNameReservation()
|
||
|
||
func recordPreparationResult(_ index: Int, _ result: FastTransferResult) {
|
||
indexedResults.append((index, result))
|
||
fastTransferResults.append(result)
|
||
switch result.state {
|
||
case .imported:
|
||
fastTransferProgress.completed += 1
|
||
fastTransferProgress.imported += 1
|
||
case .skipped:
|
||
fastTransferProgress.completed += 1
|
||
fastTransferProgress.skipped += 1
|
||
case .failed:
|
||
fastTransferProgress.failed += 1
|
||
case .cancelled:
|
||
fastTransferProgress.cancelled += 1
|
||
}
|
||
}
|
||
|
||
func nextQueuedEntry() async -> (Int, FastTransferEntry)? {
|
||
while nextEntryIndex < entries.count && !shouldStopSubmitting {
|
||
let index = nextEntryIndex
|
||
let entry = entries[index]
|
||
nextEntryIndex += 1
|
||
if index > 0 && index.isMultiple(of: 32) { await Task.yield() }
|
||
if cancelledFastTransferEntryIDs.contains(entry.id) {
|
||
let result = FastTransferResult(entry: entry, state: .cancelled, message: "任务已取消", taskID: nil, targetID: nil, details: ["任务在提交前被取消"], retryOf: retryingResultIDs[entry.id])
|
||
recordPreparationResult(index, result)
|
||
cancelledFastTransferEntryIDs.remove(entry.id)
|
||
continue
|
||
}
|
||
guard await waitForFastTransferPermission() else {
|
||
shouldStopSubmitting = true
|
||
return nil
|
||
}
|
||
return (index, entry)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
let fastTransferAPI = api
|
||
await withTaskGroup(of: (Int, FastTransferResult).self) { group in
|
||
var activeUploadCount = 0
|
||
for _ in 0..<min(fastTransferConcurrency, entries.count) {
|
||
guard let (index, entry) = await nextQueuedEntry() else { break }
|
||
fastTransferProgress.currentName = entry.path
|
||
addFastTransferActiveEntry(entry)
|
||
group.addTask {
|
||
(index, await Self.prepareAndPerformFastTransfer(entry, parentID: parentID, createDirectories: createDirectories, skipExisting: skipExisting, retryOf: retryingResultIDs[entry.id], reservations: nameReservations, api: fastTransferAPI))
|
||
}
|
||
activeUploadCount += 1
|
||
}
|
||
while let (index, result) = await group.next() {
|
||
activeUploadCount -= 1
|
||
indexedResults.append((index, result))
|
||
fastTransferResults.append(result)
|
||
switch result.state {
|
||
case .imported:
|
||
fastTransferProgress.completed += 1
|
||
fastTransferProgress.imported += 1
|
||
case .skipped:
|
||
fastTransferProgress.completed += 1
|
||
fastTransferProgress.skipped += 1
|
||
case .failed: fastTransferProgress.failed += 1
|
||
case .cancelled: fastTransferProgress.cancelled += 1
|
||
}
|
||
fastTransferProgress.currentName = result.entry.path
|
||
removeFastTransferActiveEntry(result.entry.id)
|
||
while !isFastTransferCancellationRequested,
|
||
activeUploadCount < fastTransferConcurrency,
|
||
let (nextIndex, nextEntry) = await nextQueuedEntry() {
|
||
fastTransferProgress.currentName = nextEntry.path
|
||
addFastTransferActiveEntry(nextEntry)
|
||
group.addTask {
|
||
(nextIndex, await Self.prepareAndPerformFastTransfer(nextEntry, parentID: parentID, createDirectories: createDirectories, skipExisting: skipExisting, retryOf: retryingResultIDs[nextEntry.id], reservations: nameReservations, api: fastTransferAPI))
|
||
}
|
||
activeUploadCount += 1
|
||
}
|
||
}
|
||
}
|
||
let results = indexedResults.sorted { $0.0 < $1.0 }.map(\.1)
|
||
if section == .files { await loadFiles(force: true) }
|
||
return results
|
||
}
|
||
|
||
func pauseFastTransfer() {
|
||
guard isFastTransferRunning else { return }
|
||
if let startedAt = fastTransferStartedAt {
|
||
fastTransferElapsedBeforePause += Date().timeIntervalSince(startedAt)
|
||
fastTransferStartedAt = nil
|
||
}
|
||
isFastTransferPaused = true
|
||
}
|
||
|
||
func resumeFastTransfer() {
|
||
guard isFastTransferRunning else { return }
|
||
fastTransferStartedAt = Date()
|
||
isFastTransferPaused = false
|
||
}
|
||
|
||
func fastTransferEstimatedRemaining(at date: Date = Date()) -> TimeInterval? {
|
||
let processed = fastTransferProgress.processed
|
||
let processedSinceBaseline = processed - fastTransferEstimateBaselineProcessed
|
||
guard isFastTransferRunning, processedSinceBaseline > 0, fastTransferProgress.total > processed else { return nil }
|
||
let elapsed = fastTransferElapsed(at: date) - fastTransferEstimateBaselineElapsed
|
||
guard elapsed >= 1 else { return nil }
|
||
return max(0, Double(fastTransferProgress.total - processed) * elapsed / Double(processedSinceBaseline))
|
||
}
|
||
|
||
private func fastTransferElapsed(at date: Date = Date()) -> TimeInterval {
|
||
fastTransferElapsedBeforePause + (fastTransferStartedAt.map { date.timeIntervalSince($0) } ?? 0)
|
||
}
|
||
|
||
private func resetFastTransferEstimateSample() {
|
||
fastTransferEstimateBaselineProcessed = fastTransferProgress.processed
|
||
fastTransferEstimateBaselineElapsed = fastTransferElapsed()
|
||
}
|
||
|
||
func cancelFastTransfer() {
|
||
guard isFastTransferRunning else { return }
|
||
isFastTransferCancellationRequested = true
|
||
isFastTransferPaused = false
|
||
}
|
||
|
||
func cancelFastTransferEntry(_ entryID: UUID) {
|
||
guard isFastTransferRunning, !fastTransferProgress.activeEntryIDs.contains(entryID) else { return }
|
||
cancelledFastTransferEntryIDs.insert(entryID)
|
||
}
|
||
|
||
private func addFastTransferActiveEntry(_ entry: FastTransferEntry) {
|
||
var progress = fastTransferProgress
|
||
progress.activeEntryIDs.insert(entry.id)
|
||
fastTransferProgress = progress
|
||
if !fastTransferActiveEntries.contains(where: { $0.id == entry.id }) {
|
||
fastTransferActiveEntries.append(entry)
|
||
}
|
||
}
|
||
|
||
private func removeFastTransferActiveEntry(_ entryID: UUID) {
|
||
var progress = fastTransferProgress
|
||
progress.activeEntryIDs.remove(entryID)
|
||
fastTransferProgress = progress
|
||
fastTransferActiveEntries.removeAll { $0.id == entryID }
|
||
}
|
||
|
||
private func clearFastTransferActiveEntries() {
|
||
var progress = fastTransferProgress
|
||
progress.activeEntryIDs.removeAll()
|
||
fastTransferProgress = progress
|
||
fastTransferActiveEntries.removeAll()
|
||
}
|
||
|
||
private func waitForFastTransferPermission() async -> Bool {
|
||
while isFastTransferPaused && !isFastTransferCancellationRequested && !Task.isCancelled {
|
||
try? await Task.sleep(for: .milliseconds(180))
|
||
}
|
||
return !isFastTransferCancellationRequested && !Task.isCancelled
|
||
}
|
||
|
||
func clearFastTransferResults() {
|
||
guard !isFastTransferRunning else { return }
|
||
fastTransferResults.removeAll()
|
||
}
|
||
|
||
func clearFastTransferSession() {
|
||
guard !isFastTransferRunning else { return }
|
||
fastTransferEntries.removeAll()
|
||
fastTransferResults.removeAll()
|
||
fastTransferProgress = FastTransferProgress()
|
||
}
|
||
|
||
var hasPendingFastTransferEntries: Bool {
|
||
let completedIDs = Set(fastTransferResults.map(\.entry.id))
|
||
return fastTransferEntries.contains { !completedIDs.contains($0.id) }
|
||
}
|
||
|
||
func replaceFastTransferEntries(_ entries: [FastTransferEntry]) {
|
||
guard !isFastTransferRunning else { return }
|
||
fastTransferResults.removeAll()
|
||
fastTransferEntries = entries
|
||
fastTransferProgress = FastTransferProgress(total: entries.count)
|
||
persistFastTransferSession(immediately: true)
|
||
}
|
||
|
||
private static var fastTransferSessionURL: URL? {
|
||
guard let supportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil }
|
||
let directory = supportDirectory.appendingPathComponent("Guangya", isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
return directory.appendingPathComponent("fast-transfer-session.json", isDirectory: false)
|
||
}
|
||
|
||
private func persistFastTransferSession(immediately: Bool = false) {
|
||
fastTransferSessionSaveTask?.cancel()
|
||
guard let url = Self.fastTransferSessionURL else { return }
|
||
let session = FastTransferSession(entries: fastTransferEntries, results: fastTransferResults)
|
||
fastTransferSessionSaveTask = Task.detached(priority: .utility) {
|
||
if !immediately {
|
||
try? await Task.sleep(for: .milliseconds(200))
|
||
}
|
||
guard !Task.isCancelled else { return }
|
||
if session.entries.isEmpty && session.results.isEmpty {
|
||
try? FileManager.default.removeItem(at: url)
|
||
return
|
||
}
|
||
guard let data = try? JSONEncoder().encode(session), !Task.isCancelled else { return }
|
||
try? data.write(to: url, options: .atomic)
|
||
}
|
||
}
|
||
|
||
private func restoreFastTransferSession() {
|
||
guard let url = Self.fastTransferSessionURL,
|
||
let data = try? Data(contentsOf: url),
|
||
let session = try? JSONDecoder().decode(FastTransferSession.self, from: data) else { return }
|
||
let entryIDs = Set(session.entries.map(\.id))
|
||
fastTransferEntries = session.entries
|
||
fastTransferResults = session.results.filter { entryIDs.contains($0.entry.id) }
|
||
let latestResults = fastTransferResults.reduce(into: [UUID: FastTransferResult]()) { $0[$1.entry.id] = $1 }
|
||
var progress = FastTransferProgress(total: fastTransferEntries.count)
|
||
for result in latestResults.values {
|
||
switch result.state {
|
||
case .imported:
|
||
progress.completed += 1
|
||
progress.imported += 1
|
||
case .skipped:
|
||
progress.completed += 1
|
||
progress.skipped += 1
|
||
case .failed:
|
||
progress.failed += 1
|
||
case .cancelled:
|
||
progress.cancelled += 1
|
||
}
|
||
}
|
||
fastTransferProgress = progress
|
||
}
|
||
|
||
/// Remove imported/skipped results whose target files no longer exist on
|
||
/// the cloud. Runs once after session restore to keep the queue accurate
|
||
/// without blocking the UI.
|
||
private func cleanupDeletedFiles() async {
|
||
guard !api.accessToken.isEmpty else { return }
|
||
let resultsToCheck = await MainActor.run { fastTransferResults.filter { $0.state == .imported || $0.state == .skipped } }
|
||
var deletedIDs = Set<UUID>()
|
||
for result in resultsToCheck {
|
||
guard let targetID = result.targetID else { continue }
|
||
do {
|
||
let detail = try await api.fsDetail(fileID: targetID)
|
||
if let code = detail.firstIntDeep(["code"]), code != 0 {
|
||
deletedIDs.insert(result.entry.id)
|
||
}
|
||
} catch {
|
||
deletedIDs.insert(result.entry.id)
|
||
}
|
||
try? await Task.sleep(for: .milliseconds(150))
|
||
}
|
||
guard !deletedIDs.isEmpty else { return }
|
||
await MainActor.run {
|
||
let idsToRemove = deletedIDs
|
||
fastTransferResults.removeAll { idsToRemove.contains($0.entry.id) }
|
||
fastTransferEntries.removeAll { idsToRemove.contains($0.id) }
|
||
let latestResults = fastTransferResults.reduce(into: [UUID: FastTransferResult]()) { $0[$1.entry.id] = $1 }
|
||
var progress = FastTransferProgress(total: fastTransferEntries.count)
|
||
for result in latestResults.values {
|
||
switch result.state {
|
||
case .imported: progress.completed += 1; progress.imported += 1
|
||
case .skipped: progress.completed += 1; progress.skipped += 1
|
||
case .failed: progress.failed += 1
|
||
case .cancelled: progress.cancelled += 1
|
||
}
|
||
}
|
||
fastTransferProgress = progress
|
||
}
|
||
}
|
||
|
||
private nonisolated static func prepareAndPerformFastTransfer(
|
||
_ entry: FastTransferEntry,
|
||
parentID: String?,
|
||
createDirectories: Bool,
|
||
skipExisting: Bool,
|
||
retryOf: UUID?,
|
||
reservations: FastTransferNameReservation,
|
||
api: GuangyaAPI
|
||
) async -> FastTransferResult {
|
||
var details: [String] = []
|
||
do {
|
||
var targetID = parentID
|
||
let components = entry.directoryPath
|
||
.split(separator: "/")
|
||
.map(String.init)
|
||
.filter { !$0.isEmpty && $0 != "." }
|
||
if !components.isEmpty {
|
||
guard createDirectories else {
|
||
throw FastTransferError.invalidJSON("\(entry.path) 包含目录,请开启自动创建多级目录")
|
||
}
|
||
var dirCache: [String: String] = [:]
|
||
var currentID = parentID
|
||
for component in components {
|
||
guard component != ".." else { throw FastTransferError.invalidJSON("目录不能包含 ..") }
|
||
let cacheKey = "\(currentID ?? "__root__")/\(component)"
|
||
if let cached = dirCache[cacheKey] {
|
||
currentID = cached
|
||
continue
|
||
}
|
||
let children = try await Self.fastTransferListChildren(parentID: currentID, api: api)
|
||
if let existing = children.first(where: { $0.name == component && $0.isDirectory }) {
|
||
currentID = existing.id
|
||
} else if children.contains(where: { $0.name == component }) {
|
||
throw FastTransferError.invalidJSON("\(component) 已被同名文件占用")
|
||
} else {
|
||
let created = try await api.fsCreateDir(name: component, parentID: currentID)
|
||
if let id = created.firstStringDeep(["fileId", "file_id", "id", "resId"]) {
|
||
currentID = id
|
||
} else if let refreshed = try await Self.fastTransferListChildren(parentID: currentID, api: api).first(where: { $0.name == component && $0.isDirectory }) {
|
||
currentID = refreshed.id
|
||
} else {
|
||
throw GuangyaAPIError.missingField("目录 \(component) ID")
|
||
}
|
||
}
|
||
if let currentID { dirCache[cacheKey] = currentID }
|
||
}
|
||
targetID = currentID
|
||
}
|
||
if skipExisting {
|
||
let siblings = try await Self.fastTransferListChildren(parentID: targetID, api: api)
|
||
if siblings.contains(where: { $0.name == entry.name && !$0.isDirectory }) {
|
||
details.append("目标位置已存在同名文件")
|
||
return FastTransferResult(entry: entry, state: .skipped, message: "目标位置已存在同名文件", taskID: nil, targetID: targetID, details: details, retryOf: retryOf)
|
||
}
|
||
}
|
||
let reservationKey = "\(targetID ?? "__root__")/\(entry.name)"
|
||
guard await reservations.reserve(reservationKey) else {
|
||
details.append("同名文件正在并发处理中,跳过")
|
||
return FastTransferResult(entry: entry, state: .skipped, message: "同名文件正在并发处理中", taskID: nil, targetID: targetID, details: details, retryOf: retryOf)
|
||
}
|
||
let prepared = PreparedFastTransferEntry(index: 0, entry: entry, targetID: targetID, details: details, retryOf: retryOf)
|
||
return await Self.performPreparedFastTransfer(prepared, api: api)
|
||
} catch {
|
||
details.append("准备阶段失败:\(error.localizedDescription)")
|
||
return FastTransferResult(entry: entry, state: .failed, message: error.localizedDescription, taskID: nil, targetID: nil, details: details, retryOf: retryOf)
|
||
}
|
||
}
|
||
|
||
private nonisolated static func fastTransferListChildren(parentID: String?, api: GuangyaAPI) async throws -> [CloudFile] {
|
||
var page = 0
|
||
var values: [CloudFile] = []
|
||
while true {
|
||
let response = try await api.fsFiles(parentID: parentID, page: page, pageSize: 1_000)
|
||
let batch = await Task.detached(priority: .utility) {
|
||
Self.extractFastTransferFiles(from: response)
|
||
}.value
|
||
values += batch
|
||
let total = response.firstIntDeep(["total", "totalCount", "count"])
|
||
if batch.count < 1_000 || (total != nil && values.count >= total!) { break }
|
||
page += 1
|
||
}
|
||
return await Task.detached(priority: .utility) {
|
||
Array(Dictionary(grouping: values, by: \.id).compactMap { $0.value.first })
|
||
}.value
|
||
}
|
||
|
||
private nonisolated static func performPreparedFastTransfer(_ prepared: PreparedFastTransferEntry, api: GuangyaAPI) async -> FastTransferResult {
|
||
var details = prepared.details
|
||
var taskID: String?
|
||
do {
|
||
if let etag = prepared.entry.etag, !etag.isEmpty {
|
||
details.append("正在按 MD5 秒传协议校验资源")
|
||
let token = try await api.flashTransferToken(name: prepared.entry.name, fileSize: prepared.entry.size, parentID: prepared.targetID, md5: etag)
|
||
if token.firstIntDeep(["code"]) == 156 {
|
||
details.append("MD5 \(etag) 已命中云端资源")
|
||
return FastTransferResult(entry: prepared.entry, state: .imported, message: "秒传成功", taskID: nil, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf)
|
||
}
|
||
if let unresolvedTaskID = token["data"]?.firstString(["taskId", "task_id"]) {
|
||
taskID = unresolvedTaskID
|
||
try? await api.deleteUploadTask(taskIDs: [unresolvedTaskID])
|
||
details.append("MD5 未命中云端资源,已清理预检任务")
|
||
}
|
||
throw FastTransferError.missingRemoteResource(prepared.entry.path)
|
||
} else {
|
||
guard let gcid = prepared.entry.gcid else { throw FastTransferError.unsupportedHash(prepared.entry.path) }
|
||
details.append("正在按 GCID 秒传协议校验资源")
|
||
let token = try await api.flashTransferGCIDToken(name: prepared.entry.name, fileSize: prepared.entry.size, parentID: prepared.targetID, gcid: gcid)
|
||
if token.firstIntDeep(["code"]) == 156 {
|
||
details.append("GCID \(gcid) 已命中云端资源")
|
||
return FastTransferResult(entry: prepared.entry, state: .imported, message: "秒传成功", taskID: nil, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf)
|
||
}
|
||
if let unresolvedTaskID = token["data"]?.firstString(["taskId", "task_id"]) {
|
||
taskID = unresolvedTaskID
|
||
try? await api.deleteUploadTask(taskIDs: [unresolvedTaskID])
|
||
details.append("GCID 未命中云端资源,已清理预检任务")
|
||
}
|
||
throw FastTransferError.missingRemoteResource(prepared.entry.path)
|
||
}
|
||
} catch is CancellationError {
|
||
details.append("任务已终止")
|
||
return FastTransferResult(entry: prepared.entry, state: .cancelled, message: "任务已终止", taskID: taskID, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf)
|
||
} catch {
|
||
if Task.isCancelled || (error as? URLError)?.code == .cancelled {
|
||
details.append("任务已终止")
|
||
return FastTransferResult(entry: prepared.entry, state: .cancelled, message: "任务已终止", taskID: taskID, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf)
|
||
}
|
||
details.append("失败:\(error.localizedDescription)")
|
||
return FastTransferResult(entry: prepared.entry, state: .failed, message: error.localizedDescription, taskID: taskID, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf)
|
||
}
|
||
}
|
||
|
||
private nonisolated static func waitForFastTransferCompletion(taskID: String, api: GuangyaAPI) async throws {
|
||
for _ in 0..<30 {
|
||
try Task.checkCancellation()
|
||
let result = try await api.uploadInfo(taskID: taskID)
|
||
if let data = result["data"], data != .null { return }
|
||
try await Task.sleep(for: .seconds(1))
|
||
}
|
||
throw GuangyaAPIError.http(status: 0, message: "云端秒传任务确认超时")
|
||
}
|
||
|
||
func open(_ file: CloudFile) async {
|
||
if file.isDirectory { await openFolder(file) } else { await preview(file) }
|
||
}
|
||
|
||
func remoteURL(for file: CloudFile) async throws -> URL {
|
||
let detail = try await api.fsDetail(fileID: file.id)
|
||
|
||
// Images expose their signed preview directly in picInfo.previewUrl.
|
||
if file.fileType == 1,
|
||
let value = detail.firstStringDeep(["previewUrl", "previewURL", "originalUrl", "originalURL", "thumbnail"]),
|
||
let url = URL(string: value) { return url }
|
||
|
||
// The web player does not use the generic download endpoint for videos.
|
||
// It selects a videoResource GCID then asks get_vod_download_url for the
|
||
// signed media address, which AVPlayer can consume directly.
|
||
if file.isVideo,
|
||
let resources = detail.firstArrayDeep(["videoResource"]),
|
||
let defaultResource = resources.first(where: { $0["info"]?["defaultResolution"]?.boolValue == true }) ?? resources.first,
|
||
let gcid = defaultResource.firstStringDeep(["gcid"]),
|
||
let result = try? await api.vodDownloadURL(fileID: file.id, gcid: gcid),
|
||
let value = result.firstStringDeep(["signedURL", "signedUrl", "url", "downloadUrl", "download_url", "dlink"]),
|
||
let url = URL(string: value) { return url }
|
||
|
||
// Documents and unsupported media fall back to their signed direct link.
|
||
let result = try await api.downloadURL(fileID: file.id)
|
||
guard let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]),
|
||
let url = URL(string: value) else { throw GuangyaAPIError.missingField("可预览地址或直链") }
|
||
return url
|
||
}
|
||
|
||
func preview(_ file: CloudFile) async {
|
||
await perform("正在准备预览…") { [self] in
|
||
NSWorkspace.shared.open(try await remoteURL(for: file))
|
||
}
|
||
}
|
||
|
||
func download(_ file: CloudFile) async {
|
||
await perform("正在准备下载…") { [self] in
|
||
NSWorkspace.shared.open(try await remoteURL(for: file))
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// MARK: - External Player Support
|
||
|
||
/// Supported external players: (display name, bundle identifier)
|
||
static let supportedPlayers: [(name: String, bundleID: String)] = [
|
||
("IINA", "com.colliderli.iina"),
|
||
("VLC", "org.videolan.vlc"),
|
||
("Infuse", "com.firecore.Infuse"),
|
||
("nPlayer", "com.nplayer.nplayer"),
|
||
("Movist Pro", "com.movist.MovistPro"),
|
||
("VidHub", "com.mac.utility.media.hub"),
|
||
("Forward", "flux.inchmade.app"),
|
||
("SenPlayer", "com.wuziqi.SenPlayer"),
|
||
("PotPlayer", "com.kakao.PotPlayer"),
|
||
("mpv", "io.mpv"),
|
||
]
|
||
|
||
/// Returns the list of players actually installed on this Mac.
|
||
static var installedPlayers: [(name: String, bundleID: String)] {
|
||
supportedPlayers.filter { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0.bundleID) != nil }
|
||
}
|
||
|
||
/// Open a file with a specific named player (must be in supportedPlayers).
|
||
func openFile(_ file: CloudFile, withPlayerNamed playerName: String) async {
|
||
await perform("正在使用 \(playerName) 播放…") { [self] in
|
||
guard let player = Self.supportedPlayers.first(where: { $0.name == playerName }),
|
||
let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: player.bundleID) else {
|
||
lastActionMessage = "未找到播放器 \(playerName)"
|
||
return
|
||
}
|
||
let url = try await remoteURL(for: file)
|
||
try await NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: .init())
|
||
}
|
||
}
|
||
|
||
/// Play with the first available external player (auto-detect).
|
||
func playWithExternalPlayer(_ file: CloudFile) async {
|
||
await perform("正在交给外部播放器…") { [self] in
|
||
let url = try await remoteURL(for: file)
|
||
for p in Self.supportedPlayers {
|
||
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: p.bundleID) {
|
||
try await NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: .init())
|
||
return
|
||
}
|
||
}
|
||
NSWorkspace.shared.open(url)
|
||
}
|
||
}
|
||
|
||
func rename(_ file: CloudFile, to name: String) async {
|
||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty, trimmed != file.name else { return }
|
||
await perform("正在重命名…") { [self] in _ = try await api.fsRename(fileID: file.id, newName: trimmed); await loadFiles() }
|
||
}
|
||
|
||
func batchRenameCandidates(parentID: String?, rootName: String, recursive: Bool) async throws -> [CloudFile] {
|
||
var queue: [(id: String?, path: [String])] = [(parentID, parentID == nil ? [] : [rootName])]
|
||
var result: [CloudFile] = []
|
||
while !queue.isEmpty {
|
||
try Task.checkCancellation()
|
||
let location = queue.removeFirst()
|
||
var children = try await allFiles(parentID: location.id)
|
||
for index in children.indices {
|
||
children[index].cloudPath = "/" + (location.path + [children[index].name]).joined(separator: "/")
|
||
}
|
||
result += children
|
||
if recursive {
|
||
queue += children.filter(\.isDirectory).map { ($0.id, location.path + [$0.name]) }
|
||
}
|
||
}
|
||
return Array(Dictionary(grouping: result, by: \.id).compactMap { $0.value.first })
|
||
.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }
|
||
}
|
||
|
||
func applyBatchRenames(_ changes: [BatchRenameChange]) async -> BatchRenameExecutionResult {
|
||
guard !changes.isEmpty, !isBatchRenaming else { return BatchRenameExecutionResult(succeeded: 0, failed: 0) }
|
||
isBatchRenaming = true
|
||
batchRenameProgress = BatchRenameProgress(completed: 0, total: changes.count)
|
||
defer { isBatchRenaming = false }
|
||
var succeeded = 0
|
||
var failures: [String] = []
|
||
for change in changes {
|
||
batchRenameProgress.currentName = change.file.name
|
||
do {
|
||
_ = try await api.fsRename(fileID: change.file.id, newName: change.newName)
|
||
succeeded += 1
|
||
} catch {
|
||
failures.append("\(change.file.name):\(error.localizedDescription)")
|
||
if isAuthorizationExpiry(error) {
|
||
handleAuthorizationExpiry()
|
||
break
|
||
}
|
||
}
|
||
batchRenameProgress.completed += 1
|
||
}
|
||
if isSignedIn { await loadFiles(force: true) }
|
||
let failed = changes.count - succeeded
|
||
lastActionMessage = failures.isEmpty
|
||
? "已完成 \(succeeded) 项重命名"
|
||
: "已完成 \(succeeded) 项,失败 \(failed) 项\n" + failures.prefix(5).joined(separator: "\n")
|
||
return BatchRenameExecutionResult(succeeded: succeeded, failed: failed)
|
||
}
|
||
|
||
func loadMediaLibraries() async {
|
||
if !didAttemptCloudMediaLibraryRestore, await mediaLibraryStore.isEmpty() {
|
||
didAttemptCloudMediaLibraryRestore = true
|
||
_ = try? await restoreMediaLibraryBackupFromCloud(reportStatus: false)
|
||
}
|
||
mediaLibraries = await mediaLibraryStore.libraries()
|
||
await refreshMediaLibraryStatistics()
|
||
}
|
||
|
||
func refreshMediaLibraryStatistics() async {
|
||
mediaLibraryStatistics = await mediaLibraryStore.statistics()
|
||
mediaLibraryStatisticsByLibraryID = await mediaLibraryStore.statisticsByLibraryID()
|
||
}
|
||
|
||
func createMediaLibrary(name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50) async -> MediaLibraryDefinition {
|
||
await createMediaLibrary(name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB)
|
||
}
|
||
|
||
func createMediaLibrary(name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50) async -> MediaLibraryDefinition {
|
||
let library = MediaLibraryDefinition(id: UUID().uuidString, name: name.trimmingCharacters(in: .whitespacesAndNewlines), sources: sources, kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB, updatedAt: nil)
|
||
await mediaLibraryStore.save(library: library)
|
||
await loadMediaLibraries()
|
||
return library
|
||
}
|
||
|
||
func deleteMediaLibrary(_ library: MediaLibraryDefinition) async {
|
||
await mediaLibraryStore.delete(libraryID: library.id)
|
||
await loadMediaLibraries()
|
||
}
|
||
|
||
func updateMediaLibrary(_ library: MediaLibraryDefinition) async {
|
||
await mediaLibraryStore.save(library: library)
|
||
await loadMediaLibraries()
|
||
}
|
||
|
||
func saveMediaCategoryRules(_ rules: [MediaCategoryRule]) {
|
||
mediaCategoryRules = rules
|
||
MediaCategoryRule.save(rules)
|
||
lastActionMessage = "影视分类规则已保存"
|
||
}
|
||
|
||
func resetMediaCategoryRules() {
|
||
saveMediaCategoryRules(MediaCategoryRule.defaults)
|
||
lastActionMessage = "已恢复默认影视分类"
|
||
}
|
||
|
||
func cachedMediaLibraryItems(libraryID: String) async -> [MediaLibraryItem] {
|
||
await mediaLibraryStore.items(libraryID: libraryID)
|
||
}
|
||
|
||
func cachedMediaLibraryPage(libraryID: String?, limit: Int = 72, offset: Int = 0) async -> [MediaLibraryItem] {
|
||
await mediaLibraryStore.itemPage(libraryID: libraryID, limit: limit, offset: offset)
|
||
}
|
||
|
||
func removeLegacyIgnoredMediaItemsIfNeeded() async -> Int {
|
||
let key = "guangya.zeroPaddedMediaCleanupV1"
|
||
guard !UserDefaults.standard.bool(forKey: key) else { return 0 }
|
||
let removed = await mediaLibraryStore.removeZeroPaddedMediaEntries()
|
||
UserDefaults.standard.set(true, forKey: key)
|
||
guard !removed.isEmpty else { return 0 }
|
||
let IDsByLibrary = Dictionary(grouping: removed, by: \.libraryID).mapValues { Set($0.map(\.fileID)) }
|
||
for (libraryID, ids) in IDsByLibrary {
|
||
mediaLibraryLiveItemsByLibraryID[libraryID]?.removeAll { ids.contains($0.id) }
|
||
}
|
||
await refreshMediaLibraryStatistics()
|
||
return removed.count
|
||
}
|
||
|
||
func cachedAllMediaLibraryItems() async -> [MediaLibraryItem] {
|
||
var items: [MediaLibraryItem] = []
|
||
for library in mediaLibraries {
|
||
items += await mediaLibraryStore.items(libraryID: library.id)
|
||
}
|
||
return Array(Dictionary(grouping: items, by: \.id).compactMap { $0.value.first })
|
||
}
|
||
|
||
func exportMediaLibraryBackup(to url: URL) async throws {
|
||
try await mediaLibraryStore.exportBackup(to: url)
|
||
}
|
||
|
||
func importMediaLibraryBackup(from url: URL) async throws {
|
||
try await mediaLibraryStore.importBackup(from: url)
|
||
mediaLibraryLiveItemsByLibraryID = [:]
|
||
await loadMediaLibraries()
|
||
}
|
||
|
||
func backupMediaLibraryToCloud() async {
|
||
guard !isMediaLibraryBackupRunning else { return }
|
||
isMediaLibraryBackupRunning = true
|
||
mediaLibraryBackupStatus = "正在导出影视缓存…"
|
||
defer { isMediaLibraryBackupRunning = false }
|
||
do {
|
||
let temporaryURL = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("guangya-media-library-\(UUID().uuidString).sqlite3")
|
||
defer { try? FileManager.default.removeItem(at: temporaryURL) }
|
||
try await mediaLibraryStore.exportBackup(to: temporaryURL)
|
||
mediaLibraryBackupStatus = "正在上传云盘备份…"
|
||
let folderID = try await mediaLibraryBackupFolderID(createIfMissing: true)
|
||
let existing = extractFiles(from: try await api.fsFiles(parentID: folderID, page: 0, pageSize: 1_000))
|
||
.filter { !$0.isDirectory && $0.name == Self.mediaLibraryBackupFilename }
|
||
if !existing.isEmpty { _ = try await api.fsDelete(fileIDs: existing.map(\.id)) }
|
||
_ = try await api.fileUpload(url: temporaryURL, parentID: folderID, contentType: "application/octet-stream")
|
||
mediaLibraryBackupStatus = "云盘备份已更新"
|
||
} catch {
|
||
mediaLibraryBackupStatus = "云盘备份失败:\(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
func restoreMediaLibraryBackupFromCloud() async {
|
||
guard !isMediaLibraryBackupRunning else { return }
|
||
isMediaLibraryBackupRunning = true
|
||
defer { isMediaLibraryBackupRunning = false }
|
||
do {
|
||
_ = try await restoreMediaLibraryBackupFromCloud(reportStatus: true)
|
||
mediaLibraryLiveItemsByLibraryID = [:]
|
||
await loadMediaLibraries()
|
||
} catch {
|
||
mediaLibraryBackupStatus = "云盘恢复失败:\(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private static let mediaLibraryBackupFolderName = "光鸭影视备份"
|
||
private static let mediaLibraryBackupFilename = "media-library.sqlite3"
|
||
|
||
@discardableResult
|
||
private func restoreMediaLibraryBackupFromCloud(reportStatus: Bool) async throws -> Bool {
|
||
if reportStatus { mediaLibraryBackupStatus = "正在查找云盘备份…" }
|
||
guard let folderID = try await mediaLibraryBackupFolderID(createIfMissing: false) else {
|
||
if reportStatus { mediaLibraryBackupStatus = "云盘没有影视备份" }
|
||
return false
|
||
}
|
||
let backups = extractFiles(from: try await api.fsFiles(parentID: folderID, page: 0, pageSize: 1_000))
|
||
.filter { $0.name == Self.mediaLibraryBackupFilename && !$0.isDirectory }
|
||
guard let backup = backups.first else {
|
||
if reportStatus { mediaLibraryBackupStatus = "云盘没有影视备份" }
|
||
return false
|
||
}
|
||
if reportStatus { mediaLibraryBackupStatus = "正在下载云盘备份…" }
|
||
let response = try await api.downloadURL(fileID: backup.id)
|
||
guard let value = response.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]),
|
||
let remoteURL = URL(string: value) else { throw GuangyaAPIError.missingField("影视备份下载地址") }
|
||
let (data, _) = try await URLSession.shared.data(from: remoteURL)
|
||
let temporaryURL = FileManager.default.temporaryDirectory
|
||
.appendingPathComponent("guangya-media-library-restore-\(UUID().uuidString).sqlite3")
|
||
defer { try? FileManager.default.removeItem(at: temporaryURL) }
|
||
try data.write(to: temporaryURL, options: Data.WritingOptions.atomic)
|
||
try await mediaLibraryStore.importBackup(from: temporaryURL)
|
||
if reportStatus { mediaLibraryBackupStatus = "已从云盘恢复影视缓存" }
|
||
return true
|
||
}
|
||
|
||
private func mediaLibraryBackupFolderID(createIfMissing: Bool) async throws -> String? {
|
||
let rootItems = extractFiles(from: try await api.fsFiles(parentID: nil, page: 0, pageSize: 1_000))
|
||
if let folder = rootItems.first(where: { $0.isDirectory && $0.name == Self.mediaLibraryBackupFolderName }) { return folder.id }
|
||
guard createIfMissing else { return nil }
|
||
let created = try await api.fsCreateDir(name: Self.mediaLibraryBackupFolderName, parentID: nil)
|
||
if let id = created.firstStringDeep(["fileId", "file_id", "id", "resId"]) { return id }
|
||
let refreshed = extractFiles(from: try await api.fsFiles(parentID: nil, page: 0, pageSize: 1_000))
|
||
return refreshed.first(where: { $0.isDirectory && $0.name == Self.mediaLibraryBackupFolderName })?.id
|
||
}
|
||
|
||
func manualMediaCandidates(query: String, mediaKind: TMDBMediaKind = .automatic, year: Int? = nil) async throws -> [TMDBCandidate] {
|
||
let title = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !title.isEmpty else { return [] }
|
||
guard !tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
throw GuangyaAPIError.missingField("TMDB API Key")
|
||
}
|
||
let response = try await api.tmdbSearch(query: title, apiKey: tmdbAPIKey, mediaKind: mediaKind, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort, year: year)
|
||
var seen = Set<String>()
|
||
return (response["results"]?.arrayValue ?? []).compactMap { TMDBCandidate(json: $0) }
|
||
.filter { seen.insert("\($0.mediaType.rawValue)-\($0.id)").inserted }
|
||
.sorted { left, right in
|
||
let expectedYear = year.map(String.init)
|
||
let leftMatches = String(left.releaseDate.prefix(4)) == expectedYear
|
||
let rightMatches = String(right.releaseDate.prefix(4)) == expectedYear
|
||
return leftMatches && !rightMatches
|
||
}
|
||
}
|
||
|
||
func mediaArtworkAssets(for item: MediaLibraryItem) async -> [MediaArtworkAsset] {
|
||
guard let tmdbID = item.tmdbID,
|
||
let mediaKind = item.mediaKind,
|
||
!tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return [] }
|
||
let cacheKey = "\(mediaKind.rawValue)-\(tmdbID)"
|
||
if let cached = mediaArtworkAssetCache[cacheKey] { return cached }
|
||
do {
|
||
let details = try await api.tmdbDetails(id: tmdbID, mediaKind: mediaKind, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||
func paths(in collection: String, limit: Int) -> [String] {
|
||
let images = details["images"]?[collection]?.arrayValue ?? []
|
||
let languageRank: [String?: Int] = ["zh": 0, "zh-CN": 0, nil: 1, "en": 2]
|
||
let ordered = images.sorted {
|
||
(languageRank[$0.firstString(["iso_639_1"])] ?? 3) < (languageRank[$1.firstString(["iso_639_1"])] ?? 3)
|
||
}
|
||
var unique: [String] = []
|
||
for image in ordered {
|
||
guard let path = image.firstString(["file_path"]), !unique.contains(path) else { continue }
|
||
unique.append(path)
|
||
}
|
||
return Array(unique.prefix(limit))
|
||
}
|
||
var requests: [(path: String, size: String, label: String)] = []
|
||
let posters = paths(in: "posters", limit: 6)
|
||
if let originalPoster = posters.first { requests.append((originalPoster, "original", "原稿海报")) }
|
||
requests += posters.dropFirst().map { ($0, "w500", "海报") }
|
||
requests += paths(in: "backdrops", limit: 8).map { ($0, "w1280", "横幅 / 同人画") }
|
||
requests += paths(in: "logos", limit: 4).map { ($0, "w500", "Logo") }
|
||
let assets = await withTaskGroup(of: MediaArtworkAsset?.self, returning: [MediaArtworkAsset].self) { group in
|
||
for request in requests {
|
||
group.addTask { [api, tmdbAPIKey, tmdbProxyHost, tmdbProxyPort] in
|
||
guard let data = try? await api.tmdbImage(path: request.path, size: request.size, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) else { return nil }
|
||
return MediaArtworkAsset(id: "\(request.label)-\(request.path)", label: request.label, data: data)
|
||
}
|
||
}
|
||
var values: [MediaArtworkAsset] = []
|
||
for await asset in group { if let asset { values.append(asset) } }
|
||
return values
|
||
}
|
||
await persistMediaArtworkAssets(assets, for: item)
|
||
mediaArtworkAssetCache[cacheKey] = assets
|
||
return assets
|
||
} catch { return [] }
|
||
}
|
||
|
||
func mediaCastMembers(for item: MediaLibraryItem) async -> [MediaCastMember] {
|
||
guard let tmdbID = item.tmdbID,
|
||
let mediaKind = item.mediaKind,
|
||
!tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return [] }
|
||
let mediaKey = item.file.gcid?.isEmpty == false ? item.file.gcid! : item.file.id
|
||
let cacheKey = "\(mediaKind.rawValue)-\(tmdbID)-\(mediaKey)"
|
||
if let cached = mediaCastCache[cacheKey] { return cached }
|
||
do {
|
||
let details = try await api.tmdbDetails(id: tmdbID, mediaKind: mediaKind, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||
let rootID = try await ensureMediaAssetFolder(named: "光鸭影视素材", parentID: nil)
|
||
let actorsID = try await ensureMediaAssetFolder(named: "演员", parentID: rootID)
|
||
let storageID = try await ensureMediaAssetFolder(named: safeCloudName(mediaKey), parentID: actorsID)
|
||
let existing = try await allFiles(parentID: storageID, pageSize: 1_000)
|
||
var members: [MediaCastMember] = []
|
||
for (index, person) in (details["credits"]?["cast"]?.arrayValue ?? []).prefix(12).enumerated() {
|
||
let personID = person.firstInt(["id"]) ?? index
|
||
let imageName = "\(personID).jpg"
|
||
let imageData: Data?
|
||
if let cachedImage = existing.first(where: { $0.name == imageName }) {
|
||
imageData = await Self.sidecarData(for: cachedImage, api: api)
|
||
} else if let imagePath = person.firstString(["profile_path"]), let downloaded = try? await api.tmdbImage(path: imagePath, size: "w500", apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) {
|
||
imageData = downloaded
|
||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("guangya-cast-\(UUID().uuidString)", isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
let url = directory.appendingPathComponent(imageName)
|
||
try? downloaded.write(to: url, options: .atomic)
|
||
if FileManager.default.fileExists(atPath: url.path) { _ = try? await api.fileUpload(url: url, parentID: storageID, contentType: "image/jpeg") }
|
||
try? FileManager.default.removeItem(at: directory)
|
||
} else {
|
||
imageData = nil
|
||
}
|
||
members.append(MediaCastMember(id: String(personID), name: person.firstString(["name"]) ?? "演员", role: person.firstString(["character"]) ?? "", imageData: imageData))
|
||
}
|
||
mediaCastCache[cacheKey] = members
|
||
return members
|
||
} catch { return [] }
|
||
}
|
||
|
||
private func ensureMediaAssetFolder(named name: String, parentID: String?) async throws -> String {
|
||
let response = try await api.fsFiles(parentID: parentID, page: 0, pageSize: 1_000)
|
||
if let existing = extractFiles(from: response).first(where: { $0.isDirectory && $0.name == name }) { return existing.id }
|
||
let created = try await api.fsCreateDir(name: name, parentID: parentID)
|
||
if let id = created.firstStringDeep(["fileId", "file_id", "id", "resId"]) { return id }
|
||
let refreshed = try await api.fsFiles(parentID: parentID, page: 0, pageSize: 1_000)
|
||
guard let folder = extractFiles(from: refreshed).first(where: { $0.isDirectory && $0.name == name }) else {
|
||
throw GuangyaAPIError.missingField("素材目录 \(name)")
|
||
}
|
||
return folder.id
|
||
}
|
||
|
||
private func persistMediaArtworkAssets(_ assets: [MediaArtworkAsset], for item: MediaLibraryItem) async {
|
||
guard !assets.isEmpty else { return }
|
||
let mediaKey = item.file.gcid?.isEmpty == false ? item.file.gcid! : item.file.id
|
||
do {
|
||
let rootID = try await ensureMediaAssetFolder(named: "光鸭影视素材", parentID: nil)
|
||
let imagesID = try await ensureMediaAssetFolder(named: "图片", parentID: rootID)
|
||
let storageID = try await ensureMediaAssetFolder(named: safeCloudName(mediaKey), parentID: imagesID)
|
||
let existing = try await allFiles(parentID: storageID, pageSize: 1_000)
|
||
for (index, asset) in assets.enumerated() {
|
||
let name = "\(String(format: "%02d", index + 1))-\(safeCloudName(asset.label)).jpg"
|
||
guard !existing.contains(where: { $0.name == name }) else { continue }
|
||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("guangya-artwork-\(UUID().uuidString)", isDirectory: true)
|
||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
defer { try? FileManager.default.removeItem(at: directory) }
|
||
let url = directory.appendingPathComponent(name)
|
||
try asset.data.write(to: url, options: .atomic)
|
||
_ = try await api.fileUpload(url: url, parentID: storageID, contentType: "image/jpeg")
|
||
}
|
||
} catch { }
|
||
}
|
||
|
||
func applyManualMediaCandidate(_ candidate: TMDBCandidate, to item: MediaLibraryItem, preferredLibraryID: String?) async throws -> MediaLibraryItem {
|
||
guard !tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
throw GuangyaAPIError.missingField("TMDB API Key")
|
||
}
|
||
let updated = try await resolvedManualMediaItem(candidate, replacing: item)
|
||
try await persistManuallyRecognizedMediaItem(updated, preferredLibraryID: preferredLibraryID)
|
||
await refreshMediaLibraryStatistics()
|
||
return updated
|
||
}
|
||
|
||
/// Re-identifies unrecognized resources in title groups. Episodes of one
|
||
/// series share one TMDB lookup and then receive the same scraped metadata.
|
||
func batchRecognizeUnmatchedMediaItems(_ items: [MediaLibraryItem], preferredLibraryID: String?) async -> (recognized: Int, failed: Int) {
|
||
guard !isBatchRecognizingMedia else { return (0, 0) }
|
||
let pending = items.filter { !$0.isMatched }
|
||
guard !pending.isEmpty else { return (0, 0) }
|
||
guard !tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
errorMessage = "请先配置 TMDB API Key"
|
||
return (0, pending.count)
|
||
}
|
||
isBatchRecognizingMedia = true
|
||
defer { isBatchRecognizingMedia = false }
|
||
let grouped = Dictionary(grouping: pending) { item -> String in
|
||
let parsed = parsedTMDBInfo(for: item)
|
||
let kind = parsed.isEpisode ? TMDBMediaKind.tv.rawValue : TMDBMediaKind.automatic.rawValue
|
||
return "\(kind):\(parsed.year.map(String.init) ?? ""):\(Self.normalizedTMDBLookupTitle(parsed.title))"
|
||
}
|
||
let groups = Array(grouped.values)
|
||
let concurrency = min(mediaScrapeConcurrency, groups.count)
|
||
var iterator = groups.makeIterator()
|
||
var recognized = 0
|
||
var failed = 0
|
||
await withTaskGroup(of: (recognized: Int, failed: Int).self) { group in
|
||
func addNext() {
|
||
guard let items = iterator.next() else { return }
|
||
group.addTask { [weak self] in
|
||
guard let self else { return (0, items.count) }
|
||
return await self.recognizeUnmatchedMediaGroup(items, preferredLibraryID: preferredLibraryID)
|
||
}
|
||
}
|
||
for _ in 0..<concurrency { addNext() }
|
||
while let result = await group.next() {
|
||
recognized += result.recognized
|
||
failed += result.failed
|
||
addNext()
|
||
}
|
||
}
|
||
await refreshMediaLibraryStatistics()
|
||
lastActionMessage = failed == 0 ? "批量识别完成:已识别 \(recognized) 项" : "批量识别完成:已识别 \(recognized) 项,失败 \(failed) 项"
|
||
return (recognized, failed)
|
||
}
|
||
|
||
private func recognizeUnmatchedMediaGroup(_ items: [MediaLibraryItem], preferredLibraryID: String?) async -> (recognized: Int, failed: Int) {
|
||
guard let seed = items.first else { return (0, 0) }
|
||
let parsed = parsedTMDBInfo(for: seed)
|
||
let preferredKind: TMDBMediaKind = parsed.isEpisode ? .tv : .automatic
|
||
do {
|
||
guard let candidate = try await Self.mediaTMDBCandidate(for: parsed, preferredKind: preferredKind, api: api, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) else {
|
||
return (0, items.count)
|
||
}
|
||
let template = try await resolvedManualMediaItem(candidate, replacing: seed)
|
||
var recognized = 0
|
||
for item in items {
|
||
let updated = MediaLibraryItem(file: item.file, tmdbID: template.tmdbID, title: template.title, originalTitle: template.originalTitle, mediaKind: template.mediaKind, releaseDate: template.releaseDate, overview: template.overview, posterData: template.posterData, backdropData: template.backdropData, hasChineseAudio: item.hasChineseAudio, hasChineseSubtitle: item.hasChineseSubtitle, collectionID: template.collectionID, collectionName: template.collectionName)
|
||
try await persistManuallyRecognizedMediaItem(updated, preferredLibraryID: preferredLibraryID)
|
||
recognized += 1
|
||
}
|
||
return (recognized, 0)
|
||
} catch {
|
||
return (0, items.count)
|
||
}
|
||
}
|
||
|
||
private func resolvedManualMediaItem(_ candidate: TMDBCandidate, replacing item: MediaLibraryItem) async throws -> MediaLibraryItem {
|
||
let details = try await api.tmdbDetails(id: candidate.id, mediaKind: candidate.mediaType, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||
let title = details.firstString(["title", "name"]) ?? candidate.title
|
||
let originalTitle = details.firstString(["original_title", "original_name"]) ?? candidate.originalTitle
|
||
let releaseDate = details.firstString(["release_date", "first_air_date"]) ?? candidate.releaseDate
|
||
let posterPath = Self.preferredArtworkPath(in: details, collection: "posters") ?? details.firstString(["poster_path"]) ?? candidate.posterPath
|
||
let backdropPath = Self.preferredArtworkPath(in: details, collection: "backdrops") ?? details.firstString(["backdrop_path"]) ?? candidate.backdropPath
|
||
let posterData: Data?
|
||
if let posterPath { posterData = try? await api.tmdbImage(path: posterPath, size: "w500", apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) }
|
||
else { posterData = nil }
|
||
let backdropData: Data?
|
||
if let backdropPath { backdropData = try? await api.tmdbImage(path: backdropPath, size: "w1280", apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) }
|
||
else { backdropData = nil }
|
||
let collection = details["belongs_to_collection"]
|
||
return MediaLibraryItem(file: item.file, tmdbID: candidate.id, title: title, originalTitle: originalTitle, mediaKind: candidate.mediaType, releaseDate: releaseDate, overview: details.firstString(["overview"]) ?? candidate.overview, posterData: posterData, backdropData: backdropData, hasChineseAudio: item.hasChineseAudio, hasChineseSubtitle: item.hasChineseSubtitle, collectionID: collection?.firstInt(["id"]), collectionName: collection?.firstString(["name"]))
|
||
}
|
||
|
||
private func persistManuallyRecognizedMediaItem(_ updated: MediaLibraryItem, preferredLibraryID: String?) async throws {
|
||
var targetIDs = Set(mediaLibraryLiveItemsByLibraryID.compactMap { libraryID, values in values.contains(where: { $0.id == updated.id }) ? libraryID : nil })
|
||
if let preferredLibraryID { targetIDs.insert(preferredLibraryID) }
|
||
if targetIDs.isEmpty {
|
||
for library in mediaLibraries where (await mediaLibraryStore.items(libraryID: library.id)).contains(where: { $0.id == updated.id }) {
|
||
targetIDs.insert(library.id)
|
||
}
|
||
}
|
||
guard !targetIDs.isEmpty else { throw MediaLibraryStoreError.writeFailed }
|
||
for libraryID in targetIDs {
|
||
guard let library = mediaLibraries.first(where: { $0.id == libraryID }), await mediaLibraryStore.upsertItems([updated], library: library) else { throw MediaLibraryStoreError.writeFailed }
|
||
var liveItems = mediaLibraryLiveItemsByLibraryID[libraryID] ?? []
|
||
if let index = liveItems.firstIndex(where: { $0.id == updated.id }) { liveItems[index] = updated }
|
||
else { liveItems.append(updated) }
|
||
mediaLibraryLiveItemsByLibraryID[libraryID] = liveItems.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||
if isMediaWritebackEnabled {
|
||
await writeManuallyRecognizedMediaItem(updated, library: library)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func writeManuallyRecognizedMediaItem(_ item: MediaLibraryItem, library: MediaLibraryDefinition) async {
|
||
let task = MediaLibraryTask(library: library)
|
||
mediaLibraryTasks.insert(task, at: 0)
|
||
prepareMediaLibraryTaskResources(task.id)
|
||
updateMediaLibraryTask(task.id, state: .writing, phase: "正在整理手动识别资源…", currentWriting: item.title, log: "手动识别成功,开始整理:\(item.title)")
|
||
setMediaLibraryTaskProgress(task.id, completed: 0, total: 1)
|
||
do {
|
||
let sidecars = try await mediaSidecarsForWriteback(item)
|
||
guard await writeMediaScrapeItem(item, files: sidecars, taskID: task.id) else {
|
||
throw MediaLibraryStoreError.writeFailed
|
||
}
|
||
markMediaLibraryFileWritten(task.id, fileID: item.id)
|
||
updateMediaLibraryTask(task.id, state: .completed, phase: "手动识别整理完成", currentWriting: nil, log: "已完成整理:\(item.title)")
|
||
} catch {
|
||
updateMediaLibraryTask(task.id, state: .failed, phase: "手动识别整理失败", currentWriting: nil, failure: error.localizedDescription, log: "整理失败:\(error.localizedDescription)")
|
||
}
|
||
}
|
||
|
||
private func mediaSidecarsForWriteback(_ item: MediaLibraryItem) async throws -> MediaSidecarFiles {
|
||
if item.file.isDirectory {
|
||
let children = try await allFiles(parentID: item.file.id)
|
||
if isDiscLayout(children) {
|
||
return mediaSnapshot(children: children, parentID: item.file.id, parentFolderName: item.file.name, discRoot: item.file).sidecars[item.id]
|
||
?? MediaSidecarFiles(parentID: item.file.id, mediaFolderName: item.file.name, nfo: nil, poster: nil, backdrop: nil, subtitles: [])
|
||
}
|
||
}
|
||
let detail = try await api.fsDetail(fileID: item.file.id)
|
||
let parentID = detail.firstIDDeep(["parentId", "parent_id", "parentFileId"])
|
||
let children = try await allFiles(parentID: parentID)
|
||
return mediaSnapshot(children: children, parentID: parentID, parentFolderName: nil).sidecars[item.id]
|
||
?? MediaSidecarFiles(parentID: parentID, mediaFolderName: nil, nfo: nil, poster: nil, backdrop: nil, subtitles: [])
|
||
}
|
||
|
||
@discardableResult
|
||
func startMediaLibraryScan(_ library: MediaLibraryDefinition) -> UUID {
|
||
let task = MediaLibraryTask(library: library)
|
||
mediaLibraryTasks.insert(task, at: 0)
|
||
persistMediaLibraryTaskHistory()
|
||
prepareMediaLibraryTaskResources(task.id)
|
||
launchMediaLibraryScan(library, taskID: task.id, phase: "正在读取云端目录…", log: "开始扫描云端目录")
|
||
return task.id
|
||
}
|
||
|
||
func resumeMediaLibraryTask(_ taskID: UUID) async {
|
||
guard let task = mediaLibraryTasks.first(where: { $0.id == taskID }),
|
||
[.cancelled, .failed].contains(task.state),
|
||
let library = mediaLibraries.first(where: { $0.id == task.libraryID }) else { return }
|
||
if let scanTask = mediaLibraryScanTasks[taskID] {
|
||
scanTask.cancel()
|
||
await scanTask.value
|
||
}
|
||
if let writeTask = mediaMetadataWriteTasks[taskID] {
|
||
writeTask.cancel()
|
||
await writeTask.value
|
||
}
|
||
await seedMediaLibraryTaskCheckpointIfNeeded(taskID, libraryID: library.id)
|
||
prepareMediaLibraryTaskResources(taskID)
|
||
resetMediaLibraryTaskForResume(taskID)
|
||
resumedMediaLibraryTaskIDs.insert(taskID)
|
||
let checkpoint = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint ?? .init()
|
||
launchMediaLibraryScan(library, taskID: taskID, phase: "正在继续扫描 \(checkpoint.completedFileIDs.count)/\(checkpoint.discoveredFileIDs.count)…", log: "继续任务:将从 \(checkpoint.completedFileIDs.count) 个已完成文件之后继续")
|
||
}
|
||
|
||
private func prepareMediaLibraryTaskResources(_ taskID: UUID) {
|
||
mediaMetadataWriteQueues[taskID] = []
|
||
mediaMetadataWrittenKeys[taskID] = []
|
||
normalizedMediaFolderIDs[taskID] = []
|
||
organizedMediaFolderIDs[taskID] = []
|
||
mediaCategoryFolderIDs[taskID] = [:]
|
||
mediaMetadataWriteFailures[taskID] = 0
|
||
}
|
||
|
||
private func resetMediaLibraryTaskForResume(_ taskID: UUID) {
|
||
guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||
let checkpoint = mediaLibraryTasks[index].checkpoint ?? .init()
|
||
mediaLibraryTasks[index].state = .queued
|
||
mediaLibraryTasks[index].progress.completed = checkpoint.completedFileIDs.count
|
||
mediaLibraryTasks[index].progress.total = checkpoint.discoveredFileIDs.count
|
||
mediaLibraryTasks[index].progress.phase = "准备继续任务(\(checkpoint.completedFileIDs.count)/\(checkpoint.discoveredFileIDs.count))"
|
||
mediaLibraryTasks[index].currentScrapingName = nil
|
||
mediaLibraryTasks[index].currentStoringName = nil
|
||
mediaLibraryTasks[index].currentWritingName = nil
|
||
mediaLibraryTasks[index].failureReason = nil
|
||
mediaLibraryTasks[index].updatedAt = Date()
|
||
persistMediaLibraryTaskHistory()
|
||
}
|
||
|
||
private func seedMediaLibraryTaskCheckpointIfNeeded(_ taskID: UUID, libraryID: String) async {
|
||
guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||
if let checkpoint = mediaLibraryTasks[index].checkpoint, !checkpoint.discoveredFileIDs.isEmpty { return }
|
||
let cachedIDs = Set((await mediaLibraryStore.items(libraryID: libraryID)).map(\.id))
|
||
mediaLibraryTasks[index].checkpoint = MediaLibraryTaskCheckpoint(discoveredFileIDs: cachedIDs, completedFileIDs: cachedIDs)
|
||
mediaLibraryTasks[index].progress.completed = cachedIDs.count
|
||
mediaLibraryTasks[index].progress.total = cachedIDs.count
|
||
persistMediaLibraryTaskHistory()
|
||
}
|
||
|
||
private func registerMediaLibraryDiscoveredFiles(_ taskID: UUID, files: [CloudFile]) {
|
||
guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||
var checkpoint = mediaLibraryTasks[index].checkpoint ?? .init()
|
||
checkpoint.discoveredFileIDs.formUnion(files.map(\.id))
|
||
mediaLibraryTasks[index].checkpoint = checkpoint
|
||
mediaLibraryTasks[index].progress.total = checkpoint.discoveredFileIDs.count
|
||
mediaLibraryTasks[index].progress.completed = checkpoint.completedFileIDs.count
|
||
mediaLibraryTasks[index].updatedAt = Date()
|
||
persistMediaLibraryTaskHistory()
|
||
}
|
||
|
||
private func markMediaLibraryFileCompleted(_ taskID: UUID, fileID: String) {
|
||
markMediaLibraryFilesCompleted(taskID, fileIDs: [fileID])
|
||
}
|
||
|
||
/// Checkpoints are persisted with the task history. Updating one file at a
|
||
/// time turns a large scan into thousands of UserDefaults writes, so scraper
|
||
/// results commit their checkpoints in small batches.
|
||
private func markMediaLibraryFilesCompleted(_ taskID: UUID, fileIDs: [String]) {
|
||
guard !fileIDs.isEmpty else { return }
|
||
guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||
var checkpoint = mediaLibraryTasks[index].checkpoint ?? .init()
|
||
checkpoint.discoveredFileIDs.formUnion(fileIDs)
|
||
checkpoint.completedFileIDs.formUnion(fileIDs)
|
||
mediaLibraryTasks[index].checkpoint = checkpoint
|
||
mediaLibraryTasks[index].progress.total = checkpoint.discoveredFileIDs.count
|
||
mediaLibraryTasks[index].progress.completed = checkpoint.completedFileIDs.count
|
||
mediaLibraryTasks[index].updatedAt = Date()
|
||
persistMediaLibraryTaskHistory()
|
||
}
|
||
|
||
private func markMediaLibraryFileWritten(_ taskID: UUID, fileID: String) {
|
||
guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||
var checkpoint = mediaLibraryTasks[index].checkpoint ?? .init()
|
||
checkpoint.discoveredFileIDs.insert(fileID)
|
||
checkpoint.completedFileIDs.insert(fileID)
|
||
checkpoint.writtenFileIDs.insert(fileID)
|
||
mediaLibraryTasks[index].checkpoint = checkpoint
|
||
mediaLibraryTasks[index].updatedAt = Date()
|
||
persistMediaLibraryTaskHistory()
|
||
}
|
||
|
||
private func launchMediaLibraryScan(_ library: MediaLibraryDefinition, taskID: UUID, phase: String, log: String) {
|
||
mediaLibraryScanTasks[taskID] = Task { [weak self] in
|
||
guard let self else { return }
|
||
do {
|
||
_ = try await self.scanMediaLibrary(library, taskID: taskID)
|
||
self.finishMediaLibraryScan(taskID: taskID)
|
||
} catch is CancellationError {
|
||
self.updateMediaLibraryTask(taskID, state: .cancelled, phase: "扫描已停止", log: "用户停止了任务")
|
||
} catch {
|
||
self.updateMediaLibraryTask(taskID, state: .failed, phase: "扫描失败", failure: error.localizedDescription, log: "扫描失败:\(error.localizedDescription)")
|
||
}
|
||
self.resumedMediaLibraryTaskIDs.remove(taskID)
|
||
self.mediaLibraryScanTasks[taskID] = nil
|
||
}
|
||
updateMediaLibraryTask(taskID, state: .scanning, phase: phase, log: log)
|
||
}
|
||
|
||
func cancelMediaLibraryTask(_ taskID: UUID) {
|
||
mediaLibraryScanTasks[taskID]?.cancel()
|
||
mediaMetadataWriteTasks[taskID]?.cancel()
|
||
mediaMetadataWriteQueues[taskID] = []
|
||
updateMediaLibraryTask(taskID, state: .cancelled, phase: "任务已停止", log: "用户停止了任务")
|
||
}
|
||
|
||
func removeMediaLibraryTask(_ taskID: UUID) {
|
||
guard let task = mediaLibraryTasks.first(where: { $0.id == taskID }), !task.isActive else { return }
|
||
mediaLibraryTasks.removeAll { $0.id == taskID }
|
||
mediaLibraryScanTasks[taskID] = nil
|
||
mediaMetadataWriteTasks[taskID] = nil
|
||
mediaMetadataWriteQueues[taskID] = nil
|
||
mediaMetadataWrittenKeys[taskID] = nil
|
||
normalizedMediaFolderIDs[taskID] = nil
|
||
organizedMediaFolderIDs[taskID] = nil
|
||
mediaCategoryFolderIDs[taskID] = nil
|
||
mediaMetadataWriteFailures[taskID] = nil
|
||
persistMediaLibraryTaskHistory()
|
||
}
|
||
|
||
func clearFinishedMediaLibraryTasks() {
|
||
let finishedIDs = mediaLibraryTasks.filter { !$0.isActive }.map(\.id)
|
||
for taskID in finishedIDs { removeMediaLibraryTask(taskID) }
|
||
}
|
||
|
||
private func updateMediaLibraryTask(_ taskID: UUID, state: MediaLibraryTaskState? = nil, phase: String? = nil, currentScraping: String? = nil, currentStoring: String? = nil, currentWriting: String? = nil, failure: String? = nil, log: String? = nil) {
|
||
guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||
if let state { mediaLibraryTasks[index].state = state }
|
||
if let phase { mediaLibraryTasks[index].progress.phase = phase }
|
||
if let currentScraping { mediaLibraryTasks[index].currentScrapingName = currentScraping }
|
||
if let currentStoring { mediaLibraryTasks[index].currentStoringName = currentStoring }
|
||
if let currentWriting { mediaLibraryTasks[index].currentWritingName = currentWriting }
|
||
if let failure { mediaLibraryTasks[index].failureReason = failure }
|
||
if let log { mediaLibraryTasks[index].logs.append(MediaLibraryTaskLog(message: log)) }
|
||
if let state, [.completed, .cancelled, .failed].contains(state) {
|
||
mediaLibraryTasks[index].currentScrapingName = nil
|
||
mediaLibraryTasks[index].currentStoringName = nil
|
||
mediaLibraryTasks[index].currentWritingName = nil
|
||
}
|
||
mediaLibraryTasks[index].updatedAt = Date()
|
||
persistMediaLibraryTaskHistory()
|
||
}
|
||
|
||
private func setMediaLibraryTaskProgress(_ taskID: UUID, completed: Int? = nil, total: Int? = nil, phase: String? = nil) {
|
||
guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||
if let completed { mediaLibraryTasks[index].progress.completed = completed }
|
||
if let total { mediaLibraryTasks[index].progress.total = total }
|
||
if let phase { mediaLibraryTasks[index].progress.phase = phase }
|
||
mediaLibraryTasks[index].updatedAt = Date()
|
||
persistMediaLibraryTaskHistory()
|
||
}
|
||
|
||
private func persistMediaLibraryTaskHistory() {
|
||
let history = Array(mediaLibraryTasks.prefix(100))
|
||
guard let data = try? JSONEncoder().encode(history) else { return }
|
||
UserDefaults.standard.set(data, forKey: "guangya.mediaLibraryTaskHistory")
|
||
}
|
||
|
||
private func scanMediaLibrary(_ library: MediaLibraryDefinition, taskID: UUID) async throws -> [MediaLibraryItem] {
|
||
await mediaLibraryStore.save(library: library)
|
||
let existingItems = await mediaLibraryStore.items(libraryID: library.id)
|
||
let existingItemsByFileID = Dictionary(uniqueKeysWithValues: existingItems.map { ($0.id, $0) })
|
||
mediaLibraryLiveItemsByLibraryID[library.id] = existingItems
|
||
var itemsByFileID = existingItemsByFileID
|
||
var allSidecars: [String: MediaSidecarFiles] = [:]
|
||
var seenFingerprints = Set<String>()
|
||
updateMediaLibraryTask(taskID, phase: "正在发现媒体…", log: "正在发现媒体文件")
|
||
if tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
updateMediaLibraryTask(taskID, phase: "TMDB 未配置,无法识别", failure: "请在影视模式侧栏配置 TMDB API Key", log: "TMDB API Key 未配置,文件将以未识别状态入库")
|
||
}
|
||
for try await discovered in mediaDirectorySnapshots(for: library) {
|
||
try Task.checkCancellation()
|
||
allSidecars.merge(discovered.sidecars) { current, _ in current }
|
||
let enriched = await enrichMediaFingerprints(discovered.files)
|
||
let newFiles = enriched.filter { file in
|
||
let key = file.gcid.flatMap { $0.isEmpty ? nil : "gcid:\($0.lowercased())" } ?? "file:\(file.id)"
|
||
return seenFingerprints.insert(key).inserted
|
||
}
|
||
registerMediaLibraryDiscoveredFiles(taskID, files: newFiles)
|
||
let completedIDs = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint?.completedFileIDs ?? []
|
||
let writtenIDs = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint?.writtenFileIDs ?? []
|
||
let isResuming = resumedMediaLibraryTaskIDs.contains(taskID)
|
||
let pendingFiles = newFiles.filter { !completedIDs.contains($0.id) || (isResuming && !writtenIDs.contains($0.id)) }
|
||
guard !pendingFiles.isEmpty else { continue }
|
||
let checkpoint = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint ?? .init()
|
||
setMediaLibraryTaskProgress(taskID, completed: checkpoint.completedFileIDs.count, total: checkpoint.discoveredFileIDs.count, phase: "\(library.kind.title)模式 · 正在使用 TMDB 刮削…")
|
||
let batchItems = try await scrapeMediaFiles(pendingFiles, library: library, sidecars: allSidecars, existingItemsByFileID: existingItemsByFileID, taskID: taskID, retryingWritebackFor: writtenIDs)
|
||
for item in batchItems { itemsByFileID[item.id] = item }
|
||
}
|
||
let discoveredIDs = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint?.discoveredFileIDs ?? Set(itemsByFileID.keys)
|
||
var items = itemsByFileID.values.filter { discoveredIDs.contains($0.id) }
|
||
items.sort { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
|
||
var updatedLibrary = library
|
||
updatedLibrary.updatedAt = Date()
|
||
await mediaLibraryStore.save(library: updatedLibrary)
|
||
updateMediaLibraryTask(taskID, phase: "正在入库…", currentStoring: "\(items.count) 个媒体条目", log: "正在写入本机影视库")
|
||
guard await mediaLibraryStore.replaceItems(items, library: updatedLibrary) else { throw MediaLibraryStoreError.writeFailed }
|
||
await loadMediaLibraries()
|
||
mediaLibraryLiveItemsByLibraryID[library.id] = items
|
||
updateMediaLibraryTask(taskID, phase: "刮削入库完成", currentStoring: nil, log: "已入库 \(items.count) 个媒体条目")
|
||
return items
|
||
}
|
||
|
||
private func finishMediaLibraryScan(taskID: UUID) {
|
||
if !isMediaWritebackEnabled {
|
||
updateMediaLibraryTask(taskID, state: .completed, phase: "任务完成", currentScraping: nil, currentStoring: nil, log: "已完成本机刮削入库;云端回写已关闭")
|
||
return
|
||
}
|
||
guard let queuedWrites = mediaMetadataWriteQueues[taskID], !queuedWrites.isEmpty else {
|
||
updateMediaLibraryTask(taskID, state: .completed, phase: "任务完成", currentScraping: nil, currentStoring: nil, log: "没有需要写回的资源,任务完成")
|
||
return
|
||
}
|
||
updateMediaLibraryTask(taskID, state: .writing, phase: "等待写回资源目录", currentScraping: nil, currentStoring: nil, log: "开始回写 \(queuedWrites.count) 个资源目录")
|
||
startMediaMetadataWriteback(taskID: taskID)
|
||
}
|
||
|
||
private func enrichMediaFingerprints(_ files: [CloudFile]) async -> [CloudFile] {
|
||
let api = self.api
|
||
let metadataCache = self.metadataCache
|
||
let concurrency = mediaScrapeConcurrency
|
||
var iterator = files.makeIterator()
|
||
return await withTaskGroup(of: CloudFile.self, returning: [CloudFile].self) { group in
|
||
func addNext() {
|
||
guard let file = iterator.next() else { return }
|
||
group.addTask {
|
||
guard file.gcid?.isEmpty != false, let detail = try? await api.fsDetail(fileID: file.id) else { return file }
|
||
var copy = file
|
||
copy.gcid = detail.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"])
|
||
copy.size = detail.firstInt64Deep(["size", "fileSize", "resSize"]) ?? file.size
|
||
await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail)
|
||
return copy
|
||
}
|
||
}
|
||
for _ in 0..<min(concurrency, files.count) { addNext() }
|
||
var values: [CloudFile] = []
|
||
while let file = await group.next() {
|
||
if Task.isCancelled { group.cancelAll(); break }
|
||
values.append(file); addNext()
|
||
}
|
||
return values
|
||
}
|
||
}
|
||
|
||
private func mediaDirectorySnapshots(for library: MediaLibraryDefinition) -> AsyncThrowingStream<MediaScanSnapshot, Error> {
|
||
AsyncThrowingStream<MediaScanSnapshot, Error> { (continuation: AsyncThrowingStream<MediaScanSnapshot, Error>.Continuation) in
|
||
let producer = Task { @MainActor [weak self] in
|
||
guard let self else { continuation.finish(); return }
|
||
do {
|
||
var queue: [(id: String?, path: [String], folder: CloudFile?)] = library.sources.map { source in
|
||
let path = source.rootID == nil ? [] : source.path.split(separator: "/").map(String.init)
|
||
let root = source.rootID.map { id in
|
||
CloudFile(id: id, name: path.last ?? source.path, isDirectory: true, size: 0, gcid: nil, subDirectoryCount: nil, subFileCount: nil, modifiedAt: "", cloudPath: "/" + path.joined(separator: "/"), fileType: 0)
|
||
}
|
||
return (source.rootID, path, root)
|
||
}
|
||
while !queue.isEmpty {
|
||
try Task.checkCancellation()
|
||
let current = queue.removeFirst()
|
||
// A scan can be rooted at BDMV/VIDEO_TS itself. In that
|
||
// case there is no parent disc folder for isDiscLayout to
|
||
// detect, so block the internal transport streams here.
|
||
if Self.isDiscInternalPath(current.path) { continue }
|
||
var children = try await self.allFiles(parentID: current.id)
|
||
for index in children.indices {
|
||
children[index].cloudPath = "/" + (current.path + [children[index].name]).joined(separator: "/")
|
||
}
|
||
// A Blu-ray/DVD directory is one playable work. Do not walk into
|
||
// BDMV/STREAM or VIDEO_TS, otherwise each transport stream is
|
||
// incorrectly scraped as a separate film.
|
||
if let discRoot = current.folder, self.isDiscLayout(children) {
|
||
let snapshot = self.mediaSnapshot(children: children, parentID: current.id, parentFolderName: discRoot.name, discRoot: discRoot, minimumSizeBytes: Int64(library.minimumSizeMB) * 1_024 * 1_024)
|
||
if !snapshot.files.isEmpty { continuation.yield(snapshot) }
|
||
continue
|
||
}
|
||
let snapshot = self.mediaSnapshot(children: children, parentID: current.id, parentFolderName: current.folder?.name, minimumSizeBytes: Int64(library.minimumSizeMB) * 1_024 * 1_024)
|
||
if !snapshot.files.isEmpty { continuation.yield(snapshot) }
|
||
if library.recursive { queue += children.filter(\.isDirectory).map { ($0.id, current.path + [$0.name], $0) } }
|
||
}
|
||
continuation.finish()
|
||
} catch {
|
||
continuation.finish(throwing: error)
|
||
}
|
||
}
|
||
continuation.onTermination = { @Sendable _ in producer.cancel() }
|
||
}
|
||
}
|
||
|
||
private func isDiscLayout(_ children: [CloudFile]) -> Bool {
|
||
let names = Set(children.filter(\.isDirectory).map { $0.name.uppercased() })
|
||
return names.contains("BDMV") || names.contains("VIDEO_TS") || (names.contains("CERTIFICATE") && names.contains("BDMV"))
|
||
}
|
||
|
||
private nonisolated static func isDiscInternalPath(_ path: [String]) -> Bool {
|
||
let components = Set(path.map { $0.uppercased() })
|
||
return components.contains("BDMV") || components.contains("VIDEO_TS")
|
||
}
|
||
|
||
private func mediaSnapshot(children: [CloudFile], parentID: String?, parentFolderName: String?, discRoot: CloudFile? = nil, minimumSizeBytes: Int64 = 0) -> MediaScanSnapshot {
|
||
var directMedia = children.filter { $0.isVideo && !Self.isRawDiscTransportStream($0) && ($0.size ?? 0) >= minimumSizeBytes }
|
||
if let discRoot { directMedia = [discRoot] }
|
||
let ordinaryFiles = children.filter { !$0.isDirectory && !$0.isVideo }
|
||
var sidecars: [String: MediaSidecarFiles] = [:]
|
||
for video in directMedia {
|
||
let stem = (video.name as NSString).deletingPathExtension.lowercased()
|
||
func first(named names: Set<String>, extensions: Set<String>) -> CloudFile? {
|
||
ordinaryFiles.first { file in
|
||
let lower = file.name.lowercased()
|
||
let ext = (lower as NSString).pathExtension
|
||
return names.contains(lower) || (extensions.contains(ext) && (lower.hasPrefix(stem + ".") || lower.hasPrefix(stem + "-") || lower.hasPrefix(stem + "_")))
|
||
}
|
||
}
|
||
let nfoFiles = ordinaryFiles.filter { ($0.name as NSString).pathExtension.lowercased() == "nfo" }
|
||
let nfo = first(named: ["movie.nfo", "tvshow.nfo"], extensions: ["nfo"]) ?? (nfoFiles.count == 1 ? nfoFiles.first : nil)
|
||
let poster = first(named: ["poster.jpg", "poster.jpeg", "poster.png", "folder.jpg", "folder.png"], extensions: ["jpg", "jpeg", "png", "webp"])
|
||
let backdrop = first(named: ["fanart.jpg", "fanart.png", "backdrop.jpg", "background.jpg"], extensions: ["jpg", "jpeg", "png", "webp"])
|
||
let subtitleExtensions: Set<String> = ["srt", "ass", "ssa", "sub", "vtt", "sup"]
|
||
let subtitles = ordinaryFiles.filter { file in
|
||
let lower = file.name.lowercased()
|
||
guard subtitleExtensions.contains((lower as NSString).pathExtension) else { return false }
|
||
let subtitleStem = (lower as NSString).deletingPathExtension
|
||
return subtitleStem == stem || subtitleStem.hasPrefix(stem + ".") || subtitleStem.hasPrefix(stem + "-") || subtitleStem.hasPrefix(stem + "_")
|
||
}
|
||
// Disc artwork belongs beside BDMV/VIDEO_TS at the disc root, never
|
||
// inside its internal structure.
|
||
sidecars[video.id] = MediaSidecarFiles(parentID: discRoot == nil ? parentID : discRoot?.id, mediaFolderName: discRoot?.name ?? parentFolderName, nfo: nfo, poster: poster, backdrop: backdrop, subtitles: subtitles)
|
||
}
|
||
return MediaScanSnapshot(files: directMedia, sidecars: sidecars)
|
||
}
|
||
|
||
private nonisolated static func isRawDiscTransportStream(_ file: CloudFile) -> Bool {
|
||
let name = file.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let extensionName = (name as NSString).pathExtension.lowercased()
|
||
let stem = (name as NSString).deletingPathExtension
|
||
let numericStream = stem.range(of: "^0*\\d{3,6}$", options: .regularExpression) != nil
|
||
// Disc folders commonly contain zero-padded transport stream names such
|
||
// as 00000.mkv / 00001.mp4 after being repackaged. They are never a
|
||
// standalone title, irrespective of the extension reported by the API.
|
||
let zeroPaddedStream = stem.range(of: "^0{2,}\\d+$", options: .regularExpression) != nil
|
||
let dvdStream = stem.range(of: "(?i)^VTS_\\d{1,2}_\\d{1,2}$", options: .regularExpression) != nil
|
||
if zeroPaddedStream { return true }
|
||
if ["m2ts", "mts", "vob"].contains(extensionName) { return numericStream || dvdStream }
|
||
// Some cloud API responses omit the extension of a recognised video.
|
||
return extensionName.isEmpty && file.fileType == 2 && numericStream
|
||
}
|
||
|
||
private func scrapeMediaFiles(_ files: [CloudFile], library: MediaLibraryDefinition, sidecars: [String: MediaSidecarFiles], existingItemsByFileID: [String: MediaLibraryItem], taskID: UUID, retryingWritebackFor writtenFileIDs: Set<String>) async throws -> [MediaLibraryItem] {
|
||
let kind = library.kind
|
||
let requests = files.enumerated().map { index, file in
|
||
(index: index, file: file, parsed: mediaParse(fileName: file.name, folderName: sidecars[file.id]?.mediaFolderName))
|
||
}
|
||
let api = self.api
|
||
let apiKey = tmdbAPIKey
|
||
let proxyHost = tmdbProxyHost
|
||
let proxyPort = tmdbProxyPort
|
||
let scrapeConcurrency = mediaScrapeConcurrency
|
||
let candidateCache = MediaTMDBCandidateCache()
|
||
var iterator = requests.makeIterator()
|
||
let results = await withTaskGroup(of: MediaScrapeResult.self, returning: [MediaScrapeResult].self) { group in
|
||
func addNext() {
|
||
guard let request = iterator.next() else { return }
|
||
group.addTask {
|
||
if let existing = existingItemsByFileID[request.file.id], existing.isMatched {
|
||
// A subsequent scan is incremental: recognized media keeps
|
||
// its stored scrape result and skips both TMDB and writeback.
|
||
let current = MediaLibraryItem(file: request.file, tmdbID: existing.tmdbID, title: existing.title, originalTitle: existing.originalTitle, mediaKind: existing.mediaKind, releaseDate: existing.releaseDate, overview: existing.overview, posterData: existing.posterData, backdropData: existing.backdropData, hasChineseAudio: existing.hasChineseAudio, hasChineseSubtitle: existing.hasChineseSubtitle, collectionID: existing.collectionID, collectionName: existing.collectionName)
|
||
let needsWriteback = !writtenFileIDs.contains(request.file.id)
|
||
let note = needsWriteback ? "已入库,继续未完成的目录写回" : "已入库,跳过重新识别"
|
||
return MediaScrapeResult(index: request.index, item: current, note: note, reusedExisting: true, needsWriteback: needsWriteback)
|
||
}
|
||
let language = Self.mediaLanguageProfile(fileName: request.file.name, subtitles: sidecars[request.file.id]?.subtitles ?? [])
|
||
let fallback = MediaLibraryItem(file: request.file, tmdbID: nil, title: request.parsed.title, originalTitle: request.file.name, mediaKind: nil, releaseDate: request.parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil, hasChineseAudio: language.audio, hasChineseSubtitle: language.subtitle, collectionID: nil, collectionName: nil)
|
||
do {
|
||
if let existing = await Self.existingScrape(for: request.file, fallback: fallback, sidecars: sidecars[request.file.id], fallbackKind: kind, api: api) {
|
||
return MediaScrapeResult(index: request.index, item: existing, note: nil, reusedExisting: false)
|
||
}
|
||
guard !apiKey.isEmpty else { return MediaScrapeResult(index: request.index, item: fallback, note: "TMDB API Key 未配置", reusedExisting: false) }
|
||
// Library type controls only destination categories. The
|
||
// scraper itself searches TMDB automatically so misplaced
|
||
// films/series can still be identified correctly.
|
||
let preferredKind: TMDBMediaKind = request.parsed.isEpisode ? .tv : .automatic
|
||
let candidate: TMDBCandidate
|
||
let details: JSONValue?
|
||
if let embeddedID = Self.embeddedTMDBID(in: request.file.cloudPath) {
|
||
let initialKind: TMDBMediaKind = preferredKind == .automatic ? (request.parsed.isEpisode ? .tv : .movie) : preferredKind
|
||
var embeddedDetails = try? await api.tmdbDetails(id: embeddedID, mediaKind: initialKind, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||
var resolvedKind = initialKind
|
||
if embeddedDetails == nil, preferredKind == .automatic {
|
||
resolvedKind = initialKind == .movie ? .tv : .movie
|
||
embeddedDetails = try? await api.tmdbDetails(id: embeddedID, mediaKind: resolvedKind, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||
}
|
||
guard let embeddedDetails, let embeddedCandidate = TMDBCandidate(json: embeddedDetails, forcedKind: resolvedKind) else { return MediaScrapeResult(index: request.index, item: fallback, note: "TMDB ID 无效或媒体类型不匹配", reusedExisting: false) }
|
||
candidate = embeddedCandidate
|
||
details = embeddedDetails
|
||
} else {
|
||
let cacheKey = "\(preferredKind.rawValue):\(request.parsed.year.map(String.init) ?? ""):\(Self.normalizedTMDBLookupTitle(request.parsed.title))"
|
||
let searched: TMDBCandidate?
|
||
if let cached = await candidateCache.candidate(for: cacheKey) {
|
||
searched = cached
|
||
} else {
|
||
let resolved = try await Self.mediaTMDBCandidate(for: request.parsed, preferredKind: preferredKind, api: api, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||
if let resolved { await candidateCache.store(resolved, for: cacheKey) }
|
||
searched = resolved
|
||
}
|
||
guard let searched else {
|
||
return MediaScrapeResult(index: request.index, item: fallback, note: "TMDB 未找到匹配候选:\(request.parsed.title)", reusedExisting: false)
|
||
}
|
||
candidate = searched
|
||
details = try? await api.tmdbDetails(id: searched.id, mediaKind: searched.mediaType, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||
}
|
||
let localizedTitle = details?.firstString(["title", "name"]) ?? candidate.title
|
||
let originalTitle = details?.firstString(["original_title", "original_name"]) ?? candidate.originalTitle
|
||
let localizedOverview = details?.firstString(["overview"]) ?? candidate.overview
|
||
let localizedReleaseDate = details?.firstString(["release_date", "first_air_date"]) ?? candidate.releaseDate
|
||
let localizedPosterPath = details.flatMap { Self.preferredArtworkPath(in: $0, collection: "posters") } ?? details?.firstString(["poster_path"]) ?? candidate.posterPath
|
||
let localizedBackdropPath = details.flatMap { Self.preferredArtworkPath(in: $0, collection: "backdrops") } ?? details?.firstString(["backdrop_path"]) ?? candidate.backdropPath
|
||
let collection = details?["belongs_to_collection"]
|
||
let collectionID = collection?.firstInt(["id"])
|
||
let collectionName = collection?.firstString(["name"])
|
||
let posterData: Data?
|
||
if let path = localizedPosterPath {
|
||
posterData = try? await api.tmdbImage(path: path, size: "w500", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||
} else {
|
||
posterData = nil
|
||
}
|
||
let backdropData: Data?
|
||
if request.index < 8, let path = localizedBackdropPath {
|
||
backdropData = try? await api.tmdbImage(path: path, size: "w1280", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||
} else {
|
||
backdropData = nil
|
||
}
|
||
let item = MediaLibraryItem(file: request.file, tmdbID: candidate.id, title: localizedTitle, originalTitle: originalTitle, mediaKind: candidate.mediaType, releaseDate: localizedReleaseDate, overview: localizedOverview, posterData: posterData, backdropData: backdropData, hasChineseAudio: language.audio, hasChineseSubtitle: language.subtitle, collectionID: collectionID, collectionName: collectionName)
|
||
return MediaScrapeResult(index: request.index, item: item, note: nil, reusedExisting: false)
|
||
} catch {
|
||
return MediaScrapeResult(index: request.index, item: fallback, note: "TMDB 请求失败:\(error.localizedDescription)", reusedExisting: false)
|
||
}
|
||
}
|
||
}
|
||
for _ in 0..<min(scrapeConcurrency, requests.count) { addNext() }
|
||
var values: [MediaScrapeResult] = []
|
||
var pendingStorage: [MediaScrapeResult] = []
|
||
var isFirstFlush = true
|
||
|
||
func flushPendingStorage() async {
|
||
guard !pendingStorage.isEmpty else { return }
|
||
let batch = pendingStorage
|
||
pendingStorage.removeAll(keepingCapacity: true)
|
||
guard await self.commitScrapedMediaBatch(batch, library: library, sidecars: sidecars, taskID: taskID) else { return }
|
||
isFirstFlush = false
|
||
}
|
||
|
||
while let value = await group.next() {
|
||
if Task.isCancelled { group.cancelAll(); break }
|
||
values.append(value)
|
||
pendingStorage.append(value)
|
||
if isFirstFlush || pendingStorage.count >= 8 { await flushPendingStorage() }
|
||
addNext()
|
||
}
|
||
await flushPendingStorage()
|
||
return values
|
||
}
|
||
try Task.checkCancellation()
|
||
return results.sorted { $0.index < $1.index }.map(\.item)
|
||
}
|
||
|
||
/// Commit a small completed scrape batch on the main actor. Keeping this
|
||
/// outside the task-group closure lets TMDB work stay concurrent while UI
|
||
/// state and the SQLite checkpoint are updated safely in larger chunks.
|
||
private func commitScrapedMediaBatch(_ batch: [MediaScrapeResult], library: MediaLibraryDefinition, sidecars: [String: MediaSidecarFiles], taskID: UUID) async -> Bool {
|
||
let batchItems = batch.map(\.item)
|
||
guard await mediaLibraryStore.upsertItems(batchItems, library: library) else { return false }
|
||
|
||
markMediaLibraryFilesCompleted(taskID, fileIDs: batchItems.map(\.id))
|
||
let currentName = batchItems.last.map { $0.title.isEmpty ? $0.file.name : $0.title } ?? ""
|
||
let logs = batch.map { value -> String in
|
||
let name = value.item.title.isEmpty ? value.item.file.name : value.item.title
|
||
if value.reusedExisting { return "已入库,跳过重新识别:\(name)" }
|
||
return value.item.isMatched
|
||
? "TMDB 已识别:\(name)"
|
||
: "TMDB 未识别:\(value.item.file.name)(\(value.note ?? "未返回匹配结果"))"
|
||
}
|
||
updateMediaLibraryTask(taskID, currentScraping: currentName, currentStoring: currentName, log: logs.joined(separator: "\n"))
|
||
|
||
// Publish the first result immediately, then publish compact batches.
|
||
// This lets the poster wall appear progressively while avoiding a full
|
||
// sort and SwiftUI grid refresh for every individual media file.
|
||
var liveItems = mediaLibraryLiveItemsByLibraryID[library.id] ?? []
|
||
var liveIndexes = Dictionary(uniqueKeysWithValues: liveItems.enumerated().map { ($0.element.id, $0.offset) })
|
||
for item in batchItems {
|
||
if let index = liveIndexes[item.id] { liveItems[index] = item }
|
||
else {
|
||
liveIndexes[item.id] = liveItems.count
|
||
liveItems.append(item)
|
||
}
|
||
}
|
||
mediaLibraryLiveItemsByLibraryID[library.id] = liveItems
|
||
|
||
if isMediaWritebackEnabled {
|
||
for value in batch where (!value.reusedExisting || value.needsWriteback) {
|
||
if let files = sidecars[value.item.id] {
|
||
enqueueMediaMetadataWriteback(value.item, files: files, taskID: taskID)
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
private nonisolated static func mediaTMDBCandidate(for parsed: ParsedMediaName, preferredKind: TMDBMediaKind, api: GuangyaAPI, apiKey: String, proxyHost: String, proxyPort: String) async throws -> TMDBCandidate? {
|
||
let queries = mediaTMDBQueries(parsed.title)
|
||
var tried = Set<String>()
|
||
let kinds: [TMDBMediaKind]
|
||
switch preferredKind {
|
||
case .automatic: kinds = [.automatic, .movie, .tv]
|
||
case .movie: kinds = [.movie, .tv]
|
||
case .tv: kinds = [.tv, .movie]
|
||
}
|
||
for query in queries where tried.insert(query.lowercased()).inserted {
|
||
for kind in kinds {
|
||
let response = try await api.tmdbSearch(query: query, apiKey: apiKey, mediaKind: kind, proxyHost: proxyHost, proxyPort: proxyPort)
|
||
let candidates = (response["results"]?.arrayValue ?? []).compactMap { TMDBCandidate(json: $0, forcedKind: kind == .automatic ? nil : kind) }
|
||
if let candidate = candidates
|
||
.map({ ($0, mediaTMDBMatchScore($0, query: query, expectedYear: parsed.year, isEpisode: parsed.isEpisode)) })
|
||
.filter({ $0.1 >= 0.42 })
|
||
.max(by: { $0.1 < $1.1 })?.0 {
|
||
return candidate
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private nonisolated static func normalizedTMDBLookupTitle(_ value: String) -> String {
|
||
value.lowercased()
|
||
.folding(options: [.diacriticInsensitive, .widthInsensitive], locale: .current)
|
||
.replacingOccurrences(of: "[^\\p{Han}a-z0-9]", with: "", options: .regularExpression)
|
||
}
|
||
|
||
private nonisolated static func mediaTMDBMatchScore(_ candidate: TMDBCandidate, query rawQuery: String, expectedYear: Int?, isEpisode: Bool) -> Double {
|
||
let query = normalizedTMDBLookupTitle(rawQuery)
|
||
guard query.count >= 2 else { return 0 }
|
||
let candidateTitles = [candidate.title, candidate.originalTitle].map(normalizedTMDBLookupTitle).filter { !$0.isEmpty }
|
||
var score = candidateTitles.map { title -> Double in
|
||
if title == query { return 1.0 }
|
||
if title.contains(query) || query.contains(title) {
|
||
return Double(min(title.count, query.count)) / Double(max(title.count, query.count)) * 0.9
|
||
}
|
||
let shared = Set(title).intersection(Set(query)).count
|
||
return Double(shared) / Double(max(title.count, query.count))
|
||
}.max() ?? 0
|
||
if let year = expectedYear {
|
||
let candidateYear = Int(candidate.releaseDate.prefix(4))
|
||
if candidateYear == year { score += 0.22 }
|
||
else if let candidateYear, abs(candidateYear - year) > 1 { score -= 0.18 }
|
||
}
|
||
if isEpisode && candidate.mediaType == .tv { score += 0.12 }
|
||
return score
|
||
}
|
||
|
||
private nonisolated static func mediaTMDBQueries(_ title: String) -> [String] {
|
||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let withoutBracketMetadata = trimmed
|
||
.replacingOccurrences(of: "【[^】]*】|\\[[^\\]]*\\]|([^)]*)|\\([^)]*\\)|\\{[^}]*\\}", with: " ", options: .regularExpression)
|
||
let cleaned = withoutBracketMetadata
|
||
.replacingOccurrences(of: "(?i)\\b(remastered|remux|proper|repack|extended|unrated|imax|criterion|complete|limited|web[- ]?dl|webrip|bluray|bdrip|uhd|dv|hdr10?\\+?|avc|vc-1|truehd|atmos|dts(?:-hd)?|ddp?(?: ?[0-9.]+)?|aac|flac|60fps|50fps|10bit|8bit|x26[45]|h\\.?26[45]|hevc|av1)\\b", with: " ", options: .regularExpression)
|
||
.replacingOccurrences(of: "[-_.]([A-Za-z0-9]{3,})$", with: "", options: .regularExpression)
|
||
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let withoutSeason = cleaned.replacingOccurrences(of: "(?i)(?:season|series|s)\\s*0?\\d{1,2}\\b", with: " ", options: .regularExpression)
|
||
.replacingOccurrences(of: "第\\s*\\d{1,2}\\s*季|全\\s*\\d{1,3}\\s*集|(?:国语|普通话|粤语)(?:配音)?|(?:简体|繁体|中英|双语|中文)字幕|已?完结|更新至\\s*\\d{1,3}\\s*集", with: " ", options: .regularExpression)
|
||
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let compactLatin = withoutSeason.range(of: "[\\p{Han}]", options: .regularExpression) == nil
|
||
? withoutSeason.replacingOccurrences(of: "[^A-Za-z0-9]", with: "", options: .regularExpression)
|
||
: ""
|
||
let chineseAlias = withoutSeason
|
||
.replacingOccurrences(of: "(?:\\d{1,3}\\s*帧(?:率)?(?:版本)?|全\\s*\\d{1,3}\\s*集|更新至\\s*\\d{1,3}\\s*集|(?:国语|普通话|粤语)(?:配音)?|(?:简体|繁体|中英|双语|中文)字幕|已?完结)", with: " ", options: .regularExpression)
|
||
.replacingOccurrences(of: "[^\\p{Han}]", with: "", options: .regularExpression)
|
||
let latinAlias = withoutSeason.replacingOccurrences(of: "[\\p{Han}]+", with: " ", options: .regularExpression)
|
||
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let compactLatinAlias = latinAlias.replacingOccurrences(of: "[^A-Za-z0-9]", with: "", options: .regularExpression)
|
||
// Releases commonly place the Chinese and international titles together.
|
||
// TMDB indexes either alias, but often not their concatenated form.
|
||
// Prefer a clean Chinese alias, then the international alias. A combined
|
||
// Chinese/English release string is useful only as a final fallback.
|
||
var seen = Set<String>()
|
||
return [chineseAlias, latinAlias, compactLatinAlias, withoutSeason, cleaned, trimmed, compactLatin]
|
||
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
|
||
}
|
||
|
||
private nonisolated static func existingScrape(for file: CloudFile, fallback: MediaLibraryItem, sidecars: MediaSidecarFiles?, fallbackKind: MediaLibraryKind, api: GuangyaAPI) async -> MediaLibraryItem? {
|
||
guard let sidecars else { return nil }
|
||
async let nfoData = sidecarData(for: sidecars.nfo, api: api)
|
||
async let posterData = sidecarData(for: sidecars.poster, api: api)
|
||
async let backdropData = sidecarData(for: sidecars.backdrop, api: api)
|
||
let (loadedNFO, loadedPoster, loadedBackdrop) = await (nfoData, posterData, backdropData)
|
||
guard let parsed = loadedNFO.flatMap(MediaNFOParser.parse) else {
|
||
guard loadedPoster != nil || loadedBackdrop != nil else { return nil }
|
||
return MediaLibraryItem(file: file, tmdbID: fallback.tmdbID, title: fallback.title, originalTitle: fallback.originalTitle, mediaKind: fallback.mediaKind, releaseDate: fallback.releaseDate, overview: fallback.overview, posterData: loadedPoster, backdropData: loadedBackdrop, hasChineseAudio: fallback.hasChineseAudio, hasChineseSubtitle: fallback.hasChineseSubtitle, collectionID: fallback.collectionID, collectionName: fallback.collectionName)
|
||
}
|
||
let inferredKind: TMDBMediaKind?
|
||
switch fallbackKind {
|
||
case .movies: inferredKind = .movie
|
||
case .series: inferredKind = .tv
|
||
case .mixed: inferredKind = parsed.mediaKind
|
||
}
|
||
return MediaLibraryItem(file: file, tmdbID: parsed.tmdbID, title: parsed.title, originalTitle: parsed.originalTitle, mediaKind: parsed.mediaKind ?? inferredKind, releaseDate: parsed.releaseDate, overview: parsed.overview, posterData: loadedPoster, backdropData: loadedBackdrop, hasChineseAudio: parsed.hasChineseAudio || fallback.hasChineseAudio, hasChineseSubtitle: parsed.hasChineseSubtitle || fallback.hasChineseSubtitle, collectionID: parsed.collectionID, collectionName: parsed.collectionName)
|
||
}
|
||
|
||
private nonisolated static func mediaLanguageProfile(fileName: String, subtitles: [CloudFile]) -> (audio: Bool, subtitle: Bool) {
|
||
let lower = fileName.lowercased()
|
||
let audioMarkers = ["国语", "普通话", "国粤", "粤语", "mandarin", "cantonese", "chinese.audio", "chi.audio", "zh.audio"]
|
||
let subtitleMarkers = ["中文", "中字", "简中", "繁中", "双语", ".chs", ".cht", ".chi", ".zho", ".zh", "chinese", "bilingual"]
|
||
let hasAudio = audioMarkers.contains { lower.contains($0) }
|
||
let hasSubtitle = subtitleMarkers.contains { lower.contains($0) } || subtitles.contains { subtitle in
|
||
let name = subtitle.name.lowercased()
|
||
return subtitleMarkers.contains { name.contains($0) }
|
||
}
|
||
return (hasAudio, hasSubtitle)
|
||
}
|
||
|
||
private nonisolated static func embeddedTMDBID(in cloudPath: String) -> Int? {
|
||
let folderPath = (cloudPath as NSString).deletingLastPathComponent
|
||
guard let regex = try? NSRegularExpression(pattern: "(?i)tmdb(?:id)?[\\s._:=\\-\\[\\(]*(\\d{2,})"),
|
||
let match = regex.matches(in: folderPath, range: NSRange(folderPath.startIndex..., in: folderPath)).last,
|
||
let range = Range(match.range(at: 1), in: folderPath) else { return nil }
|
||
return Int(folderPath[range])
|
||
}
|
||
|
||
private nonisolated static func preferredArtworkPath(in details: JSONValue, collection: String) -> String? {
|
||
let images = details["images"]?[collection]?.arrayValue ?? []
|
||
let languageOrder: [String?] = ["zh", "zh-CN", nil, "en"]
|
||
for language in languageOrder {
|
||
if let path = images.first(where: { image in
|
||
let code = image.firstString(["iso_639_1"])
|
||
return language == nil ? code == nil : code?.caseInsensitiveCompare(language!) == .orderedSame
|
||
})?.firstString(["file_path"]) { return path }
|
||
}
|
||
return images.first?.firstString(["file_path"])
|
||
}
|
||
|
||
private nonisolated static func sidecarData(for file: CloudFile, api: GuangyaAPI) async -> Data? {
|
||
guard let result = try? await api.downloadURL(fileID: file.id),
|
||
let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]),
|
||
let url = URL(string: value) else { return nil }
|
||
return try? await URLSession.shared.data(from: url).0
|
||
}
|
||
|
||
private nonisolated static func sidecarData(for file: CloudFile?, api: GuangyaAPI) async -> Data? {
|
||
guard let file else { return nil }
|
||
return await sidecarData(for: file, api: api)
|
||
}
|
||
|
||
private func enqueueMediaMetadataWriteback(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID, organizeOnly: Bool = false) {
|
||
guard item.isMatched else { return }
|
||
mediaMetadataWriteQueues[taskID, default: []].append(MediaMetadataWriteEntry(item: item, files: files, organizeOnly: organizeOnly))
|
||
}
|
||
|
||
private func startMediaMetadataWriteback(taskID: UUID) {
|
||
guard mediaMetadataWriteTasks[taskID] == nil else { return }
|
||
mediaMetadataWriteTasks[taskID] = Task { [weak self] in
|
||
guard let self else { return }
|
||
let total = self.mediaMetadataWriteQueues[taskID]?.count ?? 0
|
||
var completed = 0
|
||
while !(self.mediaMetadataWriteQueues[taskID]?.isEmpty ?? true), !Task.isCancelled {
|
||
guard let entry = self.mediaMetadataWriteQueues[taskID]?.removeFirst() else { break }
|
||
let item = entry.item
|
||
let name = item.title.isEmpty ? item.file.name : item.title
|
||
let phase = entry.organizeOnly ? "正在分类整理资源目录…" : "正在回写资源目录…"
|
||
self.updateMediaLibraryTask(taskID, state: .writing, phase: phase, currentWriting: name, log: "正在\(entry.organizeOnly ? "分类整理" : "回写"):\(name)")
|
||
if await self.writeMediaScrapeItem(item, files: entry.files, taskID: taskID, organizeOnly: entry.organizeOnly) {
|
||
self.markMediaLibraryFileWritten(taskID, fileID: item.id)
|
||
} else {
|
||
self.mediaMetadataWriteFailures[taskID, default: 0] += 1
|
||
}
|
||
completed += 1
|
||
self.setMediaLibraryTaskProgress(taskID, phase: "资源目录写回 \(completed)/\(total)")
|
||
}
|
||
self.mediaMetadataWriteTasks[taskID] = nil
|
||
guard !Task.isCancelled else { return }
|
||
let failures = self.mediaMetadataWriteFailures[taskID, default: 0]
|
||
self.updateMediaLibraryTask(taskID, state: failures == 0 ? .completed : .failed, phase: failures == 0 ? "资源目录写回完成" : "写回失败,\(failures) 项待续跑", currentWriting: "", failure: failures == 0 ? nil : "\(failures) 项资源目录未完成写回", log: failures == 0 ? "任务完成" : "任务未完全写回,可继续任务")
|
||
}
|
||
}
|
||
|
||
private func writeMediaScrapeItem(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID, organizeOnly: Bool = false) async -> Bool {
|
||
do {
|
||
let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID
|
||
let library = libraryID.flatMap { id in mediaLibraries.first { $0.id == id } }
|
||
let resolvedFiles: MediaSidecarFiles
|
||
if let library, let workFolderID = try await resolvedMediaWorkFolderID(for: item, files: files, library: library) {
|
||
resolvedFiles = MediaSidecarFiles(parentID: workFolderID, mediaFolderName: files.mediaFolderName, nfo: files.nfo, poster: files.poster, backdrop: files.backdrop, subtitles: files.subtitles)
|
||
} else {
|
||
resolvedFiles = files
|
||
}
|
||
let preparedFiles = try await prepareMediaWorkFolder(item, files: resolvedFiles, taskID: taskID)
|
||
let parentKey = preparedFiles.parentID ?? "root"
|
||
await organizeMediaLibraryFolder(item, files: preparedFiles, taskID: taskID)
|
||
if organizeOnly { return true }
|
||
await normalizeMediaLibraryFolder(item, files: preparedFiles, taskID: taskID)
|
||
await normalizeEpisodeFileName(item, files: preparedFiles, taskID: taskID)
|
||
if preparedFiles.nfo == nil {
|
||
let name = item.mediaKind == .tv ? "tvshow.nfo" : "movie.nfo"
|
||
let key = "\(parentKey)/\(name.lowercased())"
|
||
if mediaMetadataWrittenKeys[taskID, default: []].insert(key).inserted {
|
||
try await uploadScrapeData(Data(mediaNFO(for: item).utf8), name: name, contentType: "application/xml", parentID: preparedFiles.parentID)
|
||
}
|
||
}
|
||
if preparedFiles.poster == nil, let data = item.posterData {
|
||
let key = "\(parentKey)/poster.jpg"
|
||
if mediaMetadataWrittenKeys[taskID, default: []].insert(key).inserted { try await uploadScrapeData(data, name: "poster.jpg", contentType: "image/jpeg", parentID: preparedFiles.parentID) }
|
||
}
|
||
if preparedFiles.backdrop == nil, let data = item.backdropData {
|
||
let key = "\(parentKey)/fanart.jpg"
|
||
if mediaMetadataWrittenKeys[taskID, default: []].insert(key).inserted { try await uploadScrapeData(data, name: "fanart.jpg", contentType: "image/jpeg", parentID: preparedFiles.parentID) }
|
||
}
|
||
return true
|
||
} catch { return false }
|
||
}
|
||
|
||
private func prepareMediaWorkFolder(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID) async throws -> MediaSidecarFiles {
|
||
guard !item.file.isDirectory,
|
||
let parentID = files.parentID,
|
||
let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID,
|
||
let library = mediaLibraries.first(where: { $0.id == libraryID }),
|
||
library.sources.compactMap(\.rootID).contains(parentID),
|
||
let tmdbID = item.tmdbID else { return files }
|
||
let folderName = mediaWorkFolderName(item, tmdbID: tmdbID)
|
||
let folderID = try await mediaCategoryFolderID(named: folderName, parentID: parentID, taskID: taskID)
|
||
let attachments = [files.nfo, files.poster, files.backdrop].compactMap { $0 } + files.subtitles
|
||
let ids = Array(Set(([item.file] + attachments).map(\.id)))
|
||
_ = try await api.fsMove(fileIDs: ids, parentID: folderID)
|
||
updateMediaLibraryTask(taskID, currentWriting: folderName, log: "已为散文件创建作品目录:\(folderName)")
|
||
return MediaSidecarFiles(parentID: folderID, mediaFolderName: folderName, nfo: files.nfo, poster: files.poster, backdrop: files.backdrop, subtitles: files.subtitles)
|
||
}
|
||
|
||
private func normalizeEpisodeFileName(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID) async {
|
||
guard !item.file.isDirectory,
|
||
item.mediaKind == .tv,
|
||
item.tmdbID != nil,
|
||
let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID,
|
||
let library = mediaLibraries.first(where: { $0.id == libraryID }),
|
||
let workFolderID = try? await resolvedMediaWorkFolderID(for: item, files: files, library: library) else { return }
|
||
let parsed = parseMediaName(item.file.name)
|
||
guard parsed.isEpisode,
|
||
let season = parsed.season,
|
||
let episode = parsed.episode else { return }
|
||
let stem = (item.file.name as NSString).deletingPathExtension
|
||
let hasStandardEpisode = stem.range(of: "(?i)\\bS\\d{2}E\\d{2,3}\\b", options: .regularExpression) != nil
|
||
let hasChineseEpisode = stem.range(of: "第\\s*\\d{1,3}\\s*集", options: .regularExpression) != nil
|
||
let hasSeriesTitle = stem.localizedCaseInsensitiveContains(item.title)
|
||
let needsRename = !(hasSeriesTitle && (hasStandardEpisode || hasChineseEpisode))
|
||
let ext = (item.file.name as NSString).pathExtension
|
||
do {
|
||
let seasonFolderName = "Season \(season)"
|
||
let seasonFolderID = try await mediaCategoryFolderID(named: seasonFolderName, parentID: workFolderID, taskID: taskID)
|
||
let detail = try await api.fsDetail(fileID: item.file.id)
|
||
let currentParentID = detail.firstIDDeep(["parentId", "parent_id", "parentFileId"])
|
||
let relatedIDs = Array(Set(([item.file] + files.subtitles).map(\.id)))
|
||
if currentParentID != seasonFolderID {
|
||
_ = try await api.fsMove(fileIDs: relatedIDs, parentID: seasonFolderID)
|
||
updateMediaLibraryTask(taskID, currentWriting: seasonFolderName, log: "已归档剧集至:\(seasonFolderName)")
|
||
}
|
||
guard needsRename else { return }
|
||
let baseName = safeCloudName("\(item.title).\(item.year).\(String(format: "S%02dE%02d", season, episode))")
|
||
let targetName = ext.isEmpty ? baseName : "\(baseName).\(ext)"
|
||
guard item.file.name != targetName else { return }
|
||
_ = try await api.fsRename(fileID: item.file.id, newName: targetName)
|
||
updateMediaLibraryTask(taskID, currentWriting: targetName, log: "已规范化剧集文件:\(targetName)")
|
||
} catch {
|
||
updateMediaLibraryTask(taskID, log: "剧集文件重命名失败:\(item.file.name)")
|
||
}
|
||
}
|
||
|
||
private func normalizeMediaLibraryFolder(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID) async {
|
||
guard let tmdbID = item.tmdbID,
|
||
let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID,
|
||
let library = mediaLibraries.first(where: { $0.id == libraryID }),
|
||
let folderID = try? await resolvedMediaWorkFolderID(for: item, files: files, library: library),
|
||
normalizedMediaFolderIDs[taskID, default: []].insert(folderID).inserted else { return }
|
||
// A media-library source is a container, never a work itself. Renaming it
|
||
// would invalidate the library configuration and is intentionally skipped.
|
||
let sourceIDs = library.sources.compactMap(\.rootID)
|
||
guard !sourceIDs.contains(folderID) else { return }
|
||
guard let detail = try? await api.fsDetail(fileID: folderID),
|
||
let currentName = detail.firstStringDeep(["name", "fileName", "resName"]) else { return }
|
||
let targetName = mediaWorkFolderName(item, tmdbID: tmdbID)
|
||
guard currentName != targetName else { return }
|
||
do {
|
||
_ = try await api.fsRename(fileID: folderID, newName: targetName)
|
||
updateMediaLibraryTask(taskID, currentWriting: targetName, log: "已规范化媒体目录:\(targetName)")
|
||
} catch {
|
||
updateMediaLibraryTask(taskID, log: "媒体目录重命名失败:\(currentName)")
|
||
}
|
||
}
|
||
|
||
private func organizeMediaLibraryFolder(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID) async {
|
||
guard let tmdbID = item.tmdbID,
|
||
let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID,
|
||
let library = mediaLibraries.first(where: { $0.id == libraryID }) else { return }
|
||
guard let folderID = try? await resolvedMediaWorkFolderID(for: item, files: files, library: library),
|
||
!library.sources.compactMap(\.rootID).contains(folderID),
|
||
organizedMediaFolderIDs[taskID, default: []].insert(folderID).inserted else { return }
|
||
do {
|
||
let mediaKind = item.mediaKind ?? (library.kind == .movies ? .movie : .tv)
|
||
let details = try await api.tmdbDetails(id: tmdbID, mediaKind: mediaKind, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||
let category = mediaCategoryName(mediaKind: mediaKind, details: details)
|
||
guard let source = categorySource(for: item.file, in: library) else { return }
|
||
let categoryRootID = try await categoryRootID(for: source)
|
||
let mediaTypeID = try await mediaCategoryFolderID(named: mediaLibraryTypeFolder(for: mediaKind), parentID: categoryRootID, taskID: taskID)
|
||
let categoryID = try await mediaCategoryFolderID(named: category, parentID: mediaTypeID, taskID: taskID)
|
||
let year = mediaLibraryYear(for: item)
|
||
let yearID = try await mediaCategoryFolderID(named: year, parentID: categoryID, taskID: taskID)
|
||
let folderDetail = try await api.fsDetail(fileID: folderID)
|
||
let currentParentID = folderDetail.firstIDDeep(["parentId", "parent_id", "parentFileId"])
|
||
let finalName = mediaWorkFolderName(item, tmdbID: tmdbID)
|
||
let currentName = folderDetail.firstStringDeep(["name", "fileName", "resName"])
|
||
if currentName != finalName { _ = try await api.fsRename(fileID: folderID, newName: finalName) }
|
||
if currentParentID != yearID { _ = try await api.fsMove(fileIDs: [folderID], parentID: yearID) }
|
||
updateMediaLibraryTask(taskID, currentWriting: "\(mediaLibraryTypeFolder(for: mediaKind))/\(category)/\(year)/\(finalName)", log: "已分类整理:\(item.title) -> \(category)/\(year)")
|
||
} catch {
|
||
updateMediaLibraryTask(taskID, log: "分类整理失败:\(item.title)(\(error.localizedDescription))")
|
||
}
|
||
}
|
||
|
||
private func mediaCategoryName(mediaKind: TMDBMediaKind, details: JSONValue) -> String {
|
||
let language = (details.firstString(["original_language", "originalLanguage"]) ?? "").lowercased()
|
||
let genres = Set((details["genres"]?.arrayValue ?? []).compactMap { $0.firstInt(["id"]) })
|
||
if let rule = mediaCategoryRules.first(where: { $0.matches(mediaKind: mediaKind, genreIDs: genres, language: language) }) {
|
||
return normalizedMediaCategoryName(rule.name, mediaKind: mediaKind)
|
||
}
|
||
return mediaKind == .movie ? "其他电影" : "其他剧集"
|
||
}
|
||
|
||
private func normalizedMediaCategoryName(_ name: String, mediaKind: TMDBMediaKind) -> String {
|
||
switch (mediaKind, name) {
|
||
case (.movie, "华语电影"): return "国产电影"
|
||
case (.movie, "日语电影"), (.movie, "韩语电影"): return "日韩电影"
|
||
case (.movie, "外语电影"): return "其他电影"
|
||
case (.tv, "国产剧"): return "国产剧集"
|
||
case (.tv, "日剧"), (.tv, "韩剧"), (.tv, "泰剧"), (.tv, "日韩泰剧"): return "日韩剧集"
|
||
case (.tv, "欧美剧"): return "欧美剧集"
|
||
case (.tv, "其他剧"): return "其他剧集"
|
||
default: return name
|
||
}
|
||
}
|
||
|
||
private func mediaLibraryTypeFolder(for mediaKind: TMDBMediaKind) -> String {
|
||
mediaKind == .movie ? "电影" : "剧集"
|
||
}
|
||
|
||
private func mediaWorkFolderName(_ item: MediaLibraryItem, tmdbID: Int) -> String {
|
||
return safeCloudName("\(item.title) (\(mediaLibraryYear(for: item))) {tmdb-\(tmdbID)}")
|
||
}
|
||
|
||
private func mediaLibraryYear(for item: MediaLibraryItem) -> String {
|
||
item.year.isEmpty ? "0000" : item.year
|
||
}
|
||
|
||
private func resolvedMediaWorkFolderID(for item: MediaLibraryItem, files: MediaSidecarFiles, library: MediaLibraryDefinition) async throws -> String? {
|
||
var currentID = item.file.isDirectory ? item.file.id : files.parentID
|
||
let sourceIDs = Set(library.sources.compactMap(\.rootID))
|
||
guard let initialID = currentID, !sourceIDs.contains(initialID) else { return nil }
|
||
var fallbackID = initialID
|
||
while let folderID = currentID, !sourceIDs.contains(folderID) {
|
||
let detail = try await api.fsDetail(fileID: folderID)
|
||
let name = detail.firstStringDeep(["name", "fileName", "resName"]) ?? ""
|
||
if Self.embeddedTMDBID(in: "/\(name)/media") != nil { return folderID }
|
||
let parentID = detail.firstIDDeep(["parentId", "parent_id", "parentFileId"])
|
||
if parentID == nil || sourceIDs.contains(parentID ?? "") { return folderID }
|
||
fallbackID = folderID
|
||
currentID = parentID
|
||
}
|
||
return fallbackID
|
||
}
|
||
|
||
private func categorySource(for file: CloudFile, in library: MediaLibraryDefinition) -> MediaLibrarySource? {
|
||
let path = file.cloudPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||
let matching = library.sources.filter { source in
|
||
let sourcePath = source.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||
return sourcePath.isEmpty || path == sourcePath || path.hasPrefix(sourcePath + "/")
|
||
}
|
||
return matching.max { $0.path.count < $1.path.count } ?? library.sources.first
|
||
}
|
||
|
||
private func categoryRootID(for source: MediaLibrarySource) async throws -> String {
|
||
guard let sourceID = source.rootID else { throw GuangyaAPIError.missingField("媒体库目录 ID") }
|
||
let categoryNames = Set(mediaCategoryRules.map(\.name) + [
|
||
"电影", "剧集", "儿童节目", "国产电影", "日韩电影", "欧美电影", "其他电影",
|
||
"国产剧集", "日韩剧集", "欧美剧集", "其他剧集", "华语电影", "日语电影",
|
||
"韩语电影", "外语电影", "国产剧", "日剧", "韩剧", "泰剧", "日韩泰剧", "欧美剧", "其他剧"
|
||
])
|
||
let sourceName = source.path.split(separator: "/").last.map(String.init) ?? source.path
|
||
guard categoryNames.contains(sourceName) else { return sourceID }
|
||
let detail = try await api.fsDetail(fileID: sourceID)
|
||
return detail.firstIDDeep(["parentId", "parent_id", "parentFileId"]) ?? sourceID
|
||
}
|
||
|
||
private func mediaCategoryFolderID(named name: String, parentID: String, taskID: UUID) async throws -> String {
|
||
let cacheKey = "\(parentID):\(name)"
|
||
if let cached = mediaCategoryFolderIDs[taskID]?[cacheKey] { return cached }
|
||
let response = try await api.fsFiles(parentID: parentID, page: 0, pageSize: 1_000)
|
||
if let existing = extractFiles(from: response).first(where: { $0.isDirectory && $0.name == name }) {
|
||
mediaCategoryFolderIDs[taskID, default: [:]][cacheKey] = existing.id
|
||
return existing.id
|
||
}
|
||
let created = try await api.fsCreateDir(name: name, parentID: parentID)
|
||
if let id = created.firstStringDeep(["fileId", "file_id", "id"]) {
|
||
mediaCategoryFolderIDs[taskID, default: [:]][cacheKey] = id
|
||
return id
|
||
}
|
||
let refreshed = try await api.fsFiles(parentID: parentID, page: 0, pageSize: 1_000)
|
||
guard let createdFolder = extractFiles(from: refreshed).first(where: { $0.isDirectory && $0.name == name }) else {
|
||
throw GuangyaAPIError.missingField("分类目录 \(name)")
|
||
}
|
||
mediaCategoryFolderIDs[taskID, default: [:]][cacheKey] = createdFolder.id
|
||
return createdFolder.id
|
||
}
|
||
|
||
private func mediaNFO(for item: MediaLibraryItem) -> String {
|
||
let root = item.mediaKind == .tv ? "tvshow" : "movie"
|
||
let uniqueID = item.tmdbID.map { "<uniqueid type=\"tmdb\" default=\"true\">\($0)</uniqueid>" } ?? ""
|
||
let collection = item.collectionName.map { name in
|
||
let identifier = item.collectionID.map { "<setid>\($0)</setid>" } ?? ""
|
||
return "<set><name>\(name.xmlEscaped)</name></set>\(identifier)"
|
||
} ?? ""
|
||
return "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><\(root)>\(uniqueID)<title>\(item.title.xmlEscaped)</title><originaltitle>\(item.originalTitle.xmlEscaped)</originaltitle><plot>\(item.overview.xmlEscaped)</plot><premiered>\(item.releaseDate.xmlEscaped)</premiered>\(collection)</\(root)>"
|
||
}
|
||
|
||
private func uploadScrapeData(_ data: Data, name: String, contentType: String, parentID: String?) async throws {
|
||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("guangya-media-\(UUID().uuidString)", isDirectory: true)
|
||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
defer { try? FileManager.default.removeItem(at: directory) }
|
||
let url = directory.appendingPathComponent(name)
|
||
try data.write(to: url, options: .atomic)
|
||
_ = try await api.fileUpload(url: url, parentID: parentID, contentType: contentType)
|
||
}
|
||
|
||
func copy(_ file: CloudFile, to parentID: String?) async {
|
||
await perform("正在复制…") { [self] in _ = try await api.fsCopy(fileIDs: [file.id], parentID: parentID); await loadFiles() }
|
||
}
|
||
|
||
func move(fileIDs: [String], to parentID: String?) async {
|
||
guard !fileIDs.isEmpty else { return }
|
||
await perform("正在移动…") { [self] in _ = try await api.fsMove(fileIDs: fileIDs, parentID: parentID); await loadFiles() }
|
||
}
|
||
|
||
func move(_ file: CloudFile, to parentID: String?) async {
|
||
await perform("正在移动…") { [self] in _ = try await api.fsMove(fileIDs: [file.id], parentID: parentID); await loadFiles() }
|
||
}
|
||
|
||
func restore(_ file: CloudFile) async {
|
||
await perform("正在恢复…") { [self] in _ = try await api.fsRecycle(fileIDs: [file.id]); await loadFiles() }
|
||
}
|
||
|
||
func showDetails(_ file: CloudFile) async {
|
||
// Inspector loading is independent from global isBusy; bind the answer to
|
||
// its resource ID so a slower previous selection can never overwrite it.
|
||
detailFileID = file.id
|
||
detail = nil
|
||
detailErrorMessage = ""
|
||
detailLoadingIDs.insert(file.id)
|
||
defer { detailLoadingIDs.remove(file.id) }
|
||
do {
|
||
let value = try await cachedDetail(for: file)
|
||
guard !Task.isCancelled, detailFileID == file.id else { return }
|
||
detail = value
|
||
} catch is CancellationError { }
|
||
catch {
|
||
guard detailFileID == file.id else { return }
|
||
detail = nil
|
||
detailErrorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
func details(for file: CloudFile, forceRefresh: Bool = false) async throws -> JSONValue {
|
||
try await cachedDetail(for: file, forceRefresh: forceRefresh)
|
||
}
|
||
|
||
func share(_ file: CloudFile) async {
|
||
await perform("正在创建分享…") { [self] in
|
||
let result = try await api.shareCreate(fileIDs: [file.id], title: file.name)
|
||
if let url = result.firstStringDeep(["url", "shareUrl", "share_url", "link"]) {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(url, forType: .string)
|
||
lastActionMessage = "分享链接已复制"
|
||
} else {
|
||
lastActionMessage = "分享已创建"
|
||
}
|
||
}
|
||
}
|
||
|
||
func cleanEmptyFolders() async {
|
||
// Kept for legacy menu callers: this is now scan-only and never deletes.
|
||
startScan(ScanRequest(kind: .emptyFolders, scope: .wholeDrive, rootID: nil, rootName: "云盘根目录", excludeFilesSmallerThan: nil))
|
||
}
|
||
|
||
func startScan(_ request: ScanRequest) {
|
||
guard !isScanning else { return }
|
||
scanTask?.cancel()
|
||
let sessionID = UUID()
|
||
scanSessionID = sessionID
|
||
activeScanRequest = request
|
||
scanResult = nil
|
||
liveScanItems = []
|
||
liveScannedFilesByID = [:]
|
||
scanProgress = ScanProgress(phase: "正在准备扫描…")
|
||
isScanning = true
|
||
scanTask = Task { [weak self] in
|
||
guard let self else { return }
|
||
defer {
|
||
if self.scanSessionID == sessionID {
|
||
self.isScanning = false
|
||
self.scanTask = nil
|
||
}
|
||
}
|
||
do {
|
||
let result = try await self.runScan(request)
|
||
guard self.scanSessionID == sessionID, !Task.isCancelled else { return }
|
||
self.scanResult = result
|
||
self.scanProgress.phase = "扫描完成"
|
||
}
|
||
catch is CancellationError {
|
||
guard self.scanSessionID == sessionID else { return }
|
||
self.scanResult = ScanResult(
|
||
request: request,
|
||
items: self.liveScanItems,
|
||
foldersScanned: self.scanProgress.foldersVisited,
|
||
filesScanned: self.scanProgress.filesVisited,
|
||
skippedFolders: 0
|
||
)
|
||
self.scanProgress.phase = "扫描已取消,已保留当前结果"
|
||
}
|
||
catch {
|
||
guard self.scanSessionID == sessionID else { return }
|
||
self.scanProgress.phase = "扫描失败"
|
||
self.errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
}
|
||
|
||
func cancelScan() {
|
||
guard isScanning, let request = activeScanRequest else { return }
|
||
// Publish the current snapshot before cancelling network traversal. This
|
||
// makes cancellation immediate and prevents later task callbacks from
|
||
// clearing or replacing results already shown to the user.
|
||
scanResult = ScanResult(
|
||
request: request,
|
||
items: liveScanItems,
|
||
foldersScanned: scanProgress.foldersVisited,
|
||
filesScanned: scanProgress.filesVisited,
|
||
skippedFolders: 0
|
||
)
|
||
scanProgress.phase = "扫描已取消,已保留当前结果"
|
||
scanSessionID = nil
|
||
activeScanRequest = nil
|
||
isScanning = false
|
||
scanTask?.cancel()
|
||
scanTask = nil
|
||
}
|
||
|
||
func loadScanPickerFolders(parentID: String? = nil) async {
|
||
let requestID = UUID()
|
||
scanPickerRequestID = requestID
|
||
isLoadingScanPicker = true
|
||
scanPickerFolders = []
|
||
defer { if scanPickerRequestID == requestID { isLoadingScanPicker = false } }
|
||
do {
|
||
let folders = try await allFiles(parentID: parentID, pageSize: 5_000).filter(\.isDirectory)
|
||
guard scanPickerRequestID == requestID else { return }
|
||
scanPickerFolders = folders
|
||
} catch {
|
||
guard scanPickerRequestID == requestID else { return }
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
func deleteScannedEmptyFolders(_ ids: Set<String>) async {
|
||
guard let result = scanResult, result.request.kind == .emptyFolders, !ids.isEmpty, !isDeletingScanResults else { return }
|
||
let targets = result.emptyFolders.reversed().filter { ids.contains($0.id) }
|
||
isDeletingScanResults = true
|
||
scanDeletionProgress = ScanDeletionProgress(phase: "正在复核空文件夹…", completed: 0, total: targets.count)
|
||
defer { isDeletingScanResults = false }
|
||
await perform("正在复核并删除空文件夹…") { [self] in
|
||
var deletedIDs = Set<String>(); var skipped = 0
|
||
for folder in targets {
|
||
scanDeletionProgress.phase = "正在复核 \(folder.name)"
|
||
let children = try await allFiles(parentID: folder.id)
|
||
if children.isEmpty {
|
||
scanDeletionProgress.phase = "正在删除 \(folder.name)"
|
||
actionLoadingIDs.insert(folder.id)
|
||
do {
|
||
defer { actionLoadingIDs.remove(folder.id) }
|
||
_ = try await api.fsDelete(fileIDs: [folder.id])
|
||
}
|
||
deletedIDs.insert(folder.id)
|
||
} else {
|
||
skipped += 1
|
||
}
|
||
scanDeletionProgress.completed += 1
|
||
}
|
||
let remaining = result.emptyFolders.filter { !deletedIDs.contains($0.id) }
|
||
scanResult = ScanResult(request: result.request, items: remaining.map(ScanItem.emptyFolder), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders + skipped)
|
||
files.removeAll { deletedIDs.contains($0.id) }
|
||
scanDeletionProgress.phase = "删除完成"
|
||
lastActionMessage = skipped == 0 ? "已删除 \(deletedIDs.count) 个空文件夹" : "已删除 \(deletedIDs.count) 个,跳过 \(skipped) 个已变更文件夹"
|
||
}
|
||
}
|
||
|
||
func deleteScannedFiles(_ ids: Set<String>) async {
|
||
guard let result = scanResult, result.request.kind != .emptyFolders, !ids.isEmpty, !isDeletingScanResults else { return }
|
||
isDeletingScanResults = true
|
||
scanDeletionProgress = ScanDeletionProgress(phase: "正在删除已选项目…", completed: 0, total: ids.count)
|
||
defer { isDeletingScanResults = false }
|
||
await perform("正在删除已选文件…") { [self] in
|
||
var deleted = 0
|
||
for id in ids {
|
||
actionLoadingIDs.insert(id)
|
||
do {
|
||
defer { actionLoadingIDs.remove(id) }
|
||
_ = try await api.fsDelete(fileIDs: [id])
|
||
}
|
||
deleted += 1
|
||
scanDeletionProgress.completed = deleted
|
||
}
|
||
files.removeAll { ids.contains($0.id) }
|
||
switch result.request.kind {
|
||
case .duplicates:
|
||
let groups = result.items.compactMap { item -> DuplicateGroup? in
|
||
guard case .duplicate(let group) = item else { return nil }
|
||
let kept = group.files.filter { !ids.contains($0.id) }
|
||
return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil
|
||
}
|
||
scanResult = ScanResult(request: result.request, items: groups.map(ScanItem.duplicate), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders)
|
||
case .similarFolders:
|
||
let groups = result.items.compactMap { item -> SimilarFolderGroup? in
|
||
guard case .similar(let group) = item else { return nil }
|
||
let kept = group.folders.filter { !ids.contains($0.id) }
|
||
return kept.count > 1 ? SimilarFolderGroup(id: group.id, folders: kept) : nil
|
||
}
|
||
scanResult = ScanResult(request: result.request, items: groups.map(ScanItem.similar), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders)
|
||
case .emptyFolders: break
|
||
}
|
||
scanDeletionProgress.phase = "删除完成"
|
||
lastActionMessage = "已删除 \(deleted) 项"
|
||
}
|
||
}
|
||
|
||
private func runScan(_ request: ScanRequest) async throws -> ScanResult {
|
||
let root: String? = request.scope == .wholeDrive ? nil : request.rootID
|
||
let recursive = request.scope != .currentFolder
|
||
let rootPath = root == nil ? [] : request.rootName.split(separator: "/").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||
let snapshot = try await scanTree(parentID: root, recursive: recursive, pathComponents: rootPath)
|
||
switch request.kind {
|
||
case .emptyFolders:
|
||
let candidates = snapshot.folders.filter { $0.isEmptyAfterCandidates }
|
||
let finalItems = candidates.map { ScanItem.emptyFolder($0.file) }
|
||
liveScanItems = finalItems
|
||
return ScanResult(request: request, items: finalItems, foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0)
|
||
case .duplicates:
|
||
var candidates = snapshot.allFiles.filter { !$0.isDirectory }
|
||
if let minimum = request.excludeFilesSmallerThan { candidates = candidates.filter { ($0.size ?? 0) >= minimum } }
|
||
let detailed = await withTaskGroup(of: CloudFile.self, returning: [CloudFile].self) { group in
|
||
for file in candidates {
|
||
group.addTask { [api, metadataCache] in
|
||
var copy = file
|
||
if copy.gcid == nil, let detail = try? await api.fsDetail(fileID: file.id) {
|
||
copy.gcid = detail.firstStringDeep(["gcid", "gcId", "hash"])
|
||
if let size = detail.firstInt64Deep(["size", "fileSize", "resSize"]) { copy.size = size }
|
||
await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail)
|
||
}
|
||
return copy
|
||
}
|
||
}
|
||
var all: [CloudFile] = []; for await file in group { all.append(file) }; return all
|
||
}
|
||
var buckets: [String: [CloudFile]] = [:]
|
||
for file in detailed { if let gcid = file.gcid, !gcid.isEmpty { buckets[gcid, default: []].append(file) } }
|
||
var groups: [DuplicateGroup] = []
|
||
for (gcid, files) in buckets where files.count > 1 {
|
||
groups.append(DuplicateGroup(id: gcid, files: files.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }))
|
||
}
|
||
groups.sort { $0.files.count > $1.files.count }
|
||
return ScanResult(request: request, items: groups.map(ScanItem.duplicate), foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0)
|
||
case .similarFolders:
|
||
let groups = Dictionary(grouping: snapshot.allFiles.filter(\.isDirectory), by: { normalizedFolderName($0.name) })
|
||
.compactMap { key, folders -> SimilarFolderGroup? in
|
||
guard folders.count > 1 else { return nil }
|
||
return SimilarFolderGroup(id: key, folders: folders.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending })
|
||
}
|
||
.sorted { lhs, rhs in
|
||
(lhs.folders.first?.cloudPath ?? "").localizedStandardCompare(rhs.folders.first?.cloudPath ?? "") == .orderedAscending
|
||
}
|
||
return ScanResult(request: request, items: groups.map(ScanItem.similar), foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0)
|
||
}
|
||
}
|
||
|
||
private struct ScanTree { let allFiles: [CloudFile]; let folders: [(file: CloudFile, isEmptyAfterCandidates: Bool)] }
|
||
private func scanTree(parentID: String?, recursive: Bool, pathComponents: [String]) async throws -> ScanTree {
|
||
try Task.checkCancellation()
|
||
var children = try await allFiles(parentID: parentID)
|
||
for index in children.indices {
|
||
children[index].cloudPath = "/" + (pathComponents + [children[index].name]).joined(separator: "/")
|
||
}
|
||
for child in children { liveScannedFilesByID[child.id] = child }
|
||
refreshLiveScanItemsFromDiscoveredFiles()
|
||
// A directory whose content request is empty is immediately publishable.
|
||
// This keeps the scan result live while deeper recursion continues.
|
||
if activeScanRequest?.kind == .emptyFolders,
|
||
let parentID,
|
||
children.isEmpty,
|
||
let parent = files.first(where: { $0.id == parentID }) ?? liveScanItems.compactMap({ if case .emptyFolder(let file) = $0 { return file }; return nil }).first(where: { $0.id == parentID }) {
|
||
let item = ScanItem.emptyFolder(parent)
|
||
if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) }
|
||
}
|
||
scanProgress.filesVisited += children.filter { !$0.isDirectory }.count
|
||
scanProgress.foldersVisited += children.filter(\.isDirectory).count
|
||
var all = children; var candidates: [(CloudFile, Bool)] = []
|
||
for folder in children where folder.isDirectory {
|
||
if recursive {
|
||
let subtree = try await scanTree(parentID: folder.id, recursive: true, pathComponents: pathComponents + [folder.name])
|
||
all += subtree.allFiles
|
||
candidates += subtree.folders
|
||
let hasNonEmptyChild = subtree.folders.contains { $0.file.id == folder.id && !$0.isEmptyAfterCandidates }
|
||
let hasDirectFiles = subtree.allFiles.contains { !$0.isDirectory && $0.id != folder.id }
|
||
let isEmptyCandidate = !hasNonEmptyChild && !hasDirectFiles
|
||
candidates.append((folder, isEmptyCandidate))
|
||
if isEmptyCandidate {
|
||
let item = ScanItem.emptyFolder(folder)
|
||
if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) }
|
||
}
|
||
} else {
|
||
candidates.append((folder, true))
|
||
if children.isEmpty {
|
||
let item = ScanItem.emptyFolder(folder)
|
||
if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) }
|
||
}
|
||
}
|
||
}
|
||
return ScanTree(allFiles: all, folders: candidates)
|
||
}
|
||
|
||
private func refreshLiveScanItemsFromDiscoveredFiles() {
|
||
guard let request = activeScanRequest else { return }
|
||
let discovered = Array(liveScannedFilesByID.values)
|
||
switch request.kind {
|
||
case .emptyFolders:
|
||
break
|
||
case .duplicates:
|
||
let candidates = discovered.filter { !$0.isDirectory && $0.gcid?.isEmpty == false }
|
||
let groups = Dictionary(grouping: candidates, by: { $0.gcid! })
|
||
.compactMap { gcid, files -> DuplicateGroup? in
|
||
guard files.count > 1 else { return nil }
|
||
return DuplicateGroup(id: gcid, files: files.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending })
|
||
}
|
||
.sorted { $0.files.count > $1.files.count }
|
||
liveScanItems = groups.map(ScanItem.duplicate)
|
||
case .similarFolders:
|
||
let groups = Dictionary(grouping: discovered.filter(\.isDirectory), by: { normalizedFolderName($0.name) })
|
||
.compactMap { key, folders -> SimilarFolderGroup? in
|
||
guard folders.count > 1 else { return nil }
|
||
return SimilarFolderGroup(id: key, folders: folders.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending })
|
||
}
|
||
.sorted { lhs, rhs in
|
||
(lhs.folders.first?.cloudPath ?? "").localizedStandardCompare(rhs.folders.first?.cloudPath ?? "") == .orderedAscending
|
||
}
|
||
liveScanItems = groups.map(ScanItem.similar)
|
||
}
|
||
}
|
||
|
||
private func allFiles(parentID: String?, pageSize: Int = 5_000) async throws -> [CloudFile] {
|
||
// Scans intentionally use a large page. It avoids 25–100 round trips for
|
||
// ordinary media folders while still paging safely when a folder is huge.
|
||
let requestedSize = max(1, min(pageSize, 10_000))
|
||
var page = 0; var collected: [CloudFile] = []
|
||
while true {
|
||
try Task.checkCancellation()
|
||
scanProgress.phase = "正在读取目录内容…"
|
||
let response = try await api.fsFiles(parentID: parentID, page: page, pageSize: requestedSize)
|
||
let items = extractFiles(from: response)
|
||
collected += items
|
||
let total = response.firstIntDeep(["total", "totalCount", "count"])
|
||
if items.count < requestedSize || (total != nil && collected.count >= total!) { break }
|
||
page += 1
|
||
// Let SwiftUI process pending input/drawing between very large pages.
|
||
await Task.yield()
|
||
}
|
||
let unique = Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first })
|
||
return await hydrateFromMetadataCache(unique)
|
||
}
|
||
|
||
private func allGlobalSearchFiles() async throws -> [CloudFile] {
|
||
async let allItems = allGlobalSearchItems(resType: nil)
|
||
async let folders = allGlobalSearchItems(resType: 2)
|
||
let collected = try await allItems + folders
|
||
return Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first })
|
||
}
|
||
|
||
private func allGlobalSearchItems(resType: Int?) async throws -> [CloudFile] {
|
||
var page = 0
|
||
var collected: [CloudFile] = []
|
||
while true {
|
||
try Task.checkCancellation()
|
||
let response = try await api.fsFiles(parentID: "*", page: page, pageSize: 10_000, orderBy: 0, sortType: 0, resType: resType)
|
||
let batch = extractFiles(from: response)
|
||
collected += batch
|
||
let total = response.firstIntDeep(["total", "totalCount", "count"])
|
||
if batch.count < 10_000 || (total != nil && collected.count >= total!) { break }
|
||
page += 1
|
||
}
|
||
return collected
|
||
}
|
||
|
||
private func fastTransferChildren(parentID: String?) async throws -> [CloudFile] {
|
||
var page = 0
|
||
var values: [CloudFile] = []
|
||
while true {
|
||
let response = try await api.fsFiles(parentID: parentID, page: page, pageSize: 1_000)
|
||
let batch = await Task.detached(priority: .utility) {
|
||
Self.extractFastTransferFiles(from: response)
|
||
}.value
|
||
values += batch
|
||
let total = response.firstIntDeep(["total", "totalCount", "count"])
|
||
if batch.count < 1_000 || (total != nil && values.count >= total!) { break }
|
||
page += 1
|
||
}
|
||
return await Task.detached(priority: .utility) {
|
||
Array(Dictionary(grouping: values, by: \.id).compactMap { $0.value.first })
|
||
}.value
|
||
}
|
||
|
||
private nonisolated static func extractFastTransferFiles(from value: JSONValue) -> [CloudFile] {
|
||
var files: [CloudFile] = []
|
||
var seen = Set<String>()
|
||
func visit(_ value: JSONValue) {
|
||
if let file = CloudFile(json: value), seen.insert(file.id).inserted { files.append(file) }
|
||
if let object = value.objectValue { object.values.forEach(visit) }
|
||
if let array = value.arrayValue { array.forEach(visit) }
|
||
}
|
||
visit(value)
|
||
return files
|
||
}
|
||
|
||
private func fastTransferTargetDirectory(for entry: FastTransferEntry, parentID: String?, createDirectories: Bool, cache: inout [String: String]) async throws -> String? {
|
||
let components = entry.directoryPath
|
||
.split(separator: "/")
|
||
.map(String.init)
|
||
.filter { !$0.isEmpty && $0 != "." }
|
||
guard !components.isEmpty else { return parentID }
|
||
guard createDirectories else {
|
||
throw FastTransferError.invalidJSON("\(entry.path) 包含目录,请开启自动创建多级目录")
|
||
}
|
||
var currentID = parentID
|
||
for component in components {
|
||
guard component != ".." else { throw FastTransferError.invalidJSON("目录不能包含 ..") }
|
||
let cacheKey = "\(currentID ?? "__root__")/\(component)"
|
||
if let cached = cache[cacheKey] {
|
||
currentID = cached
|
||
continue
|
||
}
|
||
let children = try await fastTransferChildren(parentID: currentID)
|
||
if let existing = children.first(where: { $0.name == component && $0.isDirectory }) {
|
||
currentID = existing.id
|
||
} else if children.contains(where: { $0.name == component }) {
|
||
throw FastTransferError.invalidJSON("\(component) 已被同名文件占用")
|
||
} else {
|
||
let created = try await api.fsCreateDir(name: component, parentID: currentID)
|
||
if let id = created.firstStringDeep(["fileId", "file_id", "id", "resId"]) {
|
||
currentID = id
|
||
} else if let refreshed = try await fastTransferChildren(parentID: currentID).first(where: { $0.name == component && $0.isDirectory }) {
|
||
currentID = refreshed.id
|
||
} else {
|
||
throw GuangyaAPIError.missingField("目录 \(component) ID")
|
||
}
|
||
}
|
||
if let currentID { cache[cacheKey] = currentID }
|
||
}
|
||
return currentID
|
||
}
|
||
|
||
private nonisolated static func fastTransferString(in values: [String: Any], keys: [String]) -> String? {
|
||
for key in keys {
|
||
if let value = values[key] as? String, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private nonisolated static func fastTransferSize(in values: [String: Any]) -> Int64 {
|
||
if let value = values["size"] as? NSNumber { return value.int64Value }
|
||
if let value = values["size"] as? String, let size = Int64(value.trimmingCharacters(in: .whitespacesAndNewlines)) { return size }
|
||
return -1
|
||
}
|
||
|
||
private nonisolated static func validatedFastTransferHash(_ value: String?, length: Int) -> String? {
|
||
guard let value else { return nil }
|
||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||
guard normalized.count == length,
|
||
normalized.range(of: "^[0-9A-F]{\(length)}$", options: .regularExpression) != nil else { return nil }
|
||
return normalized
|
||
}
|
||
|
||
private nonisolated static func validatedFastTransferMD5(_ value: String?) -> String? {
|
||
guard let value else { return nil }
|
||
let raw = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let hex = raw.replacingOccurrences(of: "-", with: "")
|
||
if hex.count == 32,
|
||
hex.range(of: "^[0-9A-Fa-f]{32}$", options: .regularExpression) != nil {
|
||
return hex.lowercased()
|
||
}
|
||
guard raw.count == 32,
|
||
raw.range(of: "^[0-9A-Za-z+/_=-]{32}$", options: .regularExpression) != nil else { return nil }
|
||
return raw.lowercased()
|
||
}
|
||
|
||
private nonisolated static func normalizedFastTransferPath(_ value: String) -> String {
|
||
value.replacingOccurrences(of: "\\", with: "/")
|
||
.split(separator: "/")
|
||
.map(String.init)
|
||
.filter { !$0.isEmpty && $0 != "." }
|
||
.joined(separator: "/")
|
||
}
|
||
|
||
private nonisolated static func fastTransferLocalCandidates(from urls: [URL]) throws -> [LocalFastTransferCandidate] {
|
||
var candidates: [LocalFastTransferCandidate] = []
|
||
for root in urls {
|
||
let resource = try root.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey])
|
||
if resource.isRegularFile == true {
|
||
candidates.append(LocalFastTransferCandidate(url: root, path: root.lastPathComponent))
|
||
continue
|
||
}
|
||
guard resource.isDirectory == true else { continue }
|
||
let base = root.lastPathComponent
|
||
let keys: Set<URLResourceKey> = [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey]
|
||
guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: Array(keys), options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { continue }
|
||
for case let url as URL in enumerator {
|
||
let values = try url.resourceValues(forKeys: keys)
|
||
guard values.isRegularFile == true, values.isSymbolicLink != true else { continue }
|
||
let relative = url.path.replacingOccurrences(of: root.path + "/", with: "")
|
||
candidates.append(LocalFastTransferCandidate(url: url, path: "\(base)/\(relative)"))
|
||
}
|
||
}
|
||
return candidates.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }
|
||
}
|
||
|
||
private nonisolated static func localFileSize(at url: URL) -> Int64 {
|
||
(try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? 0
|
||
}
|
||
|
||
private nonisolated static func calculateLocalHashes(at url: URL) throws -> (md5: String, gcid: String) {
|
||
let fileSize = localFileSize(at: url)
|
||
let chunkSize: Int
|
||
switch fileSize {
|
||
case ...0x8000000: chunkSize = 262_144
|
||
case ...0x10000000: chunkSize = 524_288
|
||
case ...0x20000000: chunkSize = 1_048_576
|
||
default: chunkSize = 2_097_152
|
||
}
|
||
let handle = try FileHandle(forReadingFrom: url)
|
||
defer { try? handle.close() }
|
||
var combined = Data()
|
||
var md5Hasher = Insecure.MD5()
|
||
while true {
|
||
let chunk = try handle.read(upToCount: chunkSize) ?? Data()
|
||
if chunk.isEmpty { break }
|
||
md5Hasher.update(data: chunk)
|
||
var chunkHasher = Insecure.SHA1()
|
||
chunkHasher.update(data: chunk)
|
||
combined.append(contentsOf: chunkHasher.finalize())
|
||
}
|
||
let md5 = md5Hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||
let gcid = Insecure.SHA1.hash(data: combined).map { String(format: "%02X", $0) }.joined()
|
||
return (md5, gcid)
|
||
}
|
||
|
||
func scanDuplicates(excludingSmallFiles: Bool = false) async {
|
||
guard !isAnalyzing else { return }
|
||
isAnalyzing = true
|
||
defer { isAnalyzing = false }
|
||
let candidates = files.filter { !$0.isDirectory }
|
||
let api = self.api
|
||
let detailed = await withTaskGroup(of: CloudFile?.self, returning: [CloudFile].self) { group in
|
||
for file in candidates {
|
||
group.addTask { [metadataCache] in
|
||
guard file.gcid == nil else { return file }
|
||
guard let detail = try? await api.fsDetail(fileID: file.id) else { return file }
|
||
var copy = file
|
||
copy.gcid = detail.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"])
|
||
copy.size = detail.firstInt64Deep(["size", "fileSize", "resSize"])
|
||
await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail)
|
||
return copy
|
||
}
|
||
}
|
||
var result: [CloudFile] = []
|
||
for await file in group { if let file { result.append(file) } }
|
||
return result
|
||
}
|
||
let eligible = excludingSmallFiles ? detailed.filter { ($0.size ?? 0) >= 1024 * 1024 } : detailed
|
||
let groups: [String: [(String, CloudFile)]] = Dictionary(grouping: eligible.compactMap { file in file.gcid.map { ($0, file) } }, by: { $0.0 })
|
||
duplicateGroups = groups.compactMap { key, values in
|
||
guard values.count > 1 else { return nil }
|
||
return DuplicateGroup(id: key, files: values.map { $0.1 })
|
||
}.sorted { $0.files.count > $1.files.count }
|
||
lastActionMessage = duplicateGroups.isEmpty ? "没有发现重复文件" : "发现 \(duplicateGroups.count) 组重复文件"
|
||
}
|
||
|
||
func copyTransferJSON(for file: CloudFile) {
|
||
let payload: [String: Any] = ["name": file.name, "fileId": file.id, "size": file.size ?? 0, "gcid": file.gcid ?? ""]
|
||
guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]), let text = String(data: data, encoding: .utf8) else { return }
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(text, forType: .string)
|
||
let panel = NSSavePanel()
|
||
panel.nameFieldStringValue = "\(file.name).transfer.json"
|
||
panel.allowedContentTypes = [.json]
|
||
if panel.runModal() == .OK, let url = panel.url { try? data.write(to: url, options: .atomic); lastActionMessage = "秒传 JSON 已保存并复制" }
|
||
else { lastActionMessage = "秒传 JSON 已复制" }
|
||
}
|
||
|
||
func saveTMDBSettings(key: String, proxyHost: String, proxyPort: String) {
|
||
tmdbAPIKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
tmdbProxyHost = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
tmdbProxyPort = proxyPort.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
UserDefaults.standard.set(tmdbAPIKey, forKey: "guangya.tmdbAPIKey")
|
||
UserDefaults.standard.set(tmdbProxyHost, forKey: "guangya.tmdbProxyHost")
|
||
UserDefaults.standard.set(tmdbProxyPort, forKey: "guangya.tmdbProxyPort")
|
||
lastActionMessage = tmdbAPIKey.isEmpty ? "TMDB 配置已清除" : "TMDB 配置已保存"
|
||
}
|
||
|
||
func saveTMDBWorkflow(_ workflow: TMDBWorkflowConfig) {
|
||
tmdbWorkflow = workflow
|
||
workflow.save()
|
||
lastActionMessage = "TMDB 工作流配置已保存"
|
||
}
|
||
|
||
func prepareTMDBJobs(sourceID: String?, recursive: Bool, minimumSizeMB: Int, extensionsText: String) async {
|
||
guard !tmdbAPIKey.isEmpty else { errorMessage = "请先配置 TMDB API Key"; return }
|
||
isRunningTMDBJob = true
|
||
defer { isRunningTMDBJob = false }
|
||
do {
|
||
let allowed = Set(extensionsText.lowercased().split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) })
|
||
let sourcePath = sourceID == nil ? [] : [tmdbTarget?.name ?? "所选文件夹"]
|
||
let snapshot = try await scanTree(parentID: sourceID, recursive: recursive, pathComponents: sourcePath)
|
||
let minimum = Int64(max(0, minimumSizeMB)) * 1024 * 1024
|
||
let candidates = snapshot.allFiles.filter { file in
|
||
guard !file.isDirectory, (file.size ?? 0) >= minimum else { return false }
|
||
let ext = (file.name as NSString).pathExtension.lowercased()
|
||
return allowed.isEmpty || allowed.contains(ext)
|
||
}
|
||
let subtitles = snapshot.allFiles.filter { !$0.isDirectory && ["srt", "ass", "ssa", "sub", "vtt", "sup"].contains(($0.name as NSString).pathExtension.lowercased()) }
|
||
tmdbJobs = candidates.map { file in
|
||
let base = (file.name as NSString).deletingPathExtension
|
||
let related = subtitles.filter { subtitle in
|
||
let subtitleBase = (subtitle.name as NSString).deletingPathExtension
|
||
return normalizedMediaStem(subtitleBase) == normalizedMediaStem(base)
|
||
}
|
||
return TMDBJob(id: file.id, file: file, parsed: parseMediaName(file.name), relatedSubtitles: related, match: nil, state: .pending, note: "等待识别")
|
||
}
|
||
lastActionMessage = "已建立 \(tmdbJobs.count) 个 TMDB 任务,请先预览匹配结果"
|
||
} catch { errorMessage = error.localizedDescription }
|
||
}
|
||
|
||
func matchTMDBJobs() async {
|
||
guard !tmdbAPIKey.isEmpty, !tmdbJobs.isEmpty else { return }
|
||
isRunningTMDBJob = true
|
||
defer { isRunningTMDBJob = false }
|
||
for index in tmdbJobs.indices {
|
||
do {
|
||
let kind: TMDBMediaKind = tmdbWorkflow.mediaKind == .automatic ? (tmdbJobs[index].parsed.isEpisode ? .tv : .automatic) : tmdbWorkflow.mediaKind
|
||
let candidates = try await tmdbCandidates(query: tmdbJobs[index].parsed.title, mediaKind: kind, year: tmdbJobs[index].parsed.year)
|
||
tmdbJobs[index].candidates = candidates
|
||
tmdbJobs[index].selectedCandidateID = nil
|
||
tmdbJobs[index].match = nil
|
||
tmdbJobs[index].state = candidates.isEmpty ? .failed : .pending
|
||
tmdbJobs[index].note = candidates.isEmpty ? "没有可用候选" : "请选择正确的 TMDB 条目"
|
||
} catch { tmdbJobs[index].state = .failed; tmdbJobs[index].note = error.localizedDescription }
|
||
}
|
||
lastActionMessage = "匹配完成:\(tmdbJobs.filter { $0.state == .matched }.count) 成功,\(tmdbJobs.filter { $0.state == .failed }.count) 失败"
|
||
}
|
||
|
||
func selectTMDBCandidate(jobID: String, candidateID: Int?) {
|
||
guard let index = tmdbJobs.firstIndex(where: { $0.id == jobID }) else { return }
|
||
tmdbJobs[index].selectedCandidateID = candidateID
|
||
tmdbJobs[index].match = tmdbJobs[index].selectedCandidate?.match
|
||
tmdbJobs[index].state = candidateID == nil ? .skipped : .matched
|
||
tmdbJobs[index].note = candidateID == nil ? "已跳过" : "已选择,等待确认"
|
||
}
|
||
|
||
func toggleTMDBApproval(jobID: String) {
|
||
guard let index = tmdbJobs.firstIndex(where: { $0.id == jobID }), tmdbJobs[index].match != nil else { return }
|
||
tmdbJobs[index].isApproved.toggle()
|
||
tmdbJobs[index].note = tmdbJobs[index].isApproved ? "已确认执行" : "等待确认"
|
||
}
|
||
|
||
func runTMDBJobs(destinationID: String?) async {
|
||
guard tmdbWorkflow.operation == .organize else { lastActionMessage = "当前为预览模式,不会修改云盘"; return }
|
||
guard tmdbJobs.contains(where: { $0.isApproved && $0.match != nil }) else { lastActionMessage = "请先手动选择并确认至少一个匹配项"; return }
|
||
isRunningTMDBJob = true
|
||
defer { isRunningTMDBJob = false }
|
||
for index in tmdbJobs.indices where tmdbJobs[index].state == .matched && tmdbJobs[index].isApproved {
|
||
guard let match = tmdbJobs[index].match else { continue }
|
||
do {
|
||
let baseFolder = renderTMDBTemplate(tmdbWorkflow.folderTemplate, match: match, fallback: match.title)
|
||
// Every season is an independent library root: its own TMDB ID,
|
||
// NFO, artwork, cast and episodes all live in the same folder.
|
||
let targetName: String
|
||
if tmdbJobs[index].parsed.isEpisode {
|
||
let season = String(format: "S%02d", tmdbJobs[index].parsed.season ?? 0)
|
||
targetName = safeCloudName("\(baseFolder) \(season) [tmdb-\(match.id)]")
|
||
} else {
|
||
targetName = safeCloudName("\(baseFolder) [tmdb-\(match.id)]")
|
||
}
|
||
let created = try await api.fsCreateDir(name: targetName, parentID: destinationID)
|
||
guard let folderID = created.firstStringDeep(["fileId", "id", "resId"]) else { throw GuangyaAPIError.missingField("目标文件夹 ID") }
|
||
// Metadata first: if it cannot be written, leave the source media untouched.
|
||
let details = try await api.tmdbDetails(id: match.id, mediaKind: match.mediaType == "tv" ? .tv : .movie, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||
if tmdbWorkflow.createNFO { try await uploadTMDBNFO(match, job: tmdbJobs[index], details: details, parentID: folderID) }
|
||
try await uploadTMDBArtwork(details: details, job: tmdbJobs[index], parentID: folderID)
|
||
let ext = (tmdbJobs[index].file.name as NSString).pathExtension
|
||
let mediaName: String
|
||
if tmdbJobs[index].parsed.isEpisode {
|
||
mediaName = safeCloudName("\(match.title) - \(String(format: "S%02dE%02d", tmdbJobs[index].parsed.season ?? 0, tmdbJobs[index].parsed.episode ?? 0))\(ext.isEmpty ? "" : ".\(ext)")")
|
||
} else {
|
||
let stem = safeCloudName(renderTMDBTemplate(tmdbWorkflow.fileTemplate, match: match, fallback: tmdbJobs[index].file.name))
|
||
mediaName = ext.isEmpty || stem.hasSuffix(".\(ext)") ? stem : "\(stem).\(ext)"
|
||
}
|
||
if mediaName != tmdbJobs[index].file.name { _ = try await api.fsRename(fileID: tmdbJobs[index].file.id, newName: mediaName) }
|
||
_ = try await api.fsMove(fileIDs: [tmdbJobs[index].file.id] + tmdbJobs[index].relatedSubtitles.map(\.id), parentID: folderID)
|
||
tmdbJobs[index].state = .completed; tmdbJobs[index].note = "已整理至 \(targetName),含 \(tmdbJobs[index].relatedSubtitles.count) 个字幕"
|
||
} catch { tmdbJobs[index].state = .failed; tmdbJobs[index].note = error.localizedDescription }
|
||
}
|
||
lastActionMessage = "任务执行完成:\(tmdbJobs.filter { $0.state == .completed }.count) 项已整理"
|
||
await loadFiles()
|
||
}
|
||
|
||
private func uploadTMDBNFO(_ match: TMDBMatch, job: TMDBJob, details: JSONValue, parentID: String) async throws {
|
||
let root = job.parsed.isEpisode ? "episodedetails" : (match.mediaType == "tv" ? "tvshow" : "movie")
|
||
let genres = (details["genres"]?.arrayValue ?? []).compactMap { $0.firstString(["name"]) }.map { "<genre>\($0.xmlEscaped)</genre>" }.joined()
|
||
let cast = (details["credits"]?["cast"]?.arrayValue ?? []).prefix(20).map { person in "<actor><name>\((person.firstString(["name"]) ?? "").xmlEscaped)</name><role>\((person.firstString(["character"]) ?? "").xmlEscaped)</role><thumb>\(person.firstString(["profile_path"]) ?? "")</thumb></actor>" }.joined()
|
||
let crew = details["credits"]?["crew"]?.arrayValue ?? []
|
||
let directors = crew.filter { $0.firstString(["job"]) == "Director" }.compactMap { $0.firstString(["name"]) }.map { "<director>\($0.xmlEscaped)</director>" }.joined()
|
||
let rating = details["vote_average"]?.doubleValue.map { "<rating><value>\($0)</value><votes>\(details["vote_count"]?.intValue ?? 0)</votes></rating>" } ?? ""
|
||
let episodeFields = job.parsed.isEpisode ? "<season>\(job.parsed.season ?? 0)</season><episode>\(job.parsed.episode ?? 0)</episode>" : ""
|
||
let title = details.firstString(["title", "name"]) ?? match.title
|
||
let original = details.firstString(["original_title", "original_name"]) ?? ""
|
||
let plot = details.firstString(["overview"]) ?? match.overview
|
||
let premiered = details.firstString(["release_date", "first_air_date"]) ?? match.releaseDate
|
||
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><\(root)><uniqueid type=\"tmdb\" default=\"true\">\(match.id)</uniqueid><title>\(title.xmlEscaped)</title><originaltitle>\(original.xmlEscaped)</originaltitle><plot>\(plot.xmlEscaped)</plot><premiered>\(premiered)</premiered>\(rating)\(genres)\(directors)\(cast)\(episodeFields)</\(root)>"
|
||
let fileName: String
|
||
if job.parsed.isEpisode {
|
||
fileName = "\(match.title) - \(String(format: "S%02dE%02d", job.parsed.season ?? 0, job.parsed.episode ?? 0)).nfo"
|
||
} else { fileName = match.mediaType == "tv" ? "tvshow.nfo" : "movie.nfo" }
|
||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||
try xml.data(using: .utf8)?.write(to: url, options: .atomic)
|
||
defer { try? FileManager.default.removeItem(at: url) }
|
||
_ = try await api.fileUpload(url: url, parentID: parentID, contentType: "application/xml")
|
||
}
|
||
|
||
private func uploadTMDBArtwork(details: JSONValue, job: TMDBJob, parentID: String) async throws {
|
||
func upload(_ path: String?, as name: String) async throws {
|
||
guard let path, !path.isEmpty else { return }
|
||
let data = try await api.tmdbImage(path: path, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||
try data.write(to: url, options: .atomic); defer { try? FileManager.default.removeItem(at: url) }
|
||
_ = try await api.fileUpload(url: url, parentID: parentID, contentType: "image/jpeg")
|
||
}
|
||
if tmdbWorkflow.downloadPoster { try await upload(details.firstString(["poster_path"]), as: "poster.jpg") }
|
||
if tmdbWorkflow.downloadBackdrop { try await upload(details.firstString(["backdrop_path"]), as: "fanart.jpg") }
|
||
if job.parsed.isEpisode, let match = job.match, let season = job.parsed.season, let episode = job.parsed.episode {
|
||
let episodeDetails = try await api.tmdbEpisodeDetails(seriesID: match.id, season: season, episode: episode, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||
try await upload(episodeDetails.firstString(["still_path"]), as: "\(job.match?.title ?? "Episode") - \(String(format: "S%02dE%02d", season, episode))-thumb.jpg")
|
||
}
|
||
if tmdbWorkflow.downloadActorImages {
|
||
for (index, person) in (details["credits"]?["cast"]?.arrayValue ?? []).prefix(12).enumerated() {
|
||
guard let path = person.firstString(["profile_path"]), let data = try? await api.tmdbImage(path: path, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) else { continue }
|
||
let actorID = person.firstInt(["id"]) ?? index
|
||
let name = safeCloudName(person.firstString(["name"]) ?? "actor")
|
||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("actor-\(actorID)-\(name).jpg")
|
||
try data.write(to: url, options: .atomic); defer { try? FileManager.default.removeItem(at: url) }
|
||
_ = try await api.fileUpload(url: url, parentID: parentID, contentType: "image/jpeg")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func renderTMDBTemplate(_ template: String, match: TMDBMatch, fallback: String) -> String {
|
||
template.replacingOccurrences(of: "{title}", with: match.title.isEmpty ? fallback : match.title)
|
||
.replacingOccurrences(of: "{year}", with: String(match.releaseDate.prefix(4)))
|
||
.replacingOccurrences(of: "{tmdbId}", with: String(match.id))
|
||
}
|
||
|
||
func parsedTMDBInfo(for file: CloudFile) -> ParsedMediaName { parseMediaName(file.name) }
|
||
|
||
func parsedTMDBInfo(for item: MediaLibraryItem) -> ParsedMediaName {
|
||
let path = item.file.cloudPath as NSString
|
||
let folderName = (path.deletingLastPathComponent as NSString).lastPathComponent
|
||
return mediaParse(fileName: item.file.name, folderName: folderName.isEmpty ? nil : folderName)
|
||
}
|
||
|
||
func queryTMDBManually(for file: CloudFile, title: String, year: Int?, mediaKind: TMDBMediaKind) async {
|
||
guard !tmdbAPIKey.isEmpty else { errorMessage = "请先配置 TMDB API Key"; return }
|
||
let query = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !query.isEmpty else { errorMessage = "请输入要查询的中文或英文标题"; return }
|
||
isRunningTMDBJob = true
|
||
defer { isRunningTMDBJob = false }
|
||
do {
|
||
// “自动” multi-search may contain people/collections; candidates filter
|
||
// them. If no compatible result remains, retry movie then TV rather
|
||
// than surfacing the old misleading missing-field error.
|
||
var candidates = try await tmdbCandidates(query: query, mediaKind: mediaKind, year: year)
|
||
if candidates.isEmpty, mediaKind == .automatic {
|
||
candidates = try await tmdbCandidates(query: query, mediaKind: .movie, year: year)
|
||
if candidates.isEmpty { candidates = try await tmdbCandidates(query: query, mediaKind: .tv, year: year) }
|
||
}
|
||
let parsed = parseMediaName(file.name)
|
||
tmdbJobs = [TMDBJob(id: file.id, file: file, parsed: parsed, candidates: candidates, selectedCandidateID: nil, state: candidates.isEmpty ? .failed : .pending, note: candidates.isEmpty ? "未找到匹配项,请修改标题、年份或手动填写 TMDB ID" : "请选择正确的 TMDB 条目")]
|
||
if candidates.isEmpty { lastActionMessage = "TMDB 没有找到“\(query)”的可用电影或剧集结果" }
|
||
} catch { errorMessage = "TMDB 查询失败:\(error.localizedDescription)" }
|
||
}
|
||
|
||
func recognizeTMDBByID(_ file: CloudFile, tmdbID: Int, mediaKind: TMDBMediaKind) async {
|
||
guard tmdbID > 0 else { errorMessage = "请输入有效的 TMDB ID"; return }
|
||
// TMDB search API can accept numeric IDs only through the detail endpoint;
|
||
// preserve the explicit ID as an auditable manual choice for the job.
|
||
let match = TMDBMatch(id: tmdbID, title: parseMediaName(file.name).title, mediaType: mediaKind == .tv ? "tv" : "movie", releaseDate: "", overview: "手动指定 TMDB ID")
|
||
tmdbJobs = [TMDBJob(id: file.id, file: file, parsed: parseMediaName(file.name), match: match, candidates: [], selectedCandidateID: tmdbID, state: .matched, note: "手动指定 TMDB #\(tmdbID)", isApproved: true)]
|
||
lastActionMessage = "已建立手动 TMDB #\(tmdbID) 刮削任务,请在工具箱确认执行"
|
||
}
|
||
|
||
func recognizeTMDB(_ file: CloudFile, organize: Bool = false) async {
|
||
guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return }
|
||
await perform(organize ? "正在识别并整理…" : "正在识别…") { [self] in
|
||
let match = try await tmdbMatch(for: file.name)
|
||
if organize {
|
||
let created = try await api.fsCreateDir(name: match.title, parentID: folderPath.last?.id)
|
||
guard let folderID = created.firstStringDeep(["fileId", "id", "resId"]) else { throw GuangyaAPIError.missingField("整理文件夹 ID") }
|
||
_ = try await api.fsMove(fileIDs: [file.id], parentID: folderID)
|
||
let metadata = ["tmdbId": match.id, "title": match.title, "mediaType": match.mediaType, "releaseDate": match.releaseDate, "overview": match.overview] as [String: Any]
|
||
let metadataURL = FileManager.default.temporaryDirectory.appendingPathComponent("tmdb-\(match.id).json")
|
||
let data = try JSONSerialization.data(withJSONObject: metadata, options: [.prettyPrinted, .sortedKeys])
|
||
try data.write(to: metadataURL, options: .atomic)
|
||
defer { try? FileManager.default.removeItem(at: metadataURL) }
|
||
_ = try await api.fileUpload(url: metadataURL, parentID: folderID, contentType: "application/json")
|
||
lastActionMessage = "已整理到「\(match.title)」并上传刮削信息"
|
||
await loadFiles()
|
||
} else {
|
||
lastActionMessage = "识别结果:\(match.title)\(match.releaseDate.isEmpty ? "" : "(\(match.releaseDate.prefix(4)))")"
|
||
}
|
||
}
|
||
}
|
||
|
||
func recognizeTMDBFolder(_ folder: CloudFile) async {
|
||
guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return }
|
||
await perform("正在扫描文件夹并识别…") { [self] in
|
||
var pending = [folder.id]
|
||
var media: [CloudFile] = []
|
||
while let parentID = pending.popLast() {
|
||
let items = extractFiles(from: try await api.fsFiles(parentID: parentID))
|
||
for item in items {
|
||
if item.isDirectory { pending.append(item.id) } else { media.append(item) }
|
||
}
|
||
}
|
||
var matches: [String] = []
|
||
for file in media {
|
||
if let match = try? await tmdbMatch(for: file.name) { matches.append(match.title) }
|
||
}
|
||
lastActionMessage = matches.isEmpty ? "当前文件夹及子文件夹没有识别结果" : "识别到 \(matches.count) 个媒体文件:\n" + matches.prefix(8).joined(separator: "、")
|
||
}
|
||
}
|
||
|
||
func findSimilarFolders() {
|
||
let folders = files.filter(\.isDirectory)
|
||
let groups = Dictionary(grouping: folders, by: { normalizedFolderName($0.name) })
|
||
similarFolders = groups.values.filter { $0.count > 1 }.flatMap { $0 }
|
||
lastActionMessage = similarFolders.isEmpty ? "当前目录没有相似文件夹" : "找到 \(similarFolders.count) 个相似文件夹"
|
||
}
|
||
|
||
func logout() {
|
||
stopQRLogin()
|
||
countdownTask?.cancel()
|
||
listResponseTask?.cancel()
|
||
detailEnrichmentTask?.cancel()
|
||
scanTask?.cancel()
|
||
detailRequestTasks.values.forEach { $0.cancel() }
|
||
activeListRequestID = UUID()
|
||
detailEnrichmentGeneration = UUID()
|
||
countdownTask = nil
|
||
listResponseTask = nil
|
||
scanTask = nil
|
||
detailRequestTasks = [:]
|
||
detailCache = [:]
|
||
detailEnrichmentTask = nil
|
||
detailLoadingIDs = []
|
||
sizeLoadingCompleted = 0
|
||
sizeLoadingTotal = 0
|
||
UserDefaults.standard.removeObject(forKey: "guangya.accessToken")
|
||
UserDefaults.standard.removeObject(forKey: "guangya.refreshToken")
|
||
isSignedIn = false
|
||
files = []
|
||
folderPath = []
|
||
detail = nil
|
||
detailFileID = nil
|
||
detailErrorMessage = ""
|
||
scanResult = nil
|
||
liveScanItems = []
|
||
statusMessage = ""
|
||
}
|
||
|
||
private func completeLogin() {
|
||
persistTokens()
|
||
isSignedIn = true
|
||
isLoadingFiles = true
|
||
Task { await loadAccount() }
|
||
}
|
||
|
||
private func loadAccount() async {
|
||
isLoadingFiles = true
|
||
defer { isLoadingFiles = false }
|
||
do {
|
||
try await api.prepareSession()
|
||
} catch {
|
||
handleAuthorizationExpiry()
|
||
return
|
||
}
|
||
persistTokens()
|
||
async let userResult: JSONValue? = try? await api.userInfo()
|
||
async let fileResult: JSONValue? = try? await filesResponse()
|
||
if let result = await userResult {
|
||
user = UserProfile(json: result)
|
||
} else {
|
||
statusMessage = "登录状态已恢复"
|
||
}
|
||
if let result = await fileResult {
|
||
files = await hydrateFromMetadataCache(extractFiles(from: result))
|
||
updatePaging(from: result)
|
||
// Render the directory response immediately. Per-file detail calls are
|
||
// expensive and must never block or saturate the homepage on launch.
|
||
} else {
|
||
errorMessage = "文件列表加载失败,请点击刷新重试"
|
||
}
|
||
}
|
||
|
||
private func loadFiles(force: Bool = false) async {
|
||
guard force || !isLoadingFiles else { return }
|
||
if force { listResponseTask?.cancel() }
|
||
let requestID = UUID()
|
||
activeListRequestID = requestID
|
||
isLoadingFiles = true
|
||
defer { if activeListRequestID == requestID { isLoadingFiles = false; listResponseTask = nil } }
|
||
let task = Task<JSONValue, Error> { [weak self] in
|
||
guard let self else { throw CancellationError() }
|
||
return try await self.filesResponse()
|
||
}
|
||
listResponseTask = task
|
||
do {
|
||
let result = try await task.value
|
||
guard !Task.isCancelled, activeListRequestID == requestID else { return }
|
||
files = await hydrateFromMetadataCache(extractFiles(from: result))
|
||
updatePaging(from: result)
|
||
// Render the directory response immediately. Per-file detail calls are
|
||
// expensive and must never block or saturate the homepage on launch.
|
||
if files.isEmpty { statusMessage = "当前文件夹暂无文件" }
|
||
else if statusMessage == "当前文件夹暂无文件" { statusMessage = "" }
|
||
} catch is CancellationError { }
|
||
catch {
|
||
guard activeListRequestID == requestID else { return }
|
||
if isAuthorizationExpiry(error) { handleAuthorizationExpiry() }
|
||
else { errorMessage = error.localizedDescription }
|
||
}
|
||
}
|
||
|
||
private func isAuthorizationExpiry(_ error: Error) -> Bool {
|
||
if case GuangyaAPIError.authorizationExpired = error { return true }
|
||
return false
|
||
}
|
||
|
||
private func persistTokens() {
|
||
let defaults = UserDefaults.standard
|
||
defaults.set(api.accessToken, forKey: "guangya.accessToken")
|
||
if let refresh = api.refreshTokenValue { defaults.set(refresh, forKey: "guangya.refreshToken") }
|
||
}
|
||
|
||
private func handleAuthorizationExpiry() {
|
||
guard !isHandlingAuthorizationExpiry else { return }
|
||
isHandlingAuthorizationExpiry = true
|
||
api.clearTokens()
|
||
logout()
|
||
isHandlingAuthorizationExpiry = false
|
||
}
|
||
|
||
private func updatePaging(from response: JSONValue) {
|
||
let total = response.firstIntDeep(["total", "totalCount", "count"]) ?? files.count
|
||
let pages = max(1, Int(ceil(Double(total) / Double(max(pageSize, 1)))))
|
||
totalPages = pages
|
||
if currentPage >= pages { currentPage = pages - 1 }
|
||
}
|
||
|
||
private func filesResponse() async throws -> JSONValue {
|
||
switch section {
|
||
case .files: return try await api.fsFiles(parentID: folderPath.last?.id, page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue)
|
||
case .recentViewed: return try await api.recentViewed(pageSize: pageSize)
|
||
case .recentRestored: return try await api.recentRestored(pageSize: pageSize)
|
||
case .photos: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [1], resType: 1)
|
||
case .videos: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [2], resType: 1)
|
||
case .audio: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [3], resType: 1, needPlayRecord: true)
|
||
case .documents: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [4], resType: 1)
|
||
case .recycle: return try await api.fsFiles(page: currentPage, pageSize: pageSize, orderBy: 10, dirType: 4)
|
||
case .cloud, .shares: return .object([:])
|
||
}
|
||
}
|
||
|
||
private func tmdbCandidates(for name: String, mediaKind: TMDBMediaKind = .automatic) async throws -> [TMDBCandidate] {
|
||
try await tmdbCandidates(query: cleanedTMDBQuery(name), mediaKind: mediaKind, year: nil)
|
||
}
|
||
|
||
private func tmdbCandidates(query: String, mediaKind: TMDBMediaKind, year: Int?) async throws -> [TMDBCandidate] {
|
||
let response = try await api.tmdbSearch(query: query, apiKey: tmdbAPIKey, mediaKind: mediaKind, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
|
||
return (response["results"]?.arrayValue ?? []).compactMap { TMDBCandidate(json: $0, forcedKind: mediaKind == .automatic ? nil : mediaKind) }
|
||
}
|
||
|
||
private func tmdbMatch(for name: String, mediaKind: TMDBMediaKind = .automatic) async throws -> TMDBMatch {
|
||
guard let match = try await tmdbCandidates(for: name, mediaKind: mediaKind).first?.match else { throw GuangyaAPIError.missingField("TMDB 识别结果") }
|
||
return match
|
||
}
|
||
|
||
private func mediaParse(fileName: String, folderName: String?) -> ParsedMediaName {
|
||
let file = parseMediaName(fileName)
|
||
guard let folderName = folderName?.trimmingCharacters(in: .whitespacesAndNewlines), !folderName.isEmpty else {
|
||
return file
|
||
}
|
||
let folder = parseMediaName(folderName)
|
||
let folderHasTMDBID = Self.embeddedTMDBID(in: "/\(folderName)/media") != nil
|
||
let genericTitles: Set<String> = ["video", "movie", "sample", "trailer", "bdmv", "video ts", "certificate", "disc", "feature", "00000", "00001"]
|
||
let fileTitle = file.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let folderTitle = folder.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let fileIsGeneric = genericTitles.contains(fileTitle.lowercased()) || fileTitle.count < 2 || fileTitle.range(of: "^0*\\d{1,4}$", options: .regularExpression) != nil
|
||
let folderIsContainer = folderTitle.range(of: "(?i)(?:电影|电视剧|剧集|动画|合集|资源|影视|发布|专区|系列|作品|collection|movies?|shows?)$", options: .regularExpression) != nil
|
||
let folderIsUseful = !folderTitle.isEmpty && !genericTitles.contains(folderTitle.lowercased()) && !folderIsContainer
|
||
// Prefer an actual filename whenever it already carries a title. This is
|
||
// important for collection folders such as “林正英电影(1)”: that label is
|
||
// not a TMDB work. Folders win only when they explicitly identify a work
|
||
// (TMDB ID/year) or the contained filename is merely an episode number.
|
||
let useFolderTitle = folderIsUseful && (folderHasTMDBID || (folder.year != nil && !folderName.localizedCaseInsensitiveContains("发布")) || fileIsGeneric)
|
||
let selected = useFolderTitle ? folder : file
|
||
return ParsedMediaName(
|
||
title: selected.title,
|
||
year: selected.year ?? (useFolderTitle ? file.year : folder.year),
|
||
season: file.season ?? folder.season,
|
||
episode: file.episode ?? folder.episode,
|
||
isEpisode: file.isEpisode || folder.isEpisode,
|
||
resolution: file.resolution ?? folder.resolution,
|
||
source: file.source ?? folder.source,
|
||
videoCodec: file.videoCodec ?? folder.videoCodec,
|
||
audio: file.audio ?? folder.audio,
|
||
isDiscStructure: file.isDiscStructure || folder.isDiscStructure
|
||
)
|
||
}
|
||
|
||
private func parseMediaName(_ name: String) -> ParsedMediaName {
|
||
let extensionName = (name as NSString).pathExtension.lowercased()
|
||
// Folder names such as "Batman.Returns.1992" must not lose the last
|
||
// component merely because dot-separated release names resemble files.
|
||
let stem = CloudFile.supportedVideoExtensions.contains(extensionName)
|
||
? (name as NSString).deletingPathExtension
|
||
: name
|
||
// Brackets are normally release metadata. Some animation releases put
|
||
// every field in brackets though, so retain the meaningful title token
|
||
// only when stripping brackets would leave no usable filename at all.
|
||
let withoutBrackets = stem
|
||
.replacingOccurrences(of: "【[^】]*】|\\[[^\\]]*\\]|([^)]*)|\\([^)]*\\)|\\{[^}]*\\}", with: " ", options: .regularExpression)
|
||
let unbracketed = withoutBrackets.replacingOccurrences(of: "[._]+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
// Release groups commonly use [中文标题][English Alias][年份][集号].
|
||
// When a meaningful bracket title exists it is more reliable than any
|
||
// residual publisher text outside the brackets.
|
||
let bracketedTitle = bracketedReleaseTitle(in: stem)
|
||
let normalized = (bracketedTitle ?? (unbracketed.isEmpty ? stem : unbracketed))
|
||
.replacingOccurrences(of: "[._]+", with: " ", options: .regularExpression)
|
||
let episodePatterns = ["(?i)\\bS(\\d{1,2})[ ._-]*E(\\d{1,3})\\b", "(?i)\\b(\\d{1,2})x(\\d{1,3})\\b", "(?i)第\\s*(\\d{1,2})\\s*季\\s*第?\\s*(\\d{1,3})\\s*[集话]", "(?i)\\b(\\d{1,2})[ ._-](\\d{1,3})\\b"]
|
||
var season: Int?; var episode: Int?
|
||
for pattern in episodePatterns {
|
||
guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)), match.numberOfRanges >= 3 else { continue }
|
||
season = Int((normalized as NSString).substring(with: match.range(at: 1))); episode = Int((normalized as NSString).substring(with: match.range(at: 2))); break
|
||
}
|
||
if season == nil {
|
||
let episodeOnlyPatterns = ["(?i)\\b(?:E|EP|Episode)[ ._-]*(\\d{1,3})\\b", "第\\s*(\\d{1,3})\\s*[集话]", "(?:^|\\s)(\\d{1,3})$"]
|
||
for pattern in episodeOnlyPatterns {
|
||
guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)), match.numberOfRanges >= 2 else { continue }
|
||
season = 1; episode = Int((normalized as NSString).substring(with: match.range(at: 1))); break
|
||
}
|
||
}
|
||
if season == nil, let bracketEpisode = bracketedEpisode(in: stem) {
|
||
season = 1
|
||
episode = bracketEpisode
|
||
}
|
||
let yearRegex = try? NSRegularExpression(pattern: "\\b(19\\d{2}|20\\d{2})\\b")
|
||
let yearSource = normalized == stem ? stem : "\(normalized) \(stem)"
|
||
let year = yearRegex?.firstMatch(in: yearSource, range: NSRange(yearSource.startIndex..., in: yearSource)).flatMap { Int((yearSource as NSString).substring(with: $0.range(at: 1))) }
|
||
// Once release metadata starts, everything after it is normally codec,
|
||
// source, frame-rate, language or uploader information. This keeps the
|
||
// title stable regardless of the order or delimiter used by a release.
|
||
let metadataBoundary = "(?i)\\b(?:S\\d{1,2}[ ._-]*E\\d{1,3}|\\d{1,2}x\\d{1,3}|(?:E|EP|Episode)[ ._-]*\\d{1,3}|2160[pi]|1080[pi]|720[pi]|480[pi]|4k|web[- ]?(?:dl|rip)?|bluray|b[dr]rip|remux|hdtv|dvd|x26[45]|h\\.?26[45]|hevc|av1|avc|vc-1|aac|ac3|eac3|flac|truehd|dts|ddp?|atmos|hdr|dv|\\d{1,2}bit|\\d(?:\\.\\d)?audio|\\d{2,3}fps|\\d{2,3}帧(?:率)?|bonus[ ._-]*disc|(?:chinese|chn|japanese|jpn|jap|korean|kor|english|eng|french|fre|german|ger|spanish|spa|italian|ita|russian|rus|mandarin|cantonese|hkg|hong[ .-]?kong|usa|uk|multi|dual|dubbed|subbed)|第\\s*\\d{1,2}\\s*季|第\\s*\\d{1,3}\\s*[集话]|全\\s*\\d{1,3}\\s*集|(?:国语|普通话|粤语)(?:配音)?|(?:简体|繁体|中英|双语|中文)字幕)\\b"
|
||
let titleSource: String
|
||
if let range = normalized.range(of: metadataBoundary, options: .regularExpression) {
|
||
titleSource = String(normalized[..<range.lowerBound])
|
||
} else {
|
||
titleSource = normalized
|
||
}
|
||
var title = titleSource
|
||
// Some older releases begin with a publisher watermark rather than a
|
||
// title, for example “中国香港特工出品.1967.七侠五义”.
|
||
title = title.replacingOccurrences(of: "^(?:(?:中国香港|香港|中国)?(?:特工|影视|电影|邵氏)?出品)\\s*", with: "", options: .regularExpression)
|
||
title = title.replacingOccurrences(of: "(?i)\\bS\\d{1,2}[ ._-]*E\\d{1,3}\\b|\\b\\d{1,2}x\\d{1,3}\\b|\\b(?:E|EP|Episode)[ ._-]*\\d{1,3}\\b|第\\s*\\d{1,2}\\s*季\\s*第?\\s*\\d{1,3}\\s*[集话]|第\\s*\\d{1,3}\\s*[集话]|\\b\\d{1,2}[ ._-]\\d{1,3}\\b", with: " ", options: .regularExpression)
|
||
title = title.replacingOccurrences(of: "(?i)(?:season|series|s)\\s*0?\\d{1,2}\\b", with: " ", options: .regularExpression)
|
||
title = title.replacingOccurrences(of: "第\\s*\\d{1,2}\\s*季|全\\s*\\d{1,3}\\s*集|(?:国语|普通话|粤语)(?:配音)?|(?:简体|繁体|中英|双语|中文)字幕|已?完结|更新至\\s*\\d{1,3}\\s*集", with: " ", options: .regularExpression)
|
||
title = title.replacingOccurrences(of: "(?i)\\{\\s*tmdb(?:id)?\\s*[-_: ]*\\d+\\s*\\}|\\[\\s*tmdb(?:id)?\\s*[-_: ]*\\d+\\s*\\]|\\bTMDB(?:ID)?\\s*[-_: ]*\\d+\\b", with: " ", options: .regularExpression)
|
||
title = title.replacingOccurrences(of: "(?i)\\b(19\\d{2}|20\\d{2}|2160[pi]|1080[pi]|720[pi]|480[pi]|4k|web[- ]?dl|webrip|bluray|bdrip|brrip|remux|uhd|dvd|avc|vc-1|x26[45]|h\\.?26[45]|hevc|av1|aac|ac3|eac3|flac|truehd|dts(?:-hd)?|ddp?(?: ?[0-9.]+)?|atmos|hdr|hdr10?\\+?|dv|10bit|8bit|60fps|50fps|proper|repack|remastered|extended|unrated|criterion|complete|limited|bonus|disc|season|series|iq|hq|youku|qq|wetv|nf|netflix|disney(?:plus)?|amzn|hmax|hbo|max|atvp|tving|huawei|korean|kor|japanese|jpn|jap|chinese|chn|mandarin|cantonese|english|french|fre|german|ger|spanish|spa|italian|ita|russian|rus|hkg|hong[ .-]?kong|usa|uk|multi|dual|dubbed|subbed|中字|简体|繁体|chs|cht|eng|dreamhd|cathd|blacktv|zerotv|colortv|hotweb|parktv|nexusnf|pt?erweb|edr)\\b", with: " ", options: .regularExpression)
|
||
title = title.replacingOccurrences(of: "\\d{2,3}\\s*(?:fps|帧(?:率)?)(?:版本)?|高码(?:率|版)?|高帧(?:率|版)?|高质量|原盘|杜比视界|高动态范围|无水印|纯净版|修复版|收藏版|完整版|加长版", with: " ", options: .regularExpression)
|
||
title = title.replacingOccurrences(of: "(?i)[ ._-]+(?:COASTER|DREAMHD|CMCTV|FRDS|CHDBITS|HDHOME|PTER|MTEAM|TTGA|KINGS|BTSJ5|ADWEB|HONE|PLAYWEB|HDSKY|WIKI|EPSiLON|SWTYBLZ|SPHD|CINEPHiLES|BLACKTV|LE?VLETV|NEXUSNF|CHDWEB|PARKTV|ZEROTV|ZERTV|HOTWEB|COLORTV|HUAWEI|HQTV)$", with: " ", options: .regularExpression)
|
||
title = title.replacingOccurrences(of: "[\\[\\](){}._-]+", with: " ", options: .regularExpression).replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
func first(_ pattern: String) -> String? {
|
||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]), let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)) else { return nil }
|
||
return (normalized as NSString).substring(with: match.range)
|
||
}
|
||
let resolution = first("\\b(2160p|1080p|720p|480p|4k)\\b")
|
||
let source = first("\\b(WEB[- ]?DL|WEBRip|BluRay|BDRip|REMUX|HDTV|DVD|UHD)\\b")
|
||
let videoCodec = first("\\b(x26[45]|h\\.?26[45]|HEVC|AV1|VC-1)\\b")
|
||
let audio = first("\\b(Atmos|TrueHD|DTS(?:-HD)?|DDP?(?: ?[0-9.]+)?|AAC|FLAC)\\b")
|
||
let isDiscStructure = normalized.range(of: "(?i)\\b(BDMV|CERTIFICATE|VIDEO_TS|\\.iso)\\b", options: .regularExpression) != nil
|
||
if let chineseTitle = preferredChineseTitle(in: title) { title = chineseTitle }
|
||
return ParsedMediaName(title: title.isEmpty ? stem : title, year: year, season: season, episode: episode, isEpisode: season != nil && episode != nil, resolution: resolution, source: source, videoCodec: videoCodec, audio: audio, isDiscStructure: isDiscStructure)
|
||
}
|
||
|
||
private func preferredChineseTitle(in value: String) -> String? {
|
||
// Chinese release folders commonly use "中文名 English Alias.年份".
|
||
// TMDB's Chinese-first lookup is much more reliable when the alias is
|
||
// retained only as context rather than concatenated into the title.
|
||
let pattern = "^\\s*([\\p{Han}0-9::,,、!!??《》·\\-]+)(?=\\s+[A-Za-z])"
|
||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||
let match = regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
|
||
match.range(at: 1).location != NSNotFound else { return nil }
|
||
let title = (value as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return title.isEmpty ? nil : title
|
||
}
|
||
|
||
private func bracketedReleaseTitle(in stem: String) -> String? {
|
||
let pattern = "\\[([^\\]]+)\\]|【([^】]+)】"
|
||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||
let range = NSRange(stem.startIndex..., in: stem)
|
||
let tokens = regex.matches(in: stem, range: range).compactMap { match -> String? in
|
||
let contentRange = match.range(at: match.range(at: 1).location == NSNotFound ? 2 : 1)
|
||
guard contentRange.location != NSNotFound else { return nil }
|
||
return (stem as NSString).substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
func isMetadata(_ value: String) -> Bool {
|
||
value.range(of: "(?i)^(?:gm[- ]?team|国漫|动漫|动画|完结|全集|合集|全?\\s*\\d{1,3}\\s*集|更新至\\s*\\d{1,3}\\s*集|\\d{1,3}\\s*(?:fps|帧(?:率)?)(?:版本)?|(?:国语|普通话|粤语)(?:配音)?(?:[++](?:简体|繁体|中英|双语|中文)字幕)?|(?:简体|繁体|中英|双语|中文)字幕|\\d{1,4}|19\\d{2}|20\\d{2}|2160[pi]|1080[pi]|720[pi]|480[pi]|4k|avc|hevc|h\\.?26[45]|x26[45]|gb|big5|chs|cht|aac|ac3|dts|flac|web[- ]?(?:dl|rip)?|bluray|remux|hdtv|dvd|jpn|jap|kor|chinese|chn|hkg|eng|multi)$", options: .regularExpression) != nil
|
||
}
|
||
let candidates = tokens.filter { !$0.isEmpty && !isMetadata($0) }
|
||
if let chinese = candidates.first(where: { $0.range(of: "\\p{Han}", options: .regularExpression) != nil }) { return chinese }
|
||
return candidates.first
|
||
}
|
||
|
||
private func bracketedEpisode(in stem: String) -> Int? {
|
||
guard let regex = try? NSRegularExpression(pattern: "\\[(\\d{1,4})\\]|【(\\d{1,4})】") else { return nil }
|
||
let range = NSRange(stem.startIndex..., in: stem)
|
||
for match in regex.matches(in: stem, range: range).reversed() {
|
||
let valueRange = match.range(at: match.range(at: 1).location == NSNotFound ? 2 : 1)
|
||
guard valueRange.location != NSNotFound,
|
||
let value = Int((stem as NSString).substring(with: valueRange)),
|
||
(1...999).contains(value) else { continue }
|
||
return value
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func normalizedMediaStem(_ name: String) -> String { parseMediaName(name).title.lowercased().replacingOccurrences(of: "\\s+", with: "", options: .regularExpression) }
|
||
|
||
private func safeCloudName(_ name: String) -> String {
|
||
let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|\\0")
|
||
let cleaned = name.components(separatedBy: invalid).joined(separator: " ").replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return String((cleaned.isEmpty ? "Untitled" : cleaned).prefix(180))
|
||
}
|
||
|
||
private func cleanedTMDBQuery(_ name: String) -> String { parseMediaName(name).title }
|
||
|
||
private func normalizedFolderName(_ value: String) -> String {
|
||
var normalized = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||
// Cloud drives often create these suffixes when a folder is copied. They
|
||
// do not change the source folder's identity for cleanup review.
|
||
normalized = normalized.replacingOccurrences(
|
||
of: "(?i)(?:[\\s._-]*(?:copy|duplicate|backup|副本|复制|拷贝|备份))(?:[\\s._-]*\\d+)?$",
|
||
with: "",
|
||
options: .regularExpression
|
||
)
|
||
normalized = normalized.replacingOccurrences(
|
||
of: "(?:[((\\[]\\s*\\d{1,3}\\s*[))\\]])$",
|
||
with: "",
|
||
options: .regularExpression
|
||
)
|
||
return normalized.replacingOccurrences(of: "[\\s._()\\[\\]{}-]+", with: "", options: .regularExpression)
|
||
}
|
||
|
||
private func schedulePageDetailEnrichment(force: Bool = false) {
|
||
detailEnrichmentTask?.cancel()
|
||
detailEnrichmentGeneration = UUID()
|
||
let generation = detailEnrichmentGeneration
|
||
let page = files.filter { file in
|
||
file.isDirectory ? (force || needsMetadata(for: file)) : needsMetadata(for: file)
|
||
}
|
||
guard !page.isEmpty else {
|
||
isLoadingFolderSizes = false
|
||
detailLoadingIDs = []
|
||
sizeLoadingCompleted = 0
|
||
sizeLoadingTotal = 0
|
||
return
|
||
}
|
||
isLoadingFolderSizes = true
|
||
detailLoadingIDs = Set(page.map(\.id))
|
||
sizeLoadingCompleted = 0
|
||
sizeLoadingTotal = page.count
|
||
detailEnrichmentTask = Task { [weak self] in
|
||
await self?.enrichPageDetails(page, generation: generation)
|
||
}
|
||
}
|
||
|
||
private func enrichPageDetails(_ page: [CloudFile], generation: UUID) async {
|
||
let api = self.api
|
||
let metadataCache = self.metadataCache
|
||
let concurrencyLimit = 6
|
||
var iterator = page.makeIterator()
|
||
await withTaskGroup(of: (String, Int64?, String?, Int64?, Int?, Int?).self) { group in
|
||
func addNext() {
|
||
guard let file = iterator.next() else { return }
|
||
group.addTask {
|
||
let detail = try? await api.fsDetail(fileID: file.id)
|
||
if let detail { await metadataCache.save(fileID: file.id, isDirectory: file.isDirectory, detail: detail) }
|
||
return (
|
||
file.id,
|
||
detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]),
|
||
detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]),
|
||
detail?.firstInt64Deep(["utime", "ctime"]),
|
||
detail?.firstIntDeep(["subDirCount"]),
|
||
detail?.firstIntDeep(["subFileCount"])
|
||
)
|
||
}
|
||
}
|
||
for _ in 0..<min(concurrencyLimit, page.count) { addNext() }
|
||
while let item = await group.next() {
|
||
guard !Task.isCancelled, detailEnrichmentGeneration == generation else {
|
||
group.cancelAll()
|
||
break
|
||
}
|
||
if let index = files.firstIndex(where: { $0.id == item.0 }) {
|
||
if let size = item.1 { files[index].size = size }
|
||
else if files[index].isDirectory { files[index].size = 0 }
|
||
if let gcid = item.2 { files[index].gcid = gcid }
|
||
if let epoch = item.3 { files[index].modifiedAt = Self.formatDate(epoch) }
|
||
if let count = item.4 { files[index].subDirectoryCount = count }
|
||
if let count = item.5 { files[index].subFileCount = count }
|
||
}
|
||
detailLoadingIDs.remove(item.0)
|
||
sizeLoadingCompleted += 1
|
||
addNext()
|
||
}
|
||
}
|
||
guard !Task.isCancelled, detailEnrichmentGeneration == generation else { return }
|
||
detailLoadingIDs = []
|
||
isLoadingFolderSizes = false
|
||
}
|
||
|
||
private func cachedDetail(for file: CloudFile, forceRefresh: Bool = false) async throws -> JSONValue {
|
||
let fileID = file.id
|
||
if !forceRefresh, let cached = detailCache[fileID], cached.expiresAt > Date() { return cached.value }
|
||
if !forceRefresh, let cached = await metadataCache.metadata(for: fileID), cached.isUsable {
|
||
let value = cached.detailValue
|
||
detailCache[fileID] = (value, Date().addingTimeInterval(300))
|
||
return value
|
||
}
|
||
if let task = detailRequestTasks[fileID] { return try await task.value }
|
||
let api = self.api
|
||
let gate = detailRequestGate
|
||
let metadataCache = self.metadataCache
|
||
let task = Task<JSONValue, Error> {
|
||
await gate.acquire()
|
||
defer { Task { await gate.release() } }
|
||
try Task.checkCancellation()
|
||
let value = try await api.fsDetail(fileID: fileID)
|
||
await metadataCache.save(fileID: fileID, isDirectory: file.isDirectory, detail: value)
|
||
return value
|
||
}
|
||
detailRequestTasks[fileID] = task
|
||
defer { detailRequestTasks[fileID] = nil }
|
||
let value = try await task.value
|
||
detailCache[fileID] = (value, Date().addingTimeInterval(300)) // 5-minute TTL
|
||
if detailCache.count > 800 { detailCache = Dictionary(uniqueKeysWithValues: detailCache.filter { $0.value.expiresAt > Date() }) }
|
||
return value
|
||
}
|
||
|
||
private func hydrateFromMetadataCache(_ source: [CloudFile]) async -> [CloudFile] {
|
||
await metadataCache.save(files: source)
|
||
let cached = await metadataCache.metadata(for: source.map(\.id))
|
||
return source.map { file in cached[file.id]?.applying(to: file) ?? file }
|
||
}
|
||
|
||
private func needsMetadata(for file: CloudFile) -> Bool {
|
||
if file.isDirectory {
|
||
return file.subDirectoryCount == nil || file.subFileCount == nil
|
||
}
|
||
return file.gcid?.isEmpty != false
|
||
}
|
||
|
||
private static func formatDate(_ 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)))
|
||
}
|
||
|
||
private nonisolated func extractFiles(from value: JSONValue) -> [CloudFile] {
|
||
extractCloudFiles(from: value)
|
||
}
|
||
|
||
private func startQRPolling(expiresIn: Int, interval: Int) {
|
||
qrPollingTask = Task { [weak self] in
|
||
guard let self else { return }
|
||
let deadline = Date().addingTimeInterval(TimeInterval(expiresIn))
|
||
while !Task.isCancelled && !self.qrToken.isEmpty && Date() < deadline {
|
||
try? await Task.sleep(for: .seconds(interval))
|
||
guard !Task.isCancelled else { return }
|
||
do {
|
||
let result = try await self.api.loginQRPoll(token: self.qrToken)
|
||
if self.api.accessToken.isEmpty == false {
|
||
self.qrStatus = "扫码成功,正在进入网盘…"
|
||
self.completeLogin()
|
||
return
|
||
}
|
||
self.qrStatus = result.firstStringDeep(["message", "msg", "statusText"]) ?? "等待扫码"
|
||
} catch let error as GuangyaAPIError where error.isDeviceAuthorizationPending {
|
||
self.qrStatus = "等待扫码"
|
||
} catch let error as GuangyaAPIError where Self.isQRAuthorizationPending(error) {
|
||
// The account endpoint can return HTTP 400 "Precondition Required"
|
||
// while the device grant is still waiting for the phone confirmation.
|
||
self.qrStatus = "已扫码,请在 App 中确认登录"
|
||
} catch {
|
||
self.qrStatus = "二维码状态查询失败"
|
||
self.errorMessage = error.localizedDescription
|
||
return
|
||
}
|
||
}
|
||
if !Task.isCancelled && Date() >= deadline { self.qrStatus = "二维码已过期,请刷新" }
|
||
}
|
||
}
|
||
|
||
private static func isQRAuthorizationPending(_ error: GuangyaAPIError) -> Bool {
|
||
guard case .http(let status, let message) = error, status == 400 else { return false }
|
||
let text = message.lowercased()
|
||
return text.contains("precondition required") || text.contains("authorization_pending") || text.contains("slow_down")
|
||
}
|
||
|
||
private func startCountdown() {
|
||
countdownTask?.cancel()
|
||
codeCountdown = 60
|
||
countdownTask = Task { [weak self] in
|
||
while let self, self.codeCountdown > 0, !Task.isCancelled {
|
||
try? await Task.sleep(for: .seconds(1))
|
||
self.codeCountdown -= 1
|
||
}
|
||
}
|
||
}
|
||
|
||
private func perform(_ message: String, operation: @escaping @MainActor () async throws -> Void) async {
|
||
isBusy = true; errorMessage = ""; statusMessage = message
|
||
do {
|
||
try await operation()
|
||
// A request may have silently refreshed the access token.
|
||
persistTokens()
|
||
if errorMessage.isEmpty { statusMessage = "" }
|
||
} catch {
|
||
if isAuthorizationExpiry(error) { handleAuthorizationExpiry() }
|
||
else if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == NSURLErrorTimedOut {
|
||
errorMessage = "网络请求超时:已自动重试。请检查网络或稍后重试。"
|
||
} else { errorMessage = error.localizedDescription }
|
||
}
|
||
isBusy = false
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var xmlEscaped: String {
|
||
replacingOccurrences(of: "&", with: "&")
|
||
.replacingOccurrences(of: "<", with: "<")
|
||
.replacingOccurrences(of: ">", with: ">")
|
||
}
|
||
}
|