import Foundation import SQLite3 enum MediaLibraryStoreError: LocalizedError { case writeFailed var errorDescription: String? { "影视库数据库写入失败,未开始云端写回" } } actor MediaLibraryStore { static let shared = MediaLibraryStore() private var database: OpaquePointer? private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) private init() { guard let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } let directory = baseURL.appendingPathComponent("com.example.guangya-mac", isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let url = directory.appendingPathComponent("media-library.sqlite3") guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, let database else { self.database = nil; return } Self.execute(database, "PRAGMA journal_mode=WAL;") Self.execute(database, "PRAGMA synchronous=NORMAL;") Self.execute(database, "PRAGMA foreign_keys=ON;") Self.execute(database, """ CREATE TABLE IF NOT EXISTS media_libraries ( id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, root_id TEXT, root_path TEXT NOT NULL, kind TEXT NOT NULL, recursive INTEGER NOT NULL DEFAULT 1, 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, file_id TEXT NOT NULL, resource_path TEXT NOT NULL, cloud_name TEXT NOT NULL, file_size INTEGER, gcid TEXT, file_type INTEGER NOT NULL, tmdb_id INTEGER, media_kind TEXT, title TEXT NOT NULL, original_title TEXT NOT NULL, release_date TEXT NOT NULL, overview TEXT NOT NULL, poster BLOB, 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 ); """) 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) } } func libraries() -> [MediaLibraryDefinition] { guard let database else { return [] } var statement: OpaquePointer? let sql = "SELECT id, name, root_id, root_path, kind, recursive, updated_at FROM media_libraries ORDER BY name COLLATE NOCASE;" guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] } defer { sqlite3_finalize(statement) } var values: [MediaLibraryDefinition] = [] while sqlite3_step(statement) == SQLITE_ROW { 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) 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 } func save(library: MediaLibraryDefinition) { guard let database else { return } var statement: OpaquePointer? let sql = """ INSERT INTO media_libraries (id, name, root_id, root_path, kind, recursive, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(id) DO UPDATE SET name=excluded.name, root_id=excluded.root_id, root_path=excluded.root_path, kind=excluded.kind, recursive=excluded.recursive, updated_at=excluded.updated_at; """ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return } defer { sqlite3_finalize(statement) } 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) { guard let database else { return } var statement: OpaquePointer? guard sqlite3_prepare_v2(database, "DELETE FROM media_libraries WHERE id = ?1;", -1, &statement, nil) == SQLITE_OK, let statement else { return } defer { sqlite3_finalize(statement) } bind(libraryID, statement, 1) sqlite3_step(statement) } func items(libraryID: String) -> [MediaLibraryItem] { guard let database else { return [] } var statement: OpaquePointer? 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, 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 [] } defer { sqlite3_finalize(statement) } bind(libraryID, statement, 1) var values: [MediaLibraryItem] = [] while sqlite3_step(statement) == SQLITE_ROW { guard let fileID = text(statement, 0), let path = text(statement, 1), let name = text(statement, 2), let title = text(statement, 8), let originalTitle = text(statement, 9), let releaseDate = text(statement, 10), let overview = text(statement, 11) else { continue } var json: [String: JSONValue] = [ "fileId": .string(fileID), "name": .string(name), "resType": .number(1), "fileType": .number(Double(integer(statement, 5) ?? 2)), "path": .string(path) ] if let size = integer(statement, 3) { json["size"] = .number(Double(size)) } 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, collectionID: integer(statement, 16).map(Int.init), collectionName: text(statement, 17))) } return values } 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;") 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, 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 false } defer { sqlite3_finalize(statement) } for item in items { sqlite3_reset(statement); sqlite3_clear_bindings(statement) bind(library.id, statement, 1); bind(item.file.id, statement, 2); bind(item.file.cloudPath, statement, 3) bind(item.file.name, statement, 4); bind(item.file.size, statement, 5); bind(item.file.gcid, statement, 6) sqlite3_bind_int64(statement, 7, Int64(item.file.fileType)); bind(item.tmdbID.map(Int64.init), statement, 8) 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) 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) } } private func bind(_ value: String?, _ statement: OpaquePointer, _ index: Int32) { if let value { sqlite3_bind_text(statement, index, value, -1, transient) } else { sqlite3_bind_null(statement, index) } } private func bind(_ value: Int64?, _ statement: OpaquePointer, _ index: Int32) { if let value { sqlite3_bind_int64(statement, index, value) } else { sqlite3_bind_null(statement, index) } } private func bind(_ value: Data?, _ statement: OpaquePointer, _ index: Int32) { if let value { _ = value.withUnsafeBytes { sqlite3_bind_blob(statement, index, $0.baseAddress, Int32(value.count), transient) } } else { sqlite3_bind_null(statement, index) } } private func text(_ statement: OpaquePointer, _ column: Int32) -> String? { guard sqlite3_column_type(statement, column) != SQLITE_NULL, let value = sqlite3_column_text(statement, column) else { return nil } return String(cString: value) } private func integer(_ statement: OpaquePointer, _ column: Int32) -> Int64? { sqlite3_column_type(statement, column) == SQLITE_NULL ? nil : sqlite3_column_int64(statement, column) } private func blob(_ statement: OpaquePointer, _ column: Int32) -> Data? { guard sqlite3_column_type(statement, column) != SQLITE_NULL, let bytes = sqlite3_column_blob(statement, column) else { return nil } return Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, column))) } @discardableResult private nonisolated static func execute(_ database: OpaquePointer, _ sql: String) -> Bool { sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK } } final class MediaNFOParser: NSObject, XMLParserDelegate { private var currentElement = "" private var currentText = "" private var elementStack: [String] = [] private var rootElement = "" private var tmdbID: Int? private var title = "" private var originalTitle = "" private var overview = "" private var releaseDate = "" 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() let parser = XMLParser(data: data) 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, collectionID: delegate.collectionID, collectionName: delegate.collectionName) } func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { if rootElement.isEmpty { rootElement = elementName.lowercased() } currentElement = elementName.lowercased() elementStack.append(currentElement) currentText = "" uniqueIDIsTMDB = currentElement == "uniqueid" && attributeDict["type"]?.lowercased() == "tmdb" } func parser(_ parser: XMLParser, foundCharacters string: String) { currentText += string } func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { let value = currentText.trimmingCharacters(in: .whitespacesAndNewlines) let parent = elementStack.dropLast().last switch elementName.lowercased() { case "title": if title.isEmpty { title = value } case "originaltitle": originalTitle = value case "plot", "outline": if overview.isEmpty { overview = value } case "premiered", "releasedate": if releaseDate.isEmpty { releaseDate = value } 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 } if parent == "subtitle" { hasChineseSubtitle = hasChineseSubtitle || isChinese } default: break } if !elementStack.isEmpty { elementStack.removeLast() } currentText = "" uniqueIDIsTMDB = false } private static func isChineseLanguage(_ value: String) -> Bool { let normalized = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) return ["zh", "zh-cn", "zh-tw", "chi", "zho", "chs", "cht", "chinese", "mandarin", "cantonese", "中文", "国语", "粤语"].contains(normalized) } }