feat: 支持多目录影视库与增量刮削入库

This commit is contained in:
2026-07-16 14:02:32 +08:00
parent 3c27e04b2e
commit 6526e6cdc8
3 changed files with 295 additions and 32 deletions
+158 -13
View File
@@ -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<Void, Never>?
private var mediaMetadataWriteTask: Task<Void, Never>?
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..<min(4, files.count) { addNext() }
var values: [CloudFile] = []
while let file = await group.next() { values.append(file); addNext() }
return values
}
}
private func discoverMediaFiles(parentID: String?, rootPath: String, recursive: Bool) async throws -> 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..<min(4, requests.count) { addNext() }
var values: [(Int, MediaLibraryItem)] = []
var pendingDatabaseItems: [MediaLibraryItem] = []
while let value = await group.next() {
values.append(value)
pendingDatabaseItems.append(value.1)
mediaLibraryScanProgress.completed = values.count
if pendingDatabaseItems.count >= 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<String>()
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 { "<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)"
} ?? ""
let episode = parsed.isEpisode ? "<season>\(parsed.season ?? 0)</season><episode>\(parsed.episode ?? 0)</episode>" : ""
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)\(episode)</\(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() }
}
+100 -17
View File
@@ -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 }
+37 -2
View File
@@ -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 {