From 1cf53533396b982f2ace7fd387e8ea81afad76cb Mon Sep 17 00:00:00 2001 From: Laurent Date: Sun, 24 May 2026 09:36:10 +0200 Subject: [PATCH] feat: add playlist database migration and CRUD methods --- Music/Services/DatabaseService.swift | 179 ++++++++++++++++++++++++++ MusicTests/DatabaseServiceTests.swift | 129 +++++++++++++++++++ 2 files changed, 308 insertions(+) diff --git a/Music/Services/DatabaseService.swift b/Music/Services/DatabaseService.swift index e74851e..b5361dc 100644 --- a/Music/Services/DatabaseService.swift +++ b/Music/Services/DatabaseService.swift @@ -71,6 +71,30 @@ nonisolated final class DatabaseService: Sendable { } } + 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"] + ) + } + try migrator.migrate(db) } @@ -161,4 +185,159 @@ nonisolated final class DatabaseService: Sendable { try Track.fetchCount(db) } } + + // 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] + ) + } + } } diff --git a/MusicTests/DatabaseServiceTests.swift b/MusicTests/DatabaseServiceTests.swift index 1438112..0ff4422 100644 --- a/MusicTests/DatabaseServiceTests.swift +++ b/MusicTests/DatabaseServiceTests.swift @@ -98,4 +98,133 @@ struct DatabaseServiceTests { let result = try db.fetchTracks(search: "", sortColumn: "DROP TABLE tracks", ascending: true) #expect(result[0].title == "Alpha") } + + // Creates a playlist and verifies it can be fetched back. + @Test func createAndFetchPlaylist() throws { + let db = try DatabaseService(inMemory: true) + let playlist = try db.createPlaylist(name: "Chill Vibes") + + #expect(playlist.id != nil) + #expect(playlist.name == "Chill Vibes") + + let all = try db.fetchPlaylists() + #expect(all.count == 1) + #expect(all[0].name == "Chill Vibes") + } + + // Renames a playlist and verifies the new name persists. + @Test func renamePlaylist() throws { + let db = try DatabaseService(inMemory: true) + let playlist = try db.createPlaylist(name: "Old Name") + try db.renamePlaylist(id: playlist.id!, name: "New Name") + + let all = try db.fetchPlaylists() + #expect(all[0].name == "New Name") + } + + // Deletes a playlist and verifies it's gone. + @Test func deletePlaylist() throws { + let db = try DatabaseService(inMemory: true) + let playlist = try db.createPlaylist(name: "To Delete") + try db.deletePlaylist(id: playlist.id!) + + let all = try db.fetchPlaylists() + #expect(all.isEmpty) + } + + // Adds tracks to a playlist and verifies order is preserved. + @Test func addTracksToPlaylist() throws { + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Song A") + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Song B") + try db.insert(&t1) + try db.insert(&t2) + let playlist = try db.createPlaylist(name: "My Playlist") + + try db.addTrackToPlaylist(trackId: t1.id!, playlistId: playlist.id!) + try db.addTrackToPlaylist(trackId: t2.id!, playlistId: playlist.id!) + + let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) + #expect(tracks.count == 2) + #expect(tracks[0].title == "Song A") + #expect(tracks[1].title == "Song B") + } + + // Removes a track from a playlist and verifies positions are compacted. + @Test func removeTrackFromPlaylist() throws { + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Song A") + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Song B") + var t3 = Track.fixture(fileURL: "/c.mp3", title: "Song C") + try db.insert(&t1) + try db.insert(&t2) + try db.insert(&t3) + let playlist = try db.createPlaylist(name: "My Playlist") + + try db.addTrackToPlaylist(trackId: t1.id!, playlistId: playlist.id!) + try db.addTrackToPlaylist(trackId: t2.id!, playlistId: playlist.id!) + try db.addTrackToPlaylist(trackId: t3.id!, playlistId: playlist.id!) + + // Remove middle track + try db.removeTrackFromPlaylist(trackId: t2.id!, playlistId: playlist.id!) + + let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) + #expect(tracks.count == 2) + #expect(tracks[0].title == "Song A") + #expect(tracks[1].title == "Song C") + } + + // Reorders tracks in a playlist (move last to first). + @Test func reorderPlaylistTracks() throws { + let db = try DatabaseService(inMemory: true) + var t1 = Track.fixture(fileURL: "/a.mp3", title: "Song A") + var t2 = Track.fixture(fileURL: "/b.mp3", title: "Song B") + var t3 = Track.fixture(fileURL: "/c.mp3", title: "Song C") + try db.insert(&t1) + try db.insert(&t2) + try db.insert(&t3) + let playlist = try db.createPlaylist(name: "My Playlist") + + try db.addTrackToPlaylist(trackId: t1.id!, playlistId: playlist.id!) + try db.addTrackToPlaylist(trackId: t2.id!, playlistId: playlist.id!) + try db.addTrackToPlaylist(trackId: t3.id!, playlistId: playlist.id!) + + // Move Song C (position 2) to position 0 + try db.reorderPlaylistTrack(playlistId: playlist.id!, fromPosition: 2, toPosition: 0) + + let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) + #expect(tracks[0].title == "Song C") + #expect(tracks[1].title == "Song A") + #expect(tracks[2].title == "Song B") + } + + // Verifies that deleting a playlist cascades to playlist_tracks. + @Test func deletePlaylistCascadesJoinRows() throws { + let db = try DatabaseService(inMemory: true) + var track = Track.fixture() + try db.insert(&track) + let playlist = try db.createPlaylist(name: "Temp") + try db.addTrackToPlaylist(trackId: track.id!, playlistId: playlist.id!) + + try db.deletePlaylist(id: playlist.id!) + + // Re-create a playlist to verify no orphan join rows cause issues + let playlist2 = try db.createPlaylist(name: "Temp2") + let tracks = try db.fetchPlaylistTracks(playlistId: playlist2.id!) + #expect(tracks.isEmpty) + } + + // Verifies adding a duplicate track to a playlist is silently ignored. + @Test func addDuplicateTrackToPlaylistIsIgnored() throws { + let db = try DatabaseService(inMemory: true) + var track = Track.fixture() + try db.insert(&track) + let playlist = try db.createPlaylist(name: "My Playlist") + + try db.addTrackToPlaylist(trackId: track.id!, playlistId: playlist.id!) + try db.addTrackToPlaylist(trackId: track.id!, playlistId: playlist.id!) + + let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) + #expect(tracks.count == 1) + } }