You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
601 lines
23 KiB
601 lines
23 KiB
import Foundation
|
|
import GRDB
|
|
|
|
// `nonisolated` opts this struct out of the project-wide `default-isolation = MainActor`.
|
|
nonisolated struct MonthlyCount: Sendable {
|
|
let month: Date
|
|
let count: Int
|
|
}
|
|
|
|
// `nonisolated` opts this class out of the project-wide `default-isolation = MainActor`
|
|
// setting so database operations run off the main actor and can be used from Sendable contexts.
|
|
nonisolated final class DatabaseService: Sendable {
|
|
let dbPool: DatabaseWriter
|
|
|
|
init(path: String) throws {
|
|
let dbPool = try DatabasePool(path: path)
|
|
self.dbPool = dbPool
|
|
try Self.migrate(dbPool)
|
|
}
|
|
|
|
init(inMemory: Bool) throws {
|
|
let dbQueue = try DatabaseQueue()
|
|
self.dbPool = dbQueue
|
|
try Self.migrate(dbQueue)
|
|
}
|
|
|
|
private static func migrate(_ db: DatabaseWriter) throws {
|
|
var migrator = DatabaseMigrator()
|
|
|
|
migrator.registerMigration("v1-create-tracks") { db in
|
|
try db.create(table: "tracks") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("fileURL", .text).notNull().unique()
|
|
t.column("title", .text).notNull()
|
|
t.column("artist", .text).notNull()
|
|
t.column("albumArtist", .text).notNull()
|
|
t.column("album", .text).notNull()
|
|
t.column("genre", .text).notNull()
|
|
t.column("year", .integer)
|
|
t.column("trackNumber", .integer)
|
|
t.column("discNumber", .integer)
|
|
t.column("duration", .double).notNull()
|
|
t.column("bpm", .integer)
|
|
t.column("composer", .text).notNull()
|
|
t.column("fileFormat", .text).notNull()
|
|
t.column("bitrate", .integer)
|
|
t.column("sampleRate", .integer)
|
|
t.column("fileSize", .integer).notNull()
|
|
t.column("artworkData", .blob)
|
|
t.column("playCount", .integer).notNull().defaults(to: 0)
|
|
t.column("lastPlayedAt", .datetime)
|
|
t.column("rating", .integer).notNull().defaults(to: 0)
|
|
t.column("dateAdded", .datetime).notNull()
|
|
t.column("dateModified", .datetime).notNull()
|
|
t.column("fileHash", .text).notNull()
|
|
}
|
|
|
|
try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"])
|
|
try db.create(index: "idx_tracks_album", on: "tracks", columns: ["album"])
|
|
try db.create(index: "idx_tracks_genre", on: "tracks", columns: ["genre"])
|
|
try db.create(index: "idx_tracks_year", on: "tracks", columns: ["year"])
|
|
try db.create(
|
|
index: "idx_tracks_album_order",
|
|
on: "tracks",
|
|
columns: ["albumArtist", "album", "discNumber", "trackNumber"]
|
|
)
|
|
|
|
try db.create(virtualTable: "tracks_ft", using: FTS5()) { t in
|
|
t.synchronize(withTable: "tracks")
|
|
t.tokenizer = .unicode61()
|
|
t.column("title")
|
|
t.column("artist")
|
|
t.column("albumArtist")
|
|
t.column("album")
|
|
t.column("genre")
|
|
t.column("composer")
|
|
}
|
|
}
|
|
|
|
migrator.registerMigration("v2-create-playlists") { db in
|
|
try db.create(table: "playlists") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("name", .text).notNull().unique()
|
|
t.column("createdAt", .datetime).notNull()
|
|
}
|
|
|
|
try db.create(table: "playlist_tracks") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("playlistId", .integer).notNull()
|
|
.references("playlists", onDelete: .cascade)
|
|
t.column("trackId", .integer).notNull()
|
|
.references("tracks", onDelete: .cascade)
|
|
t.column("position", .integer).notNull()
|
|
t.uniqueKey(["playlistId", "trackId"])
|
|
}
|
|
|
|
try db.create(
|
|
index: "idx_playlist_tracks_order",
|
|
on: "playlist_tracks",
|
|
columns: ["playlistId", "position"]
|
|
)
|
|
}
|
|
|
|
migrator.registerMigration("v3-create-smart-playlists") { db in
|
|
try db.create(table: "smart_playlists") { t in
|
|
t.autoIncrementedPrimaryKey("id")
|
|
t.column("name", .text).notNull().unique()
|
|
t.column("searchQuery", .text).notNull()
|
|
t.column("createdAt", .datetime).notNull()
|
|
}
|
|
}
|
|
|
|
migrator.registerMigration("v4-drop-artworkData") { db in
|
|
try db.alter(table: "tracks") { t in
|
|
t.drop(column: "artworkData")
|
|
}
|
|
}
|
|
|
|
migrator.registerMigration("v5-add-smart-playlist-conditions") { db in
|
|
try db.alter(table: "smart_playlists") { t in
|
|
t.add(column: "conditions", .text)
|
|
}
|
|
}
|
|
|
|
try migrator.migrate(db)
|
|
}
|
|
|
|
// MARK: - Maintenance
|
|
|
|
/// Create a self-contained copy of the database at the given path.
|
|
///
|
|
/// Uses SQLite's `VACUUM INTO` rather than the online backup API. The online
|
|
/// backup (`dbPool.backup(to:)`) produced copies whose FTS5 `tracks_ft` shadow
|
|
/// tables were not transferred, so any `ValueObservation` opened on the copy
|
|
/// failed with "no such table: tracks_ft" — which left the remote client's
|
|
/// track list empty even though the row data copied fine. `VACUUM INTO` writes
|
|
/// a fresh, fully-rebuilt, self-contained database that includes a functional
|
|
/// FTS5 index and all committed data, with no WAL/SHM side files.
|
|
///
|
|
/// `VACUUM INTO` requires the destination file to not already exist, so any
|
|
/// stale file at `destinationPath` is removed first.
|
|
func backup(to destinationPath: String) throws {
|
|
try? FileManager.default.removeItem(atPath: destinationPath)
|
|
let escaped = destinationPath.replacingOccurrences(of: "'", with: "''")
|
|
try dbPool.writeWithoutTransaction { db in
|
|
try db.execute(sql: "VACUUM INTO '\(escaped)'")
|
|
}
|
|
}
|
|
|
|
// MARK: - Write
|
|
|
|
func insert(_ track: inout Track) throws {
|
|
try dbPool.write { db in
|
|
try track.insert(db)
|
|
}
|
|
}
|
|
|
|
func insertBatch(_ tracks: [Track]) throws {
|
|
try dbPool.write { db in
|
|
for var track in tracks {
|
|
try track.insert(db, onConflict: .ignore)
|
|
}
|
|
}
|
|
}
|
|
|
|
func updatePlayStats(trackId: Int64, playCount: Int, lastPlayedAt: Date) throws {
|
|
try dbPool.write { db in
|
|
try db.execute(
|
|
sql: "UPDATE tracks SET playCount = ?, lastPlayedAt = ? WHERE id = ?",
|
|
arguments: [playCount, lastPlayedAt, trackId]
|
|
)
|
|
}
|
|
}
|
|
|
|
func deleteTracksWithURLs(_ urls: Set<String>) throws {
|
|
try dbPool.write { db in
|
|
let placeholders = databaseQuestionMarks(count: urls.count)
|
|
try db.execute(
|
|
sql: "DELETE FROM tracks WHERE fileURL IN (\(placeholders))",
|
|
arguments: StatementArguments(Array(urls))
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Read
|
|
|
|
private static let validSortColumns: Set<String> = [
|
|
"title", "artist", "albumArtist", "album", "genre", "year", "duration",
|
|
"trackNumber", "dateAdded", "playCount", "rating", "bpm"
|
|
]
|
|
|
|
func fetchTracks(search: String, sortColumn: String, ascending: Bool) throws -> [Track] {
|
|
try dbPool.read { db in
|
|
try self.fetchTracks(db: db, search: search, sortColumn: sortColumn, ascending: ascending)
|
|
}
|
|
}
|
|
|
|
// Used by ValueObservation which already holds a Database access
|
|
func fetchTracks(db: Database, search: String, sortColumn: String, ascending: Bool) throws -> [Track] {
|
|
let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title"
|
|
let order = ascending ? "ASC" : "DESC"
|
|
|
|
if search.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
return try Track.fetchAll(
|
|
db,
|
|
sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)"
|
|
)
|
|
}
|
|
|
|
let terms = search.split(separator: " ").map { "\($0)*" }
|
|
let pattern = terms.joined(separator: " ")
|
|
|
|
return try Track.fetchAll(
|
|
db,
|
|
sql: """
|
|
SELECT tracks.* FROM tracks
|
|
JOIN tracks_ft ON tracks_ft.rowid = tracks.id
|
|
WHERE tracks_ft MATCH ?
|
|
ORDER BY \(col) COLLATE NOCASE \(order)
|
|
""",
|
|
arguments: [pattern]
|
|
)
|
|
}
|
|
|
|
func fetchTracks(conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] {
|
|
try dbPool.read { db in
|
|
try self.fetchTracks(db: db, conditions: conditions, sortColumn: sortColumn, ascending: ascending)
|
|
}
|
|
}
|
|
|
|
func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] {
|
|
let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title"
|
|
let order = ascending ? "ASC" : "DESC"
|
|
let (whereSQL, args) = buildWhereClause(conditions)
|
|
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 WHERE \(whereSQL) ORDER BY \(col) COLLATE NOCASE \(order)",
|
|
arguments: args
|
|
)
|
|
}
|
|
|
|
func allFileURLs() throws -> Set<String> {
|
|
try dbPool.read { db in
|
|
let urls = try String.fetchAll(db, sql: "SELECT fileURL FROM tracks")
|
|
return Set(urls)
|
|
}
|
|
}
|
|
|
|
func trackCount() throws -> Int {
|
|
try dbPool.read { db in
|
|
try Track.fetchCount(db)
|
|
}
|
|
}
|
|
|
|
func fetchTracksByIds(_ ids: [Int64]) throws -> [Track] {
|
|
guard !ids.isEmpty else { return [] }
|
|
let tracks = try dbPool.read { db in
|
|
let placeholders = databaseQuestionMarks(count: ids.count)
|
|
return try Track.fetchAll(
|
|
db,
|
|
sql: "SELECT * FROM tracks WHERE id IN (\(placeholders))",
|
|
arguments: StatementArguments(ids)
|
|
)
|
|
}
|
|
let trackMap = Dictionary(uniqueKeysWithValues: tracks.compactMap { t in t.id.map { ($0, t) } })
|
|
return ids.compactMap { trackMap[$0] }
|
|
}
|
|
|
|
func fetchRecentlyAdded(limit: Int) throws -> [Track] {
|
|
try dbPool.read { db in
|
|
try Track.fetchAll(
|
|
db,
|
|
sql: "SELECT * FROM tracks ORDER BY dateAdded DESC LIMIT ?",
|
|
arguments: [limit]
|
|
)
|
|
}
|
|
}
|
|
|
|
func totalDuration() throws -> Double {
|
|
try dbPool.read { db in
|
|
try Double.fetchOne(db, sql: "SELECT COALESCE(SUM(duration), 0) FROM tracks") ?? 0
|
|
}
|
|
}
|
|
|
|
func fetchMonthlyAdditions(months: Int) throws -> [MonthlyCount] {
|
|
// Use a UTC calendar throughout: GRDB stores Date values as UTC ISO8601 strings,
|
|
// so SQLite's strftime('%Y-%m', …) returns UTC months. We align Swift's month
|
|
// boundaries to UTC as well to ensure consistent bucketing regardless of the
|
|
// device's local timezone.
|
|
var utcCalendar = Calendar(identifier: .gregorian)
|
|
utcCalendar.timeZone = TimeZone(identifier: "UTC")!
|
|
let now = Date()
|
|
let thisMonth = utcCalendar.dateInterval(of: .month, for: now)!.start
|
|
let startMonth = utcCalendar.date(byAdding: .month, value: -(months - 1), to: thisMonth)!
|
|
|
|
let rows: [(String, Int)] = try dbPool.read { db in
|
|
let rows = try Row.fetchAll(
|
|
db,
|
|
sql: """
|
|
SELECT strftime('%Y-%m', dateAdded) AS month, COUNT(*) AS cnt
|
|
FROM tracks
|
|
WHERE dateAdded >= ?
|
|
GROUP BY month
|
|
ORDER BY month ASC
|
|
""",
|
|
arguments: [startMonth]
|
|
)
|
|
return rows.map { ($0["month"] as String, $0["cnt"] as Int) }
|
|
}
|
|
|
|
let rowDict = Dictionary(uniqueKeysWithValues: rows)
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM"
|
|
formatter.timeZone = TimeZone(identifier: "UTC")!
|
|
|
|
var results: [MonthlyCount] = []
|
|
for i in 0..<months {
|
|
let month = utcCalendar.date(byAdding: .month, value: i, to: startMonth)!
|
|
let key = formatter.string(from: month)
|
|
let count = rowDict[key] ?? 0
|
|
results.append(MonthlyCount(month: month, count: count))
|
|
}
|
|
return results
|
|
}
|
|
|
|
// MARK: - Playlists
|
|
|
|
func createPlaylist(name: String) throws -> Playlist {
|
|
try dbPool.write { db in
|
|
var playlist = Playlist(id: nil, name: name, createdAt: Date())
|
|
try playlist.insert(db)
|
|
return playlist
|
|
}
|
|
}
|
|
|
|
func renamePlaylist(id: Int64, name: String) throws {
|
|
try dbPool.write { db in
|
|
try db.execute(
|
|
sql: "UPDATE playlists SET name = ? WHERE id = ?",
|
|
arguments: [name, id]
|
|
)
|
|
}
|
|
}
|
|
|
|
func deletePlaylist(id: Int64) throws {
|
|
try dbPool.write { db in
|
|
try db.execute(sql: "DELETE FROM playlists WHERE id = ?", arguments: [id])
|
|
}
|
|
}
|
|
|
|
func fetchPlaylists() throws -> [Playlist] {
|
|
try dbPool.read { db in
|
|
try Playlist.fetchAll(db, sql: "SELECT * FROM playlists ORDER BY name COLLATE NOCASE ASC")
|
|
}
|
|
}
|
|
|
|
func fetchPlaylists(db: Database) throws -> [Playlist] {
|
|
try Playlist.fetchAll(db, sql: "SELECT * FROM playlists ORDER BY name COLLATE NOCASE ASC")
|
|
}
|
|
|
|
func addTrackToPlaylist(trackId: Int64, playlistId: Int64) throws {
|
|
try dbPool.write { db in
|
|
let maxPos = try Int.fetchOne(
|
|
db,
|
|
sql: "SELECT MAX(position) FROM playlist_tracks WHERE playlistId = ?",
|
|
arguments: [playlistId]
|
|
) ?? -1
|
|
var entry = PlaylistTrack(
|
|
id: nil, playlistId: playlistId, trackId: trackId, position: maxPos + 1
|
|
)
|
|
try entry.insert(db, onConflict: .ignore)
|
|
}
|
|
}
|
|
|
|
func removeTrackFromPlaylist(trackId: Int64, playlistId: Int64) throws {
|
|
try dbPool.write { db in
|
|
let position = try Int.fetchOne(
|
|
db,
|
|
sql: "SELECT position FROM playlist_tracks WHERE playlistId = ? AND trackId = ?",
|
|
arguments: [playlistId, trackId]
|
|
)
|
|
try db.execute(
|
|
sql: "DELETE FROM playlist_tracks WHERE playlistId = ? AND trackId = ?",
|
|
arguments: [playlistId, trackId]
|
|
)
|
|
if let pos = position {
|
|
try db.execute(
|
|
sql: """
|
|
UPDATE playlist_tracks SET position = position - 1
|
|
WHERE playlistId = ? AND position > ?
|
|
""",
|
|
arguments: [playlistId, pos]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func reorderPlaylistTrack(playlistId: Int64, fromPosition: Int, toPosition: Int) throws {
|
|
try dbPool.write { db in
|
|
let trackId = try Int64.fetchOne(
|
|
db,
|
|
sql: "SELECT trackId FROM playlist_tracks WHERE playlistId = ? AND position = ?",
|
|
arguments: [playlistId, fromPosition]
|
|
)
|
|
guard let trackId else { return }
|
|
|
|
if fromPosition < toPosition {
|
|
try db.execute(
|
|
sql: """
|
|
UPDATE playlist_tracks SET position = position - 1
|
|
WHERE playlistId = ? AND position > ? AND position <= ?
|
|
""",
|
|
arguments: [playlistId, fromPosition, toPosition]
|
|
)
|
|
} else {
|
|
try db.execute(
|
|
sql: """
|
|
UPDATE playlist_tracks SET position = position + 1
|
|
WHERE playlistId = ? AND position >= ? AND position < ?
|
|
""",
|
|
arguments: [playlistId, toPosition, fromPosition]
|
|
)
|
|
}
|
|
|
|
try db.execute(
|
|
sql: """
|
|
UPDATE playlist_tracks SET position = ?
|
|
WHERE playlistId = ? AND trackId = ?
|
|
""",
|
|
arguments: [toPosition, playlistId, trackId]
|
|
)
|
|
}
|
|
}
|
|
|
|
func fetchPlaylistTracks(playlistId: Int64, search: String = "") throws -> [Track] {
|
|
try dbPool.read { db in
|
|
try self.fetchPlaylistTracks(db: db, playlistId: playlistId, search: search)
|
|
}
|
|
}
|
|
|
|
func fetchPlaylistTracks(db: Database, playlistId: Int64, search: String = "") throws -> [Track] {
|
|
if search.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
return try Track.fetchAll(
|
|
db,
|
|
sql: """
|
|
SELECT tracks.* FROM tracks
|
|
JOIN playlist_tracks ON playlist_tracks.trackId = tracks.id
|
|
WHERE playlist_tracks.playlistId = ?
|
|
ORDER BY playlist_tracks.position ASC
|
|
""",
|
|
arguments: [playlistId]
|
|
)
|
|
}
|
|
|
|
let terms = search.split(separator: " ").map { "\($0)*" }
|
|
let pattern = terms.joined(separator: " ")
|
|
|
|
return try Track.fetchAll(
|
|
db,
|
|
sql: """
|
|
SELECT tracks.* FROM tracks
|
|
JOIN playlist_tracks ON playlist_tracks.trackId = tracks.id
|
|
JOIN tracks_ft ON tracks_ft.rowid = tracks.id
|
|
WHERE playlist_tracks.playlistId = ? AND tracks_ft MATCH ?
|
|
ORDER BY playlist_tracks.position ASC
|
|
""",
|
|
arguments: [playlistId, pattern]
|
|
)
|
|
}
|
|
|
|
func playlistName(id: Int64) throws -> String? {
|
|
try dbPool.read { db in
|
|
try String.fetchOne(
|
|
db,
|
|
sql: "SELECT name FROM playlists WHERE id = ?",
|
|
arguments: [id]
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Smart Playlists
|
|
|
|
/// Builds a parameterized SQL WHERE clause from an array of conditions.
|
|
/// Column names come from `TrackField.rawValue` (an enum, not user input) —
|
|
/// safe to interpolate. Values are always bound via `StatementArguments` (`?`)
|
|
/// to prevent SQL injection.
|
|
private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, arguments: StatementArguments) {
|
|
guard !conditions.isEmpty else { return ("", StatementArguments()) }
|
|
var fragments: [String] = []
|
|
var args: [DatabaseValueConvertible?] = []
|
|
|
|
for condition in conditions {
|
|
let col = condition.field.rawValue
|
|
switch (condition.op, condition.value) {
|
|
case (.equals, .string(let s)):
|
|
fragments.append("LOWER(\(col)) = LOWER(?)")
|
|
args.append(s)
|
|
case (.startsWith, .string(let s)):
|
|
let escaped = s
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "%", with: "\\%")
|
|
.replacingOccurrences(of: "_", with: "\\_")
|
|
fragments.append("LOWER(\(col)) LIKE LOWER(?) || '%' ESCAPE '\\'")
|
|
args.append(escaped)
|
|
case (.equals, .int(let i)):
|
|
fragments.append("\(col) = ?"); args.append(i)
|
|
case (.greaterThan, .int(let i)):
|
|
fragments.append("\(col) > ?"); args.append(i)
|
|
case (.lessThan, .int(let i)):
|
|
fragments.append("\(col) < ?"); args.append(i)
|
|
case (.equals, .double(let d)):
|
|
fragments.append("\(col) = ?"); args.append(d)
|
|
case (.greaterThan, .double(let d)):
|
|
fragments.append("\(col) > ?"); args.append(d)
|
|
case (.lessThan, .double(let d)):
|
|
fragments.append("\(col) < ?"); args.append(d)
|
|
case (.equals, .date(let date)):
|
|
fragments.append("\(col) = ?"); args.append(date)
|
|
case (.greaterThan, .date(let date)):
|
|
fragments.append("\(col) > ?"); args.append(date)
|
|
case (.lessThan, .date(let date)):
|
|
fragments.append("\(col) < ?"); args.append(date)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return (fragments.joined(separator: " AND "), StatementArguments(args))
|
|
}
|
|
|
|
func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist {
|
|
try dbPool.write { db in
|
|
var smartPlaylist = SmartPlaylist(
|
|
id: nil, name: name, searchQuery: searchQuery, createdAt: Date(), conditions: nil
|
|
)
|
|
try smartPlaylist.insert(db)
|
|
return smartPlaylist
|
|
}
|
|
}
|
|
|
|
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws -> SmartPlaylist {
|
|
try dbPool.write { db in
|
|
var smartPlaylist = SmartPlaylist(
|
|
id: nil, name: name, searchQuery: "", createdAt: Date(), conditions: conditions
|
|
)
|
|
try smartPlaylist.insert(db)
|
|
return smartPlaylist
|
|
}
|
|
}
|
|
|
|
func renameSmartPlaylist(id: Int64, name: String) throws {
|
|
try dbPool.write { db in
|
|
try db.execute(
|
|
sql: "UPDATE smart_playlists SET name = ? WHERE id = ?",
|
|
arguments: [name, id]
|
|
)
|
|
}
|
|
}
|
|
|
|
func updateSmartPlaylistQuery(id: Int64, searchQuery: String) throws {
|
|
try dbPool.write { db in
|
|
try db.execute(
|
|
sql: "UPDATE smart_playlists SET searchQuery = ? WHERE id = ?",
|
|
arguments: [searchQuery, id]
|
|
)
|
|
}
|
|
}
|
|
|
|
func updateSmartPlaylistConditions(id: Int64, conditions: [SmartPlaylistCondition]) throws {
|
|
let data = try JSONEncoder().encode(conditions)
|
|
let json = String(data: data, encoding: .utf8)
|
|
try dbPool.write { db in
|
|
try db.execute(
|
|
sql: "UPDATE smart_playlists SET conditions = ? WHERE id = ?",
|
|
arguments: [json, id]
|
|
)
|
|
}
|
|
}
|
|
|
|
func deleteSmartPlaylist(id: Int64) throws {
|
|
try dbPool.write { db in
|
|
try db.execute(sql: "DELETE FROM smart_playlists WHERE id = ?", arguments: [id])
|
|
}
|
|
}
|
|
|
|
func fetchSmartPlaylists() throws -> [SmartPlaylist] {
|
|
try dbPool.read { db in
|
|
try SmartPlaylist.fetchAll(
|
|
db, sql: "SELECT * FROM smart_playlists ORDER BY name COLLATE NOCASE ASC"
|
|
)
|
|
}
|
|
}
|
|
|
|
func fetchSmartPlaylists(db: Database) throws -> [SmartPlaylist] {
|
|
try SmartPlaylist.fetchAll(
|
|
db, sql: "SELECT * FROM smart_playlists ORDER BY name COLLATE NOCASE ASC"
|
|
)
|
|
}
|
|
}
|
|
|