226 lines
13 KiB
Swift
226 lines
13 KiB
Swift
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
|
|
}
|
|
}
|