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() } } try migrator.migrate(db) } // 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) 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 = [ "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 allFileURLs() throws -> Set { 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 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.. 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 func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist { try dbPool.write { db in var smartPlaylist = SmartPlaylist( id: nil, name: name, searchQuery: searchQuery, createdAt: Date() ) 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 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" ) } }