From 6526e6cdc87b065141c48f9b6bc13f1ab9edd8e3 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Thu, 16 Jul 2026 14:02:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=A4=9A=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=BD=B1=E8=A7=86=E5=BA=93=E4=B8=8E=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E5=88=AE=E5=89=8A=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- guangya_mac/AppModel.swift | 171 +++++++++++++++++++++++++--- guangya_mac/MediaLibraryStore.swift | 117 ++++++++++++++++--- guangya_mac/Models.swift | 39 ++++++- 3 files changed, 295 insertions(+), 32 deletions(-) diff --git a/guangya_mac/AppModel.swift b/guangya_mac/AppModel.swift index 4751ebc..a880643 100644 --- a/guangya_mac/AppModel.swift +++ b/guangya_mac/AppModel.swift @@ -38,6 +38,10 @@ final class AppModel: ObservableObject { @Published var mediaLibraries: [MediaLibraryDefinition] = [] @Published var isScanningMediaLibrary = false @Published var mediaLibraryScanProgress = MediaLibraryScanProgress() + @Published var isWritingMediaMetadata = false + @Published var mediaMetadataWriteProgress = MediaLibraryScanProgress() + @Published var mediaLibraryLiveLibraryID: String? + @Published var mediaLibraryLiveItems: [MediaLibraryItem] = [] @Published var isRunningTMDBJob = false @Published var isAnalyzing = false @Published var activeScanRequest: ScanRequest? @@ -78,6 +82,7 @@ final class AppModel: ObservableObject { private let mediaLibraryStore = MediaLibraryStore.shared private var isHandlingAuthorizationExpiry = false private var scanTask: Task? + private var mediaMetadataWriteTask: Task? init() { let defaults = UserDefaults.standard @@ -386,7 +391,11 @@ final class AppModel: ObservableObject { } func createMediaLibrary(name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool) async -> MediaLibraryDefinition { - let library = MediaLibraryDefinition(id: UUID().uuidString, name: name.trimmingCharacters(in: .whitespacesAndNewlines), rootID: rootID, rootPath: rootPath, kind: kind, recursive: recursive, updatedAt: nil) + await createMediaLibrary(name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive) + } + + func createMediaLibrary(name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool) async -> MediaLibraryDefinition { + let library = MediaLibraryDefinition(id: UUID().uuidString, name: name.trimmingCharacters(in: .whitespacesAndNewlines), sources: sources, kind: kind, recursive: recursive, updatedAt: nil) await mediaLibraryStore.save(library: library) await loadMediaLibraries() return library @@ -397,6 +406,11 @@ final class AppModel: ObservableObject { await loadMediaLibraries() } + func updateMediaLibrary(_ library: MediaLibraryDefinition) async { + await mediaLibraryStore.save(library: library) + await loadMediaLibraries() + } + func cachedMediaLibraryItems(libraryID: String) async -> [MediaLibraryItem] { await mediaLibraryStore.items(libraryID: libraryID) } @@ -406,18 +420,69 @@ final class AppModel: ObservableObject { isScanningMediaLibrary = true mediaLibraryScanProgress = MediaLibraryScanProgress(phase: "正在读取云端目录…") defer { isScanningMediaLibrary = false } - let snapshot = try await discoverMediaFiles(parentID: library.rootID, rootPath: library.rootPath, recursive: library.recursive) + var discoveredFiles: [CloudFile] = [] + var discoveredSidecars: [String: MediaSidecarFiles] = [:] + for (index, source) in library.sources.enumerated() { + mediaLibraryScanProgress.phase = "正在读取目录 \(index + 1)/\(library.sources.count)…" + let snapshot = try await discoverMediaFiles(parentID: source.rootID, rootPath: source.path, recursive: library.recursive) + discoveredFiles += snapshot.files + discoveredSidecars.merge(snapshot.sidecars) { current, _ in current } + } + mediaLibraryScanProgress.phase = "正在校验 GCID…" + let uniqueByID = Array(Dictionary(grouping: discoveredFiles, by: \.id).compactMap { $0.value.first }) + let enriched = await enrichMediaFingerprints(uniqueByID) + var preferredByFingerprint: [String: CloudFile] = [:] + for file in enriched { + let key = file.gcid.flatMap { $0.isEmpty ? nil : "gcid:\($0)" } ?? "file:\(file.id)" + guard let existing = preferredByFingerprint[key] else { preferredByFingerprint[key] = file; continue } + let candidateLanguage = Self.mediaLanguageProfile(fileName: file.name, subtitles: discoveredSidecars[file.id]?.subtitles ?? []) + let existingLanguage = Self.mediaLanguageProfile(fileName: existing.name, subtitles: discoveredSidecars[existing.id]?.subtitles ?? []) + let candidateScore = (candidateLanguage.audio ? 2 : 0) + (candidateLanguage.subtitle ? 1 : 0) + let existingScore = (existingLanguage.audio ? 2 : 0) + (existingLanguage.subtitle ? 1 : 0) + if candidateScore > existingScore || (candidateScore == existingScore && file.cloudPath.count < existing.cloudPath.count) { + preferredByFingerprint[key] = file + } + } + let files = preferredByFingerprint.values.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending } + let snapshot = MediaScanSnapshot(files: files, sidecars: discoveredSidecars) mediaLibraryScanProgress = MediaLibraryScanProgress(phase: "正在读取已有刮削信息…", completed: 0, total: snapshot.files.count) - let items = await scrapeMediaFiles(snapshot.files, kind: library.kind, sidecars: snapshot.sidecars) + await mediaLibraryStore.save(library: library) + mediaLibraryLiveLibraryID = library.id + mediaLibraryLiveItems = await mediaLibraryStore.items(libraryID: library.id) + let items = await scrapeMediaFiles(snapshot.files, library: library, sidecars: snapshot.sidecars) var updatedLibrary = library updatedLibrary.updatedAt = Date() await mediaLibraryStore.save(library: updatedLibrary) - await mediaLibraryStore.replaceItems(items, library: updatedLibrary) + guard await mediaLibraryStore.replaceItems(items, library: updatedLibrary) else { throw MediaLibraryStoreError.writeFailed } await loadMediaLibraries() - mediaLibraryScanProgress.phase = "扫描完成" + mediaLibraryScanProgress.phase = "刮削完成" + startMediaMetadataWriteback(items, sidecars: snapshot.sidecars) return items } + private func enrichMediaFingerprints(_ files: [CloudFile]) async -> [CloudFile] { + let api = self.api + let metadataCache = self.metadataCache + 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.. MediaScanSnapshot { let initialPath = parentID == nil ? [] : rootPath.split(separator: "/").map(String.init) var queue: [(id: String?, path: [String])] = [(parentID, initialPath)] @@ -453,9 +518,7 @@ final class AppModel: ObservableObject { let subtitleStem = (lower as NSString).deletingPathExtension return subtitleStem == stem || subtitleStem.hasPrefix(stem + ".") || subtitleStem.hasPrefix(stem + "-") || subtitleStem.hasPrefix(stem + "_") } - if nfo != nil || poster != nil || backdrop != nil || !subtitles.isEmpty { - sidecars[video.id] = MediaSidecarFiles(nfo: nfo, poster: poster, backdrop: backdrop, subtitles: subtitles) - } + sidecars[video.id] = MediaSidecarFiles(parentID: current.id, nfo: nfo, poster: poster, backdrop: backdrop, subtitles: subtitles) } if recursive { queue += children.filter(\.isDirectory).map { ($0.id, current.path + [$0.name]) } } } @@ -464,7 +527,8 @@ final class AppModel: ObservableObject { return MediaScanSnapshot(files: unique, sidecars: sidecars) } - private func scrapeMediaFiles(_ files: [CloudFile], kind: MediaLibraryKind, sidecars: [String: MediaSidecarFiles]) async -> [MediaLibraryItem] { + private func scrapeMediaFiles(_ files: [CloudFile], library: MediaLibraryDefinition, sidecars: [String: MediaSidecarFiles]) async -> [MediaLibraryItem] { + let kind = library.kind let requests = files.enumerated().map { index, file in (index: index, file: file, parsed: parseMediaName(file.name)) } @@ -478,7 +542,7 @@ final class AppModel: ObservableObject { guard let request = iterator.next() else { return } group.addTask { 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) + 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 (request.index, existing) @@ -501,6 +565,9 @@ final class AppModel: ObservableObject { 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) @@ -513,7 +580,7 @@ final class AppModel: ObservableObject { } else { backdropData = nil } - return (request.index, 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)) + return (request.index, 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)) } catch { return (request.index, fallback) } @@ -521,11 +588,22 @@ final class AppModel: ObservableObject { } for _ in 0..= 8 { + _ = await mediaLibraryStore.upsertItems(pendingDatabaseItems, library: library) + pendingDatabaseItems.removeAll(keepingCapacity: true) + mediaLibraryLiveItems = await mediaLibraryStore.items(libraryID: library.id) + } addNext() } + if !pendingDatabaseItems.isEmpty { + _ = await mediaLibraryStore.upsertItems(pendingDatabaseItems, library: library) + mediaLibraryLiveItems = await mediaLibraryStore.items(libraryID: library.id) + } return values } return results.sorted { $0.0 < $1.0 }.map(\.1) @@ -539,7 +617,7 @@ final class AppModel: ObservableObject { 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) + 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 { @@ -547,7 +625,7 @@ final class AppModel: ObservableObject { 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) + 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) { @@ -586,6 +664,73 @@ final class AppModel: ObservableObject { return await sidecarData(for: file, api: api) } + private func writeMediaScrapeToCloud(_ items: [MediaLibraryItem], sidecars: [String: MediaSidecarFiles]) async -> Int { + var failures = 0 + var writtenKeys = Set() + for (index, item) in items.enumerated() { + if Task.isCancelled { break } + defer { mediaMetadataWriteProgress.completed = index + 1 } + guard item.isMatched, let files = sidecars[item.file.id] else { continue } + let parentKey = files.parentID ?? "root" + do { + if files.nfo == nil { + let stem = (item.file.name as NSString).deletingPathExtension + let name = "\(stem).nfo" + let key = "\(parentKey)/\(name.lowercased())" + if writtenKeys.insert(key).inserted { + try await uploadScrapeData(Data(mediaNFO(for: item).utf8), name: name, contentType: "application/xml", parentID: files.parentID) + } + } + if files.poster == nil, let data = item.posterData { + let key = "\(parentKey)/poster.jpg" + if writtenKeys.insert(key).inserted { try await uploadScrapeData(data, name: "poster.jpg", contentType: "image/jpeg", parentID: files.parentID) } + } + if files.backdrop == nil, let data = item.backdropData { + let key = "\(parentKey)/fanart.jpg" + if writtenKeys.insert(key).inserted { try await uploadScrapeData(data, name: "fanart.jpg", contentType: "image/jpeg", parentID: files.parentID) } + } + } catch { + failures += 1 + } + await Task.yield() + } + return failures + } + + private func startMediaMetadataWriteback(_ items: [MediaLibraryItem], sidecars: [String: MediaSidecarFiles]) { + mediaMetadataWriteTask?.cancel() + isWritingMediaMetadata = true + mediaMetadataWriteProgress = MediaLibraryScanProgress(phase: "正在写回资源目录…", completed: 0, total: items.count) + mediaMetadataWriteTask = Task { [weak self] in + guard let self else { return } + let failures = await self.writeMediaScrapeToCloud(items, sidecars: sidecars) + guard !Task.isCancelled else { self.isWritingMediaMetadata = false; return } + self.mediaMetadataWriteProgress.phase = failures == 0 ? "资源目录写回完成" : "写回完成,\(failures) 项失败" + self.isWritingMediaMetadata = false + } + } + + private func mediaNFO(for item: MediaLibraryItem) -> String { + let parsed = parseMediaName(item.file.name) + let root = parsed.isEpisode ? "episodedetails" : (item.mediaKind == .tv ? "tvshow" : "movie") + let uniqueID = item.tmdbID.map { "\($0)" } ?? "" + let collection = item.collectionName.map { name in + let identifier = item.collectionID.map { "\($0)" } ?? "" + return "\(name.xmlEscaped)\(identifier)" + } ?? "" + let episode = parsed.isEpisode ? "\(parsed.season ?? 0)\(parsed.episode ?? 0)" : "" + return "<\(root)>\(uniqueID)\(item.title.xmlEscaped)\(item.originalTitle.xmlEscaped)\(item.overview.xmlEscaped)\(item.releaseDate.xmlEscaped)\(collection)\(episode)" + } + + 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() } } diff --git a/guangya_mac/MediaLibraryStore.swift b/guangya_mac/MediaLibraryStore.swift index 01fadf8..ad20408 100644 --- a/guangya_mac/MediaLibraryStore.swift +++ b/guangya_mac/MediaLibraryStore.swift @@ -1,6 +1,12 @@ import Foundation import SQLite3 +enum MediaLibraryStoreError: LocalizedError { + case writeFailed + + var errorDescription: String? { "影视库数据库写入失败,未开始云端写回" } +} + actor MediaLibraryStore { static let shared = MediaLibraryStore() @@ -28,6 +34,20 @@ actor MediaLibraryStore { updated_at REAL ); """) + Self.execute(database, """ + CREATE TABLE IF NOT EXISTS media_library_sources ( + id TEXT PRIMARY KEY NOT NULL, + library_id TEXT NOT NULL, + root_id TEXT, + root_path TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE + ); + """) + Self.execute(database, """ + INSERT OR IGNORE INTO media_library_sources (id, library_id, root_id, root_path, sort_order) + SELECT id || '-legacy', id, root_id, root_path, 0 FROM media_libraries; + """) Self.execute(database, """ CREATE TABLE IF NOT EXISTS media_items ( library_id TEXT NOT NULL, @@ -47,6 +67,8 @@ actor MediaLibraryStore { backdrop BLOB, has_chinese_audio INTEGER NOT NULL DEFAULT 0, has_chinese_subtitle INTEGER NOT NULL DEFAULT 0, + collection_id INTEGER, + collection_name TEXT, updated_at REAL NOT NULL, PRIMARY KEY (library_id, file_id), FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE @@ -54,8 +76,12 @@ actor MediaLibraryStore { """) Self.execute(database, "ALTER TABLE media_items ADD COLUMN has_chinese_audio INTEGER NOT NULL DEFAULT 0;") Self.execute(database, "ALTER TABLE media_items ADD COLUMN has_chinese_subtitle INTEGER NOT NULL DEFAULT 0;") + Self.execute(database, "ALTER TABLE media_items ADD COLUMN collection_id INTEGER;") + Self.execute(database, "ALTER TABLE media_items ADD COLUMN collection_name TEXT;") Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_library_kind ON media_items(library_id, media_kind);") Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_gcid ON media_items(gcid);") + Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_collection ON media_items(library_id, collection_id);") + Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_library_sources_library ON media_library_sources(library_id, sort_order);") } deinit { if let database { sqlite3_close(database) } } @@ -71,7 +97,9 @@ actor MediaLibraryStore { guard let id = text(statement, 0), let name = text(statement, 1), let rootPath = text(statement, 3), let rawKind = text(statement, 4), let kind = MediaLibraryKind(rawValue: rawKind) else { continue } let epoch = integer(statement, 6) - values.append(MediaLibraryDefinition(id: id, name: name, rootID: text(statement, 2), rootPath: rootPath, kind: kind, recursive: sqlite3_column_int(statement, 5) != 0, updatedAt: epoch.map { Date(timeIntervalSince1970: TimeInterval($0)) })) + let sources = sources(libraryID: id) + let resolvedSources = sources.isEmpty ? [MediaLibrarySource(id: "\(id)-legacy", rootID: text(statement, 2), path: rootPath)] : sources + values.append(MediaLibraryDefinition(id: id, name: name, sources: resolvedSources, kind: kind, recursive: sqlite3_column_int(statement, 5) != 0, updatedAt: epoch.map { Date(timeIntervalSince1970: TimeInterval($0)) })) } return values } @@ -88,11 +116,13 @@ actor MediaLibraryStore { """ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return } defer { sqlite3_finalize(statement) } - bind(library.id, statement, 1); bind(library.name, statement, 2); bind(library.rootID, statement, 3) - bind(library.rootPath, statement, 4); bind(library.kind.rawValue, statement, 5) + let primarySource = library.sources.first + bind(library.id, statement, 1); bind(library.name, statement, 2); bind(primarySource?.rootID, statement, 3) + bind(primarySource?.path ?? "未配置目录", statement, 4); bind(library.kind.rawValue, statement, 5) sqlite3_bind_int(statement, 6, library.recursive ? 1 : 0) if let updatedAt = library.updatedAt { sqlite3_bind_double(statement, 7, updatedAt.timeIntervalSince1970) } else { sqlite3_bind_null(statement, 7) } sqlite3_step(statement) + replaceSources(library.sources, libraryID: library.id) } func delete(libraryID: String) { @@ -110,7 +140,7 @@ actor MediaLibraryStore { let sql = """ SELECT file_id, resource_path, cloud_name, file_size, gcid, file_type, tmdb_id, media_kind, title, original_title, release_date, overview, poster, backdrop, - has_chinese_audio, has_chinese_subtitle + has_chinese_audio, has_chinese_subtitle, collection_id, collection_name FROM media_items WHERE library_id = ?1 ORDER BY title COLLATE NOCASE; """ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] } @@ -129,26 +159,44 @@ actor MediaLibraryStore { if let gcid = text(statement, 4) { json["gcid"] = .string(gcid) } guard let file = CloudFile(json: .object(json)) else { continue } let kind = text(statement, 7).flatMap(TMDBMediaKind.init(rawValue:)) - values.append(MediaLibraryItem(file: file, tmdbID: integer(statement, 6).map(Int.init), title: title, originalTitle: originalTitle, mediaKind: kind, releaseDate: releaseDate, overview: overview, posterData: blob(statement, 12), backdropData: blob(statement, 13), hasChineseAudio: sqlite3_column_int(statement, 14) != 0, hasChineseSubtitle: sqlite3_column_int(statement, 15) != 0)) + values.append(MediaLibraryItem(file: file, tmdbID: integer(statement, 6).map(Int.init), title: title, originalTitle: originalTitle, mediaKind: kind, releaseDate: releaseDate, overview: overview, posterData: blob(statement, 12), backdropData: blob(statement, 13), hasChineseAudio: sqlite3_column_int(statement, 14) != 0, hasChineseSubtitle: sqlite3_column_int(statement, 15) != 0, collectionID: integer(statement, 16).map(Int.init), collectionName: text(statement, 17))) } return values } - func replaceItems(_ items: [MediaLibraryItem], library: MediaLibraryDefinition) { - guard let database else { return } + func replaceItems(_ items: [MediaLibraryItem], library: MediaLibraryDefinition) -> Bool { + writeItems(items, library: library, deleteExisting: true) + } + + func upsertItems(_ items: [MediaLibraryItem], library: MediaLibraryDefinition) -> Bool { + writeItems(items, library: library, deleteExisting: false) + } + + private func writeItems(_ items: [MediaLibraryItem], library: MediaLibraryDefinition, deleteExisting: Bool) -> Bool { + guard let database else { return false } Self.execute(database, "BEGIN IMMEDIATE TRANSACTION;") - var deleteStatement: OpaquePointer? - if sqlite3_prepare_v2(database, "DELETE FROM media_items WHERE library_id = ?1;", -1, &deleteStatement, nil) == SQLITE_OK, let deleteStatement { - bind(library.id, deleteStatement, 1); sqlite3_step(deleteStatement); sqlite3_finalize(deleteStatement) + if deleteExisting { + var deleteStatement: OpaquePointer? + if sqlite3_prepare_v2(database, "DELETE FROM media_items WHERE library_id = ?1;", -1, &deleteStatement, nil) == SQLITE_OK, let deleteStatement { + bind(library.id, deleteStatement, 1); sqlite3_step(deleteStatement); sqlite3_finalize(deleteStatement) + } } var statement: OpaquePointer? let sql = """ INSERT INTO media_items (library_id, file_id, resource_path, cloud_name, file_size, gcid, file_type, tmdb_id, media_kind, title, original_title, release_date, overview, poster, backdrop, - has_chinese_audio, has_chinese_subtitle, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18); + has_chinese_audio, has_chinese_subtitle, collection_id, collection_name, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20) + ON CONFLICT(library_id, file_id) DO UPDATE SET + resource_path=excluded.resource_path, cloud_name=excluded.cloud_name, file_size=excluded.file_size, + gcid=excluded.gcid, file_type=excluded.file_type, tmdb_id=excluded.tmdb_id, + media_kind=excluded.media_kind, title=excluded.title, original_title=excluded.original_title, + release_date=excluded.release_date, overview=excluded.overview, poster=excluded.poster, + backdrop=excluded.backdrop, has_chinese_audio=excluded.has_chinese_audio, + has_chinese_subtitle=excluded.has_chinese_subtitle, collection_id=excluded.collection_id, + collection_name=excluded.collection_name, updated_at=excluded.updated_at; """ - guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { Self.execute(database, "ROLLBACK;"); return } + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { Self.execute(database, "ROLLBACK;"); return false } defer { sqlite3_finalize(statement) } for item in items { sqlite3_reset(statement); sqlite3_clear_bindings(statement) @@ -158,10 +206,41 @@ actor MediaLibraryStore { bind(item.mediaKind?.rawValue, statement, 9); bind(item.title, statement, 10); bind(item.originalTitle, statement, 11) bind(item.releaseDate, statement, 12); bind(item.overview, statement, 13); bind(item.posterData, statement, 14); bind(item.backdropData, statement, 15) sqlite3_bind_int(statement, 16, item.hasChineseAudio ? 1 : 0); sqlite3_bind_int(statement, 17, item.hasChineseSubtitle ? 1 : 0) - sqlite3_bind_double(statement, 18, Date().timeIntervalSince1970) - guard sqlite3_step(statement) == SQLITE_DONE else { Self.execute(database, "ROLLBACK;"); return } + bind(item.collectionID.map(Int64.init), statement, 18); bind(item.collectionName, statement, 19) + sqlite3_bind_double(statement, 20, Date().timeIntervalSince1970) + guard sqlite3_step(statement) == SQLITE_DONE else { Self.execute(database, "ROLLBACK;"); return false } + } + return Self.execute(database, "COMMIT;") + } + + private func sources(libraryID: String) -> [MediaLibrarySource] { + guard let database else { return [] } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "SELECT id, root_id, root_path FROM media_library_sources WHERE library_id = ?1 ORDER BY sort_order, root_path COLLATE NOCASE;", -1, &statement, nil) == SQLITE_OK, let statement else { return [] } + defer { sqlite3_finalize(statement) } + bind(libraryID, statement, 1) + var values: [MediaLibrarySource] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard let id = text(statement, 0), let path = text(statement, 2) else { continue } + values.append(MediaLibrarySource(id: id, rootID: text(statement, 1), path: path)) + } + return values + } + + private func replaceSources(_ sources: [MediaLibrarySource], libraryID: String) { + guard let database else { return } + var deleteStatement: OpaquePointer? + if sqlite3_prepare_v2(database, "DELETE FROM media_library_sources WHERE library_id = ?1;", -1, &deleteStatement, nil) == SQLITE_OK, let deleteStatement { + bind(libraryID, deleteStatement, 1); sqlite3_step(deleteStatement); sqlite3_finalize(deleteStatement) + } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "INSERT INTO media_library_sources (id, library_id, root_id, root_path, sort_order) VALUES (?1, ?2, ?3, ?4, ?5);", -1, &statement, nil) == SQLITE_OK, let statement else { return } + defer { sqlite3_finalize(statement) } + for (index, source) in sources.enumerated() { + sqlite3_reset(statement); sqlite3_clear_bindings(statement) + bind(source.id, statement, 1); bind(libraryID, statement, 2); bind(source.rootID, statement, 3); bind(source.path, statement, 4) + sqlite3_bind_int(statement, 5, Int32(index)); sqlite3_step(statement) } - Self.execute(database, "COMMIT;") } private func bind(_ value: String?, _ statement: OpaquePointer, _ index: Int32) { @@ -198,6 +277,8 @@ final class MediaNFOParser: NSObject, XMLParserDelegate { private var uniqueIDIsTMDB = false private var hasChineseAudio = false private var hasChineseSubtitle = false + private var collectionID: Int? + private var collectionName: String? static func parse(_ data: Data) -> ParsedMediaNFO? { let delegate = MediaNFOParser() @@ -205,7 +286,7 @@ final class MediaNFOParser: NSObject, XMLParserDelegate { parser.delegate = delegate guard parser.parse(), !delegate.title.isEmpty else { return nil } let kind: TMDBMediaKind? = delegate.rootElement == "tvshow" || delegate.rootElement == "episodedetails" ? .tv : (delegate.rootElement == "movie" ? .movie : nil) - return ParsedMediaNFO(tmdbID: delegate.tmdbID, title: delegate.title, originalTitle: delegate.originalTitle.isEmpty ? delegate.title : delegate.originalTitle, overview: delegate.overview, releaseDate: delegate.releaseDate, mediaKind: kind, hasChineseAudio: delegate.hasChineseAudio, hasChineseSubtitle: delegate.hasChineseSubtitle) + return ParsedMediaNFO(tmdbID: delegate.tmdbID, title: delegate.title, originalTitle: delegate.originalTitle.isEmpty ? delegate.title : delegate.originalTitle, overview: delegate.overview, releaseDate: delegate.releaseDate, mediaKind: kind, hasChineseAudio: delegate.hasChineseAudio, hasChineseSubtitle: delegate.hasChineseSubtitle, collectionID: delegate.collectionID, collectionName: delegate.collectionName) } func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { @@ -229,6 +310,8 @@ final class MediaNFOParser: NSObject, XMLParserDelegate { case "year": if releaseDate.isEmpty { releaseDate = value } case "uniqueid": if uniqueIDIsTMDB { tmdbID = Int(value) } case "tmdbid": tmdbID = Int(value) + case "setid", "collectionid": collectionID = Int(value) + case "name": if parent == "set" || parent == "collection" { collectionName = value } case "language": let isChinese = Self.isChineseLanguage(value) if parent == "audio" { hasChineseAudio = hasChineseAudio || isChinese } diff --git a/guangya_mac/Models.swift b/guangya_mac/Models.swift index a8494d2..c05dfac 100644 --- a/guangya_mac/Models.swift +++ b/guangya_mac/Models.swift @@ -496,12 +496,26 @@ struct MediaLibraryItem: Identifiable, Sendable { let backdropData: Data? let hasChineseAudio: Bool let hasChineseSubtitle: Bool + let collectionID: Int? + let collectionName: String? var id: String { file.id } var year: String { String(releaseDate.prefix(4)) } var isMatched: Bool { mediaKind != nil } } +struct MediaLibrarySource: Identifiable, Hashable, Sendable { + let id: String + let rootID: String? + let path: String + + init(id: String = UUID().uuidString, rootID: String?, path: String) { + self.id = id + self.rootID = rootID + self.path = path + } +} + enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable { case movies case series @@ -527,11 +541,29 @@ enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable { struct MediaLibraryDefinition: Identifiable, Hashable, Sendable { let id: String var name: String - var rootID: String? - var rootPath: String + var sources: [MediaLibrarySource] var kind: MediaLibraryKind var recursive: Bool var updatedAt: Date? + + var rootID: String? { sources.first?.rootID } + var rootPath: String { + if sources.count == 1 { return sources[0].path } + return sources.isEmpty ? "未配置目录" : "\(sources.count) 个媒体目录" + } + + init(id: String, name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool, updatedAt: Date?) { + self.id = id + self.name = name + self.sources = sources + self.kind = kind + self.recursive = recursive + self.updatedAt = updatedAt + } + + init(id: String, name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool, updatedAt: Date?) { + self.init(id: id, name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive, updatedAt: updatedAt) + } } struct MediaLibraryScanProgress: Sendable { @@ -541,6 +573,7 @@ struct MediaLibraryScanProgress: Sendable { } struct MediaSidecarFiles: Sendable { + let parentID: String? let nfo: CloudFile? let poster: CloudFile? let backdrop: CloudFile? @@ -561,6 +594,8 @@ struct ParsedMediaNFO: Sendable { let mediaKind: TMDBMediaKind? let hasChineseAudio: Bool let hasChineseSubtitle: Bool + let collectionID: Int? + let collectionName: String? } struct ParsedMediaName: Sendable {