|
|
|
@ -147,6 +147,37 @@ nonisolated final class DatabaseService: Sendable { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Returns `true` only if the SQLite file at `path` is a complete, well-formed |
|
|
|
|
|
|
|
/// database. Opens a throwaway **read-only** connection (so it never flips the |
|
|
|
|
|
|
|
/// file to WAL or creates side files) and runs `PRAGMA quick_check`, which walks |
|
|
|
|
|
|
|
/// every b-tree page. This catches a truncated or inconsistent copy *before* it |
|
|
|
|
|
|
|
/// reaches GRDB — where it would otherwise blow up with the opaque |
|
|
|
|
|
|
|
/// "database disk image is malformed" (`SQLITE_CORRUPT`) error mid-query. |
|
|
|
|
|
|
|
static func isWellFormedDatabase(atPath path: String) -> Bool { |
|
|
|
|
|
|
|
guard FileManager.default.fileExists(atPath: path) else { return false } |
|
|
|
|
|
|
|
do { |
|
|
|
|
|
|
|
var config = Configuration() |
|
|
|
|
|
|
|
config.readonly = true |
|
|
|
|
|
|
|
let queue = try DatabaseQueue(path: path, configuration: config) |
|
|
|
|
|
|
|
return try queue.read { db in |
|
|
|
|
|
|
|
// 1. quick_check walks every b-tree page — catches truncated/corrupt images. |
|
|
|
|
|
|
|
let check = try String.fetchOne(db, sql: "PRAGMA quick_check") ?? "unknown" |
|
|
|
|
|
|
|
guard check == "ok" else { return false } |
|
|
|
|
|
|
|
// 2. A 0-byte or schema-less file is *valid* but empty SQLite, which would |
|
|
|
|
|
|
|
// yield an empty remote library. Require the core `tracks` table so an |
|
|
|
|
|
|
|
// empty/wrong file is rejected too. (Existence, not row count — an empty |
|
|
|
|
|
|
|
// library with the table present is legitimate.) |
|
|
|
|
|
|
|
let hasTracks = try Int.fetchOne( |
|
|
|
|
|
|
|
db, |
|
|
|
|
|
|
|
sql: "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'tracks'" |
|
|
|
|
|
|
|
) ?? 0 |
|
|
|
|
|
|
|
return hasTracks > 0 |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} catch { |
|
|
|
|
|
|
|
return false |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Write |
|
|
|
// MARK: - Write |
|
|
|
|
|
|
|
|
|
|
|
func insert(_ track: inout Track) throws { |
|
|
|
func insert(_ track: inout Track) throws { |
|
|
|
@ -172,6 +203,15 @@ nonisolated final class DatabaseService: Sendable { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Full-record update for metadata edits. The tracks_ft FTS5 index is kept in |
|
|
|
|
|
|
|
// sync automatically by the triggers installed via synchronize(withTable:), |
|
|
|
|
|
|
|
// so no manual FTS write is needed here. |
|
|
|
|
|
|
|
func updateTrack(_ track: Track) throws { |
|
|
|
|
|
|
|
try dbPool.write { db in |
|
|
|
|
|
|
|
try track.update(db) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
func deleteTracksWithURLs(_ urls: Set<String>) throws { |
|
|
|
func deleteTracksWithURLs(_ urls: Set<String>) throws { |
|
|
|
try dbPool.write { db in |
|
|
|
try dbPool.write { db in |
|
|
|
let placeholders = databaseQuestionMarks(count: urls.count) |
|
|
|
let placeholders = databaseQuestionMarks(count: urls.count) |
|
|
|
@ -189,6 +229,21 @@ nonisolated final class DatabaseService: Sendable { |
|
|
|
"trackNumber", "dateAdded", "playCount", "rating", "bpm" |
|
|
|
"trackNumber", "dateAdded", "playCount", "rating", "bpm" |
|
|
|
] |
|
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// Builds the SQL `ORDER BY` expression (without the `ORDER BY` keyword) for a track |
|
|
|
|
|
|
|
/// list. `column` is whitelisted against `validSortColumns`, so it is safe to |
|
|
|
|
|
|
|
/// interpolate. When sorting by `album`, a secondary `discNumber, trackNumber` |
|
|
|
|
|
|
|
/// ascending sort is appended so tracks within an album stay in playing order — |
|
|
|
|
|
|
|
/// always ascending, even when the album sort itself is descending. |
|
|
|
|
|
|
|
private static func orderByClause(column: String, ascending: Bool) -> String { |
|
|
|
|
|
|
|
let col = validSortColumns.contains(column) ? column : "title" |
|
|
|
|
|
|
|
let order = ascending ? "ASC" : "DESC" |
|
|
|
|
|
|
|
var clause = "\(col) COLLATE NOCASE \(order)" |
|
|
|
|
|
|
|
if col == "album" { |
|
|
|
|
|
|
|
clause += ", discNumber ASC, trackNumber ASC" |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return clause |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
func fetchTracks(search: String, sortColumn: String, ascending: Bool) throws -> [Track] { |
|
|
|
func fetchTracks(search: String, sortColumn: String, ascending: Bool) throws -> [Track] { |
|
|
|
try dbPool.read { db in |
|
|
|
try dbPool.read { db in |
|
|
|
try self.fetchTracks(db: db, search: search, sortColumn: sortColumn, ascending: ascending) |
|
|
|
try self.fetchTracks(db: db, search: search, sortColumn: sortColumn, ascending: ascending) |
|
|
|
@ -197,13 +252,12 @@ nonisolated final class DatabaseService: Sendable { |
|
|
|
|
|
|
|
|
|
|
|
// Used by ValueObservation which already holds a Database access |
|
|
|
// Used by ValueObservation which already holds a Database access |
|
|
|
func fetchTracks(db: Database, search: String, sortColumn: String, ascending: Bool) throws -> [Track] { |
|
|
|
func fetchTracks(db: Database, search: String, sortColumn: String, ascending: Bool) throws -> [Track] { |
|
|
|
let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title" |
|
|
|
let orderBy = Self.orderByClause(column: sortColumn, ascending: ascending) |
|
|
|
let order = ascending ? "ASC" : "DESC" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if search.trimmingCharacters(in: .whitespaces).isEmpty { |
|
|
|
if search.trimmingCharacters(in: .whitespaces).isEmpty { |
|
|
|
return try Track.fetchAll( |
|
|
|
return try Track.fetchAll( |
|
|
|
db, |
|
|
|
db, |
|
|
|
sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)" |
|
|
|
sql: "SELECT * FROM tracks ORDER BY \(orderBy)" |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@ -216,7 +270,7 @@ nonisolated final class DatabaseService: Sendable { |
|
|
|
SELECT tracks.* FROM tracks |
|
|
|
SELECT tracks.* FROM tracks |
|
|
|
JOIN tracks_ft ON tracks_ft.rowid = tracks.id |
|
|
|
JOIN tracks_ft ON tracks_ft.rowid = tracks.id |
|
|
|
WHERE tracks_ft MATCH ? |
|
|
|
WHERE tracks_ft MATCH ? |
|
|
|
ORDER BY \(col) COLLATE NOCASE \(order) |
|
|
|
ORDER BY \(orderBy) |
|
|
|
""", |
|
|
|
""", |
|
|
|
arguments: [pattern] |
|
|
|
arguments: [pattern] |
|
|
|
) |
|
|
|
) |
|
|
|
@ -229,15 +283,14 @@ nonisolated final class DatabaseService: Sendable { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] { |
|
|
|
func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] { |
|
|
|
let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title" |
|
|
|
let orderBy = Self.orderByClause(column: sortColumn, ascending: ascending) |
|
|
|
let order = ascending ? "ASC" : "DESC" |
|
|
|
|
|
|
|
let (whereSQL, args) = buildWhereClause(conditions) |
|
|
|
let (whereSQL, args) = buildWhereClause(conditions) |
|
|
|
if whereSQL.isEmpty { |
|
|
|
if whereSQL.isEmpty { |
|
|
|
return try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)") |
|
|
|
return try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY \(orderBy)") |
|
|
|
} |
|
|
|
} |
|
|
|
return try Track.fetchAll( |
|
|
|
return try Track.fetchAll( |
|
|
|
db, |
|
|
|
db, |
|
|
|
sql: "SELECT * FROM tracks WHERE \(whereSQL) ORDER BY \(col) COLLATE NOCASE \(order)", |
|
|
|
sql: "SELECT * FROM tracks WHERE \(whereSQL) ORDER BY \(orderBy)", |
|
|
|
arguments: args |
|
|
|
arguments: args |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|