feat: 支持多目录影视库与增量刮削入库
This commit is contained in:
+158
-13
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user