feat: add playlist database migration and CRUD methods

feat/music-streaming
Laurent 1 month ago
parent 41754b1f56
commit 1cf5353339
  1. 179
      Music/Services/DatabaseService.swift
  2. 129
      MusicTests/DatabaseServiceTests.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]
)
}
}
}

@ -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)
}
}

Loading…
Cancel
Save