feat: add database queries for home page (recently added, total duration, monthly additions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/music-streaming
Laurent 1 month ago
parent ac4e421340
commit 4a3dd23e57
  1. 63
      Music/Services/DatabaseService.swift
  2. 62
      MusicTests/DatabaseServiceTests.swift

@ -1,6 +1,12 @@
import Foundation import Foundation
import GRDB 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` // `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. // setting so database operations run off the main actor and can be used from Sendable contexts.
nonisolated final class DatabaseService: Sendable { nonisolated final class DatabaseService: Sendable {
@ -195,6 +201,63 @@ nonisolated final class DatabaseService: Sendable {
} }
} }
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 // MARK: - Playlists
func createPlaylist(name: String) throws -> Playlist { func createPlaylist(name: String) throws -> Playlist {

@ -227,4 +227,66 @@ struct DatabaseServiceTests {
let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!)
#expect(tracks.count == 1) #expect(tracks.count == 1)
} }
// Inserts three tracks with different dateAdded values and verifies fetchRecentlyAdded
// returns them in descending order capped to the given limit.
@Test func fetchRecentlyAdded() throws {
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Old", dateAdded: Date(timeIntervalSince1970: 1000))
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Mid", dateAdded: Date(timeIntervalSince1970: 2000))
var t3 = Track.fixture(fileURL: "/c.mp3", title: "New", dateAdded: Date(timeIntervalSince1970: 3000))
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let recent = try db.fetchRecentlyAdded(limit: 2)
#expect(recent.count == 2)
#expect(recent[0].title == "New")
#expect(recent[1].title == "Mid")
}
// Inserts two tracks with known durations and verifies totalDuration sums them correctly.
@Test func totalDuration() throws {
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", duration: 120.0)
var t2 = Track.fixture(fileURL: "/b.mp3", duration: 300.5)
try db.insert(&t1)
try db.insert(&t2)
let total = try db.totalDuration()
#expect(abs(total - 420.5) < 0.01)
}
// Verifies totalDuration returns 0 when the library is empty.
@Test func totalDurationEmptyLibrary() throws {
let db = try DatabaseService(inMemory: true)
let total = try db.totalDuration()
#expect(total == 0)
}
// Inserts tracks in different months and verifies fetchMonthlyAdditions returns
// the correct per-month counts covering the requested range including empty months.
// Uses a UTC calendar to match the implementation, which uses UTC month boundaries
// because GRDB stores dates as UTC ISO8601 strings.
@Test func fetchMonthlyAdditions() throws {
let db = try DatabaseService(inMemory: true)
var utcCal = Calendar(identifier: .gregorian)
utcCal.timeZone = TimeZone(identifier: "UTC")!
let now = Date()
let thisMonth = utcCal.dateInterval(of: .month, for: now)!.start
let twoMonthsAgo = utcCal.date(byAdding: .month, value: -2, to: thisMonth)!
var t1 = Track.fixture(fileURL: "/a.mp3", dateAdded: thisMonth)
var t2 = Track.fixture(fileURL: "/b.mp3", dateAdded: thisMonth.addingTimeInterval(86400))
var t3 = Track.fixture(fileURL: "/c.mp3", dateAdded: twoMonthsAgo)
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let results = try db.fetchMonthlyAdditions(months: 3)
#expect(results.count == 3)
#expect(results[0].count == 1)
#expect(results[1].count == 0)
#expect(results[2].count == 2)
}
} }

Loading…
Cancel
Save