import Foundation import SQLite3 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_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, 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, "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);") } 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) 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)) })) } 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) } 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) 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) } 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 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))) } return values } func replaceItems(_ items: [MediaLibraryItem], library: MediaLibraryDefinition) { guard let database else { return } 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) } 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, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16); """ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { Self.execute(database, "ROLLBACK;"); return } 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_double(statement, 16, Date().timeIntervalSince1970) guard sqlite3_step(statement) == SQLITE_DONE else { Self.execute(database, "ROLLBACK;"); return } } Self.execute(database, "COMMIT;") } 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 rootElement = "" private var tmdbID: Int? private var title = "" private var originalTitle = "" private var overview = "" private var releaseDate = "" private var uniqueIDIsTMDB = false 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) } 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() 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) 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) default: break } currentText = "" uniqueIDIsTMDB = false } }