Files
ngfchl 0df8013d3e feat: 移除内置播放器,统一外部播放体验
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器
- 播放器选择器优化为 Grid 布局,显示真实应用图标
- 右键菜单合并为"播放"菜单,直接列出所有播放器
- 详情页剧集列表增加右键菜单
- 媒体库所有上下文菜单统一播放入口
- 修复启动时自动清理已删除文件的秒传记录
- 添加 prepareAndPerformFastTransfer 静态方法
2026-07-18 00:50:21 +08:00

3921 lines
227 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 25100 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
}