refactor: remove artworkData BLOB from database, load artwork from files on demand

Artwork was stored inline in the tracks table (385MB / 98% of DB size for 8K tracks).
Now read directly from audio file metadata via AVAsset when needed, shrinking the DB to ~8MB.
feat/music-streaming
Laurent 1 month ago
parent db3873b29e
commit d7e81b1d31
  1. 4
      Music/Models/Track.swift
  2. 6
      Music/Services/DatabaseService.swift
  3. 5
      Music/Services/ScannerService.swift
  4. 30
      Music/Views/PlayerControlsView.swift
  5. 1
      MusicTests/TrackTests.swift

@ -22,7 +22,6 @@ nonisolated struct Track: Codable, Identifiable, Equatable, Hashable, Sendable {
var bitrate: Int? var bitrate: Int?
var sampleRate: Int? var sampleRate: Int?
var fileSize: Int64 var fileSize: Int64
var artworkData: Data?
var playCount: Int var playCount: Int
var lastPlayedAt: Date? var lastPlayedAt: Date?
var rating: Int var rating: Int
@ -66,7 +65,6 @@ extension Track {
bitrate: Int? = 320, bitrate: Int? = 320,
sampleRate: Int? = 44100, sampleRate: Int? = 44100,
fileSize: Int64 = 5_000_000, fileSize: Int64 = 5_000_000,
artworkData: Data? = nil,
playCount: Int = 0, playCount: Int = 0,
lastPlayedAt: Date? = nil, lastPlayedAt: Date? = nil,
rating: Int = 0, rating: Int = 0,
@ -79,7 +77,7 @@ extension Track {
albumArtist: albumArtist, album: album, genre: genre, year: year, albumArtist: albumArtist, album: album, genre: genre, year: year,
trackNumber: trackNumber, discNumber: discNumber, duration: duration, trackNumber: trackNumber, discNumber: discNumber, duration: duration,
bpm: bpm, composer: composer, fileFormat: fileFormat, bitrate: bitrate, bpm: bpm, composer: composer, fileFormat: fileFormat, bitrate: bitrate,
sampleRate: sampleRate, fileSize: fileSize, artworkData: artworkData, sampleRate: sampleRate, fileSize: fileSize,
playCount: playCount, lastPlayedAt: lastPlayedAt, rating: rating, playCount: playCount, lastPlayedAt: lastPlayedAt, rating: rating,
dateAdded: dateAdded, dateModified: dateModified, fileHash: fileHash dateAdded: dateAdded, dateModified: dateModified, fileHash: fileHash
) )

@ -110,6 +110,12 @@ nonisolated final class DatabaseService: Sendable {
} }
} }
migrator.registerMigration("v4-drop-artworkData") { db in
try db.alter(table: "tracks") { t in
t.drop(column: "artworkData")
}
}
try migrator.migrate(db) try migrator.migrate(db)
} }

@ -90,8 +90,6 @@ final class ScannerService {
var discNumber: Int? var discNumber: Int?
var bpm: Int? var bpm: Int?
var composer = "" var composer = ""
var artworkData: Data?
do { do {
let metadata = try await asset.load(.metadata) let metadata = try await asset.load(.metadata)
@ -106,8 +104,6 @@ final class ScannerService {
if albumArtist == "Unknown" { albumArtist = val } if albumArtist == "Unknown" { albumArtist = val }
case .commonKeyAlbumName: case .commonKeyAlbumName:
album = (try? await item.load(.stringValue)) ?? "Unknown" album = (try? await item.load(.stringValue)) ?? "Unknown"
case .commonKeyArtwork:
artworkData = try? await item.load(.dataValue)
case .commonKeyCreator: case .commonKeyCreator:
composer = (try? await item.load(.stringValue)) ?? "" composer = (try? await item.load(.stringValue)) ?? ""
default: default:
@ -184,7 +180,6 @@ final class ScannerService {
bitrate: bitrate, bitrate: bitrate,
sampleRate: sampleRate, sampleRate: sampleRate,
fileSize: fileSize, fileSize: fileSize,
artworkData: artworkData,
playCount: 0, playCount: 0,
lastPlayedAt: nil, lastPlayedAt: nil,
rating: 0, rating: 0,

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import AVFoundation
struct PlayerControlsView: View { struct PlayerControlsView: View {
let currentTrack: Track? let currentTrack: Track?
@ -20,6 +21,7 @@ struct PlayerControlsView: View {
@State private var isDragging = false @State private var isDragging = false
@State private var dragValue: Double = 0 @State private var dragValue: Double = 0
@State private var artworkImage: NSImage?
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -38,6 +40,29 @@ struct PlayerControlsView: View {
.padding(.vertical, 8) .padding(.vertical, 8)
} }
.background(.bar) .background(.bar)
.onChange(of: currentTrack?.id) {
loadArtwork()
}
.onAppear {
loadArtwork()
}
}
private func loadArtwork() {
guard let urlString = currentTrack?.fileURL,
let url = URL(string: urlString) else {
artworkImage = nil
return
}
Task.detached {
let asset = AVURLAsset(url: url)
let metadata = try? await asset.load(.metadata)
let data = try? await metadata?
.first { $0.commonKey == .commonKeyArtwork }?
.load(.dataValue)
let image = data.flatMap { NSImage(data: $0) }
await MainActor.run { artworkImage = image }
}
} }
private var nowPlayingSection: some View { private var nowPlayingSection: some View {
@ -46,9 +71,8 @@ struct PlayerControlsView: View {
.fill(.quaternary) .fill(.quaternary)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
.overlay { .overlay {
if let data = currentTrack?.artworkData, if let artworkImage {
let nsImage = NSImage(data: data) { Image(nsImage: artworkImage)
Image(nsImage: nsImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
} else { } else {

@ -27,7 +27,6 @@ struct TrackTests {
t.column("bitrate", .integer) t.column("bitrate", .integer)
t.column("sampleRate", .integer) t.column("sampleRate", .integer)
t.column("fileSize", .integer).notNull() t.column("fileSize", .integer).notNull()
t.column("artworkData", .blob)
t.column("playCount", .integer).notNull().defaults(to: 0) t.column("playCount", .integer).notNull().defaults(to: 0)
t.column("lastPlayedAt", .datetime) t.column("lastPlayedAt", .datetime)
t.column("rating", .integer).notNull().defaults(to: 0) t.column("rating", .integer).notNull().defaults(to: 0)

Loading…
Cancel
Save