Files
guangya_client/guangya_mac/MediaLibraryStore.swift
T

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
}
}