0df8013d3e
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
493 lines
29 KiB
Swift
493 lines
29 KiB
Swift
import Foundation
|
|
import SQLite3
|
|
|
|
enum MediaLibraryStoreError: LocalizedError {
|
|
case writeFailed
|
|
case backupFailed
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .writeFailed: return "影视库数据库写入失败,未开始云端写回"
|
|
case .backupFailed: return "影视库缓存备份文件无效或无法写入"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MediaLibraryStatistics: Sendable, Equatable {
|
|
var total = 0
|
|
var movies = 0
|
|
var series = 0
|
|
var unmatched = 0
|
|
var collections = 0
|
|
}
|
|
|
|
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,
|
|
minimum_size_mb INTEGER NOT NULL DEFAULT 50,
|
|
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, "ALTER TABLE media_libraries ADD COLUMN minimum_size_mb INTEGER NOT NULL DEFAULT 50;")
|
|
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_library_title ON media_items(library_id, title COLLATE NOCASE);")
|
|
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_title ON media_items(title COLLATE NOCASE);")
|
|
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 isEmpty() -> Bool {
|
|
guard let database else { return true }
|
|
return Self.scalarCount(database, sql: "SELECT COUNT(*) FROM media_libraries;") == 0 &&
|
|
Self.scalarCount(database, sql: "SELECT COUNT(*) FROM media_items;") == 0
|
|
}
|
|
|
|
func exportBackup(to url: URL) throws {
|
|
guard let database else { throw MediaLibraryStoreError.backupFailed }
|
|
try? FileManager.default.removeItem(at: url)
|
|
var destination: OpaquePointer?
|
|
guard sqlite3_open_v2(url.path, &destination, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK,
|
|
let destination else { throw MediaLibraryStoreError.backupFailed }
|
|
defer { sqlite3_close(destination) }
|
|
guard let backup = sqlite3_backup_init(destination, "main", database, "main") else { throw MediaLibraryStoreError.backupFailed }
|
|
defer { sqlite3_backup_finish(backup) }
|
|
guard sqlite3_backup_step(backup, -1) == SQLITE_DONE else { throw MediaLibraryStoreError.backupFailed }
|
|
}
|
|
|
|
func importBackup(from url: URL) throws {
|
|
guard let database else { throw MediaLibraryStoreError.backupFailed }
|
|
var source: OpaquePointer?
|
|
guard sqlite3_open_v2(url.path, &source, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK,
|
|
let source else { throw MediaLibraryStoreError.backupFailed }
|
|
defer { sqlite3_close(source) }
|
|
guard let backup = sqlite3_backup_init(database, "main", source, "main") else { throw MediaLibraryStoreError.backupFailed }
|
|
defer { sqlite3_backup_finish(backup) }
|
|
guard sqlite3_backup_step(backup, -1) == SQLITE_DONE else { throw MediaLibraryStoreError.backupFailed }
|
|
Self.execute(database, "PRAGMA foreign_keys=ON;")
|
|
}
|
|
|
|
func libraries() -> [MediaLibraryDefinition] {
|
|
guard let database else { return [] }
|
|
var statement: OpaquePointer?
|
|
let sql = "SELECT id, name, root_id, root_path, kind, recursive, minimum_size_mb, 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 minimumSizeMB = Int(integer(statement, 6) ?? 50)
|
|
let epoch = integer(statement, 7)
|
|
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, minimumSizeMB: minimumSizeMB, 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, minimum_size_mb, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
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,
|
|
minimum_size_mb=excluded.minimum_size_mb, 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)
|
|
sqlite3_bind_int(statement, 7, Int32(library.minimumSizeMB))
|
|
if let updatedAt = library.updatedAt { sqlite3_bind_double(statement, 8, updatedAt.timeIntervalSince1970) } else { sqlite3_bind_null(statement, 8) }
|
|
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] {
|
|
loadItems(libraryID: libraryID, limit: nil, offset: 0, includeBackdrop: true)
|
|
}
|
|
|
|
/// Poster-wall reads deliberately omit full backdrops. Background artwork
|
|
/// is substantially larger than a poster and decoding every row before the
|
|
/// first view appears made even indexed libraries feel slow.
|
|
func itemPage(libraryID: String?, limit: Int, offset: Int) -> [MediaLibraryItem] {
|
|
loadItems(libraryID: libraryID, limit: max(1, limit), offset: max(0, offset), includeBackdrop: false)
|
|
}
|
|
|
|
/// Migrates cache entries created before zero-padded disc streams were
|
|
/// filtered at scan time. This intentionally reads only IDs and names — no
|
|
/// poster/backdrop BLOBs are materialised during cleanup.
|
|
func removeZeroPaddedMediaEntries() -> [(libraryID: String, fileID: String)] {
|
|
guard let database else { return [] }
|
|
var select: OpaquePointer?
|
|
let sql = "SELECT library_id, file_id, cloud_name FROM media_items WHERE file_type = 2;"
|
|
guard sqlite3_prepare_v2(database, sql, -1, &select, nil) == SQLITE_OK, let select else { return [] }
|
|
defer { sqlite3_finalize(select) }
|
|
var values: [(libraryID: String, fileID: String)] = []
|
|
while sqlite3_step(select) == SQLITE_ROW {
|
|
guard let libraryID = text(select, 0), let fileID = text(select, 1), let name = text(select, 2) else { continue }
|
|
let stem = (name as NSString).deletingPathExtension
|
|
if stem.range(of: "^0{2,}\\d+$", options: .regularExpression) != nil {
|
|
values.append((libraryID, fileID))
|
|
}
|
|
}
|
|
guard !values.isEmpty else { return [] }
|
|
Self.execute(database, "BEGIN IMMEDIATE TRANSACTION;")
|
|
var delete: OpaquePointer?
|
|
guard sqlite3_prepare_v2(database, "DELETE FROM media_items WHERE library_id = ?1 AND file_id = ?2;", -1, &delete, nil) == SQLITE_OK, let delete else {
|
|
Self.execute(database, "ROLLBACK;")
|
|
return []
|
|
}
|
|
defer { sqlite3_finalize(delete) }
|
|
for value in values {
|
|
sqlite3_reset(delete); sqlite3_clear_bindings(delete)
|
|
bind(value.libraryID, delete, 1); bind(value.fileID, delete, 2)
|
|
guard sqlite3_step(delete) == SQLITE_DONE else { Self.execute(database, "ROLLBACK;"); return [] }
|
|
}
|
|
return Self.execute(database, "COMMIT;") ? values : []
|
|
}
|
|
|
|
/// Lightweight aggregation for navigation badges. It touches only indexed
|
|
/// scalar columns and never materialises media rows or artwork BLOBs.
|
|
func statistics() -> MediaLibraryStatistics {
|
|
guard let database else { return .init() }
|
|
var statement: OpaquePointer?
|
|
let sql = """
|
|
SELECT COUNT(*),
|
|
COALESCE(SUM(CASE WHEN media_kind = 'movie' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN media_kind = 'tv' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN tmdb_id IS NULL THEN 1 ELSE 0 END), 0),
|
|
COUNT(DISTINCT CASE
|
|
WHEN collection_id IS NOT NULL THEN 'id:' || collection_id
|
|
WHEN collection_name IS NOT NULL AND collection_name <> '' THEN 'name:' || collection_name
|
|
END)
|
|
FROM media_items;
|
|
"""
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return .init() }
|
|
defer { sqlite3_finalize(statement) }
|
|
guard sqlite3_step(statement) == SQLITE_ROW else { return .init() }
|
|
return MediaLibraryStatistics(
|
|
total: Int(sqlite3_column_int64(statement, 0)),
|
|
movies: Int(sqlite3_column_int64(statement, 1)),
|
|
series: Int(sqlite3_column_int64(statement, 2)),
|
|
unmatched: Int(sqlite3_column_int64(statement, 3)),
|
|
collections: Int(sqlite3_column_int64(statement, 4))
|
|
)
|
|
}
|
|
|
|
func statisticsByLibraryID() -> [String: MediaLibraryStatistics] {
|
|
guard let database else { return [:] }
|
|
var statement: OpaquePointer?
|
|
let sql = """
|
|
SELECT library_id, COUNT(*),
|
|
COALESCE(SUM(CASE WHEN media_kind = 'movie' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN media_kind = 'tv' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN tmdb_id IS NULL THEN 1 ELSE 0 END), 0),
|
|
COUNT(DISTINCT CASE
|
|
WHEN collection_id IS NOT NULL THEN 'id:' || collection_id
|
|
WHEN collection_name IS NOT NULL AND collection_name <> '' THEN 'name:' || collection_name
|
|
END)
|
|
FROM media_items GROUP BY library_id;
|
|
"""
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [:] }
|
|
defer { sqlite3_finalize(statement) }
|
|
var values: [String: MediaLibraryStatistics] = [:]
|
|
while sqlite3_step(statement) == SQLITE_ROW {
|
|
guard let libraryID = text(statement, 0) else { continue }
|
|
values[libraryID] = MediaLibraryStatistics(
|
|
total: Int(sqlite3_column_int64(statement, 1)),
|
|
movies: Int(sqlite3_column_int64(statement, 2)),
|
|
series: Int(sqlite3_column_int64(statement, 3)),
|
|
unmatched: Int(sqlite3_column_int64(statement, 4)),
|
|
collections: Int(sqlite3_column_int64(statement, 5))
|
|
)
|
|
}
|
|
return values
|
|
}
|
|
|
|
private func loadItems(libraryID: String?, limit: Int?, offset: Int, includeBackdrop: Bool) -> [MediaLibraryItem] {
|
|
guard let database else { return [] }
|
|
var statement: OpaquePointer?
|
|
let scope = libraryID == nil ? "" : "WHERE library_id = ?1"
|
|
let backdropColumn = includeBackdrop ? "backdrop" : "NULL AS backdrop"
|
|
let pagination = limit.map { " LIMIT \($0) OFFSET \(offset)" } ?? ""
|
|
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, \(backdropColumn),
|
|
has_chinese_audio, has_chinese_subtitle, collection_id, collection_name
|
|
FROM media_items \(scope) ORDER BY title COLLATE NOCASE\(pagination);
|
|
"""
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] }
|
|
defer { sqlite3_finalize(statement) }
|
|
if let libraryID { 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 static func scalarCount(_ database: OpaquePointer, sql: String) -> Int {
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return 0 }
|
|
defer { sqlite3_finalize(statement) }
|
|
return sqlite3_step(statement) == SQLITE_ROW ? Int(sqlite3_column_int64(statement, 0)) : 0
|
|
}
|
|
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)
|
|
}
|
|
}
|