diff --git a/Music/Services/DatabaseService.swift b/Music/Services/DatabaseService.swift index 59c74d5..21a71b2 100644 --- a/Music/Services/DatabaseService.swift +++ b/Music/Services/DatabaseService.swift @@ -1,6 +1,12 @@ 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 { @@ -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.. Playlist { diff --git a/MusicTests/DatabaseServiceTests.swift b/MusicTests/DatabaseServiceTests.swift index 0ff4422..bd4ffcf 100644 --- a/MusicTests/DatabaseServiceTests.swift +++ b/MusicTests/DatabaseServiceTests.swift @@ -227,4 +227,66 @@ struct DatabaseServiceTests { let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) #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) + } }