Compare commits
37 Commits
db3873b29e
...
7f31945b84
|
Before Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 575 KiB |
@ -0,0 +1,21 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
<plist version="1.0"> |
||||
<dict> |
||||
<key>UTExportedTypeDeclarations</key> |
||||
<array> |
||||
<dict> |
||||
<key>UTTypeConformsTo</key> |
||||
<array> |
||||
<string>public.data</string> |
||||
</array> |
||||
<key>UTTypeDescription</key> |
||||
<string>Music Track ID</string> |
||||
<key>UTTypeIdentifier</key> |
||||
<string>com.music.trackID</string> |
||||
<key>UTTypeTagSpecification</key> |
||||
<dict/> |
||||
</dict> |
||||
</array> |
||||
</dict> |
||||
</plist> |
||||
@ -0,0 +1,84 @@ |
||||
import Foundation |
||||
|
||||
// The user-editable subset of Track, plus the pure logic for single- and |
||||
// multi-track editing. No UI, no I/O — fully unit-testable. |
||||
// |
||||
// Note: Named `EditableTrackField` (not `TrackField`) to avoid collision with |
||||
// the existing `TrackField` enum in SmartPlaylistCondition.swift, which covers |
||||
// all filterable columns including non-editable ones. |
||||
nonisolated enum EditableTrackField: CaseIterable, Sendable { |
||||
case title, artist, albumArtist, album, genre, composer |
||||
case year, trackNumber, discNumber, bpm, rating |
||||
// Library-managed, not a file tag — edits persist to the DB only (like rating). |
||||
case dateAdded |
||||
} |
||||
|
||||
nonisolated struct EditableTrackFields: Equatable, Sendable { |
||||
var title: String |
||||
var artist: String |
||||
var albumArtist: String |
||||
var album: String |
||||
var genre: String |
||||
var composer: String |
||||
var year: Int? |
||||
var trackNumber: Int? |
||||
var discNumber: Int? |
||||
var bpm: Int? |
||||
var rating: Int |
||||
var dateAdded: Date |
||||
|
||||
init(from t: Track) { |
||||
title = t.title; artist = t.artist; albumArtist = t.albumArtist |
||||
album = t.album; genre = t.genre; composer = t.composer |
||||
year = t.year; trackNumber = t.trackNumber; discNumber = t.discNumber |
||||
bpm = t.bpm; rating = t.rating; dateAdded = t.dateAdded |
||||
} |
||||
|
||||
func changedFields(to other: EditableTrackFields) -> Set<EditableTrackField> { |
||||
var changed: Set<EditableTrackField> = [] |
||||
if title != other.title { changed.insert(.title) } |
||||
if artist != other.artist { changed.insert(.artist) } |
||||
if albumArtist != other.albumArtist { changed.insert(.albumArtist) } |
||||
if album != other.album { changed.insert(.album) } |
||||
if genre != other.genre { changed.insert(.genre) } |
||||
if composer != other.composer { changed.insert(.composer) } |
||||
if year != other.year { changed.insert(.year) } |
||||
if trackNumber != other.trackNumber { changed.insert(.trackNumber) } |
||||
if discNumber != other.discNumber { changed.insert(.discNumber) } |
||||
if bpm != other.bpm { changed.insert(.bpm) } |
||||
if rating != other.rating { changed.insert(.rating) } |
||||
if dateAdded != other.dateAdded { changed.insert(.dateAdded) } |
||||
return changed |
||||
} |
||||
|
||||
// Returns prefill values (from the first track) plus the set of fields whose |
||||
// values are NOT identical across all tracks (shown as "Mixed" in the UI). |
||||
// Precondition: caller must pass at least one track; an empty array will trap. |
||||
static func shared(across tracks: [Track]) -> (values: EditableTrackFields, mixed: Set<EditableTrackField>) { |
||||
precondition(!tracks.isEmpty, "shared(across:) requires at least one track") |
||||
let base = EditableTrackFields(from: tracks[0]) |
||||
var mixed: Set<EditableTrackField> = [] |
||||
for t in tracks.dropFirst() { |
||||
mixed.formUnion(base.changedFields(to: EditableTrackFields(from: t))) |
||||
} |
||||
return (base, mixed) |
||||
} |
||||
|
||||
// Copies ONLY the edited fields onto the track; everything else is untouched. |
||||
func apply(editing edited: Set<EditableTrackField>, to track: Track) -> Track { |
||||
var t = track |
||||
if edited.contains(.title) { t.title = title } |
||||
if edited.contains(.artist) { t.artist = artist } |
||||
if edited.contains(.albumArtist) { t.albumArtist = albumArtist } |
||||
if edited.contains(.album) { t.album = album } |
||||
if edited.contains(.genre) { t.genre = genre } |
||||
if edited.contains(.composer) { t.composer = composer } |
||||
if edited.contains(.year) { t.year = year } |
||||
if edited.contains(.trackNumber) { t.trackNumber = trackNumber } |
||||
if edited.contains(.discNumber) { t.discNumber = discNumber } |
||||
if edited.contains(.bpm) { t.bpm = bpm } |
||||
if edited.contains(.rating) { t.rating = rating } |
||||
if edited.contains(.dateAdded) { t.dateAdded = dateAdded } |
||||
return t |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@ |
||||
import Foundation |
||||
|
||||
// A single slot in the manual "Up Next" queue. Carries its own stable identity so |
||||
// the same track can be queued more than once without SwiftUI confusing the rows — |
||||
// Track.id alone is not unique across duplicate queue entries. |
||||
nonisolated struct QueueEntry: Identifiable, Sendable { |
||||
let id = UUID() |
||||
let track: Track |
||||
} |
||||
@ -0,0 +1,152 @@ |
||||
import Foundation |
||||
|
||||
// Classifies a track field for operator and UI purposes. |
||||
enum FieldType: Sendable { |
||||
case string, int, double, date |
||||
} |
||||
|
||||
// Represents a track column that can be filtered on. |
||||
// Raw value matches the SQLite column name in the "tracks" table. |
||||
enum TrackField: String, Codable, CaseIterable, Identifiable, Sendable { |
||||
case title, artist, albumArtist, album, genre, composer, fileFormat |
||||
case year, bpm, rating, playCount, trackNumber, discNumber, bitrate, sampleRate |
||||
case fileSize, duration |
||||
case dateAdded, dateModified, lastPlayedAt |
||||
|
||||
var id: String { rawValue } |
||||
|
||||
var displayName: String { |
||||
switch self { |
||||
case .title: return "Title" |
||||
case .artist: return "Artist" |
||||
case .albumArtist: return "Album Artist" |
||||
case .album: return "Album" |
||||
case .genre: return "Genre" |
||||
case .composer: return "Composer" |
||||
case .fileFormat: return "File Format" |
||||
case .year: return "Year" |
||||
case .bpm: return "BPM" |
||||
case .rating: return "Rating" |
||||
case .playCount: return "Play Count" |
||||
case .trackNumber: return "Track Number" |
||||
case .discNumber: return "Disc Number" |
||||
case .bitrate: return "Bitrate" |
||||
case .sampleRate: return "Sample Rate" |
||||
case .fileSize: return "File Size" |
||||
case .duration: return "Duration" |
||||
case .dateAdded: return "Date Added" |
||||
case .dateModified: return "Date Modified" |
||||
case .lastPlayedAt: return "Last Played" |
||||
} |
||||
} |
||||
|
||||
var fieldType: FieldType { |
||||
switch self { |
||||
case .title, .artist, .albumArtist, .album, .genre, .composer, .fileFormat: |
||||
return .string |
||||
case .year, .bpm, .rating, .playCount, .trackNumber, .discNumber, .bitrate, .sampleRate, .fileSize: |
||||
return .int |
||||
case .duration: |
||||
return .double |
||||
case .dateAdded, .dateModified, .lastPlayedAt: |
||||
return .date |
||||
} |
||||
} |
||||
|
||||
var validOperators: [ConditionOperator] { |
||||
switch fieldType { |
||||
case .string: return [.equals, .startsWith] |
||||
case .int, .double, .date: return [.equals, .greaterThan, .lessThan] |
||||
} |
||||
} |
||||
|
||||
var defaultValue: ConditionValue { |
||||
switch fieldType { |
||||
case .string: return .string("") |
||||
case .int: return .int(0) |
||||
case .double: return .double(0) |
||||
case .date: return .date(Date()) |
||||
} |
||||
} |
||||
} |
||||
|
||||
enum ConditionOperator: String, Codable, Identifiable, Sendable { |
||||
case equals |
||||
case startsWith |
||||
case greaterThan |
||||
case lessThan |
||||
|
||||
var id: String { rawValue } |
||||
|
||||
var displayName: String { |
||||
switch self { |
||||
case .equals: return "is" |
||||
case .startsWith: return "starts with" |
||||
case .greaterThan: return "is greater than" |
||||
case .lessThan: return "is less than" |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Tagged union storing the actual filter value with its type. |
||||
// Uses custom Codable to survive JSON round-trips cleanly. |
||||
enum ConditionValue: Equatable, Hashable, Sendable { |
||||
case string(String) |
||||
case int(Int) |
||||
case double(Double) |
||||
case date(Date) |
||||
|
||||
var isEmpty: Bool { |
||||
if case .string(let s) = self { |
||||
return s.trimmingCharacters(in: .whitespaces).isEmpty |
||||
} |
||||
return false |
||||
} |
||||
} |
||||
|
||||
extension ConditionValue: Codable { |
||||
private enum CodingKeys: String, CodingKey { case type, value } |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
switch self { |
||||
case .string(let s): |
||||
try container.encode("string", forKey: .type) |
||||
try container.encode(s, forKey: .value) |
||||
case .int(let i): |
||||
try container.encode("int", forKey: .type) |
||||
try container.encode(i, forKey: .value) |
||||
case .double(let d): |
||||
try container.encode("double", forKey: .type) |
||||
try container.encode(d, forKey: .value) |
||||
case .date(let date): |
||||
try container.encode("date", forKey: .type) |
||||
try container.encode(date.timeIntervalSince1970, forKey: .value) |
||||
} |
||||
} |
||||
|
||||
init(from decoder: Decoder) throws { |
||||
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||
let type = try container.decode(String.self, forKey: .type) |
||||
switch type { |
||||
case "string": |
||||
self = .string(try container.decode(String.self, forKey: .value)) |
||||
case "int": |
||||
self = .int(try container.decode(Int.self, forKey: .value)) |
||||
case "double": |
||||
self = .double(try container.decode(Double.self, forKey: .value)) |
||||
case "date": |
||||
self = .date(Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .value))) |
||||
default: |
||||
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)") |
||||
} |
||||
} |
||||
} |
||||
|
||||
nonisolated struct SmartPlaylistCondition: Codable, Equatable, Hashable, Sendable { |
||||
var field: TrackField |
||||
var op: ConditionOperator |
||||
var value: ConditionValue |
||||
|
||||
var isEmpty: Bool { value.isEmpty } |
||||
} |
||||
@ -0,0 +1,130 @@ |
||||
import Foundation |
||||
|
||||
// `nonisolated` opts this struct out of the project-wide `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` |
||||
// setting (same reason as Track, Playlist, etc.). `Sendable` is omitted because the closure |
||||
// properties are not Sendable — the config is created and consumed exclusively on @MainActor. |
||||
nonisolated struct TrackContextMenuConfig { |
||||
let playlists: [Playlist] |
||||
let lastUsedPlaylistName: String? |
||||
let selectedPlaylist: Playlist? |
||||
let onAddToPlaylist: (Track, Playlist) -> Void |
||||
let onAddToLastPlaylist: ((Track) -> Void)? // nil hides the "Add to [last]" button; always non-nil in practice |
||||
let onRemoveFromPlaylist: ((Track) -> Void)? |
||||
// nil hides the corresponding item (e.g. when driving a remote device). |
||||
let onPlayNext: ((Track) -> Void)? |
||||
let onAddToQueue: ((Track) -> Void)? |
||||
// nil hides the "New Playlist…" item; always non-nil in practice. |
||||
let onAddToNewPlaylist: ((Track) -> Void)? |
||||
// Opens "Get Info" for the resolved target set (full selection if the |
||||
// right-clicked row is part of it, else just the clicked row). nil hides it. |
||||
let onGetInfo: (([Track]) -> Void)? |
||||
let onDelete: (([Track]) -> Void)? |
||||
|
||||
// Explicit init so that onPlayNext, onAddToQueue and onGetInfo default to nil, |
||||
// allowing existing call sites that omit them to keep compiling unchanged. |
||||
init( |
||||
playlists: [Playlist], |
||||
lastUsedPlaylistName: String?, |
||||
selectedPlaylist: Playlist?, |
||||
onAddToPlaylist: @escaping (Track, Playlist) -> Void, |
||||
onAddToLastPlaylist: ((Track) -> Void)?, |
||||
onRemoveFromPlaylist: ((Track) -> Void)?, |
||||
onPlayNext: ((Track) -> Void)? = nil, |
||||
onAddToQueue: ((Track) -> Void)? = nil, |
||||
onAddToNewPlaylist: ((Track) -> Void)? = nil, |
||||
onGetInfo: (([Track]) -> Void)? = nil, |
||||
onDelete: (([Track]) -> Void)? = nil |
||||
) { |
||||
self.playlists = playlists |
||||
self.lastUsedPlaylistName = lastUsedPlaylistName |
||||
self.selectedPlaylist = selectedPlaylist |
||||
self.onAddToPlaylist = onAddToPlaylist |
||||
self.onAddToLastPlaylist = onAddToLastPlaylist |
||||
self.onRemoveFromPlaylist = onRemoveFromPlaylist |
||||
self.onPlayNext = onPlayNext |
||||
self.onAddToQueue = onAddToQueue |
||||
self.onAddToNewPlaylist = onAddToNewPlaylist |
||||
self.onGetInfo = onGetInfo |
||||
self.onDelete = onDelete |
||||
} |
||||
} |
||||
|
||||
// A renderer-agnostic description of one context-menu entry. Both the AppKit |
||||
// table menu (TrackTableView) and the SwiftUI control-bar menu |
||||
// (TrackContextMenuModifier) render from the SAME list of these, so the two |
||||
// menus can never drift. |
||||
nonisolated enum TrackMenuEntry { |
||||
case button(title: String, action: () -> Void) |
||||
case submenu(title: String, items: [TrackMenuEntry]) |
||||
case separator |
||||
} |
||||
|
||||
nonisolated extension TrackContextMenuConfig { |
||||
/// The single source of truth for the track context menu. |
||||
/// - `primary`: the track that single-track actions operate on (e.g. the |
||||
/// right-clicked row, or the now-playing track in the control bar). |
||||
/// - `selection`: the full target set for multi-capable actions (Get Info). |
||||
/// Pass `[primary]` when there is no multi-selection. |
||||
func entries(primary track: Track, selection: [Track]) -> [TrackMenuEntry] { |
||||
var entries: [TrackMenuEntry] = [] |
||||
|
||||
if let onGetInfo { |
||||
let targets = selection.isEmpty ? [track] : selection |
||||
entries.append(.button(title: "Get Info") { onGetInfo(targets) }) |
||||
entries.append(.separator) |
||||
} |
||||
|
||||
if let onPlayNext { |
||||
entries.append(.button(title: "Play Next") { onPlayNext(track) }) |
||||
} |
||||
if let onAddToQueue { |
||||
entries.append(.button(title: "Add to Queue") { onAddToQueue(track) }) |
||||
} |
||||
entries.append(.separator) |
||||
|
||||
if let lastUsedPlaylistName, let onAddToLastPlaylist { |
||||
entries.append(.button(title: "Add to \(lastUsedPlaylistName)") { onAddToLastPlaylist(track) }) |
||||
entries.append(.separator) |
||||
} |
||||
|
||||
if !playlists.isEmpty || onAddToNewPlaylist != nil { |
||||
var sub: [TrackMenuEntry] = [] |
||||
if let onAddToNewPlaylist { |
||||
sub.append(.button(title: "New Playlist…") { onAddToNewPlaylist(track) }) |
||||
if !playlists.isEmpty { sub.append(.separator) } |
||||
} |
||||
for playlist in playlists { |
||||
sub.append(.button(title: playlist.name) { onAddToPlaylist(track, playlist) }) |
||||
} |
||||
entries.append(.submenu(title: "Add to Playlist", items: sub)) |
||||
} |
||||
|
||||
if selectedPlaylist != nil, let onRemoveFromPlaylist { |
||||
entries.append(.separator) |
||||
entries.append(.button(title: "Remove from Playlist") { onRemoveFromPlaylist(track) }) |
||||
} |
||||
|
||||
if let onDelete { |
||||
let targets = selection.isEmpty ? [track] : selection |
||||
entries.append(.separator) |
||||
entries.append(.button(title: "Delete") { onDelete(targets) }) |
||||
} |
||||
|
||||
return Self.normalizeSeparators(entries) |
||||
} |
||||
|
||||
/// Drops leading/trailing separators and collapses consecutive ones, so the |
||||
/// builder above can append separators freely without worrying about hygiene. |
||||
static func normalizeSeparators(_ entries: [TrackMenuEntry]) -> [TrackMenuEntry] { |
||||
var result: [TrackMenuEntry] = [] |
||||
for entry in entries { |
||||
if case .separator = entry { |
||||
if result.isEmpty { continue } |
||||
if case .separator? = result.last { continue } |
||||
} |
||||
result.append(entry) |
||||
} |
||||
if case .separator? = result.last { result.removeLast() } |
||||
return result |
||||
} |
||||
} |
||||
@ -0,0 +1,25 @@ |
||||
import Foundation |
||||
|
||||
@MainActor |
||||
protocol PlaybackProvider: AnyObject { |
||||
var isPlaying: Bool { get } |
||||
var currentTime: Double { get } |
||||
var duration: Double { get } |
||||
var volume: Float { get } |
||||
var isScrubbing: Bool { get } |
||||
|
||||
var onTrackFinished: (() -> Void)? { get set } |
||||
var onPlaybackStateChanged: (() -> Void)? { get set } |
||||
|
||||
func urlForTrack(_ track: Track) -> URL? |
||||
func play(url: URL) |
||||
func pause() |
||||
func resume() |
||||
func togglePlayPause() |
||||
func seek(to position: Double) |
||||
func setVolume(_ level: Float) |
||||
func stop() |
||||
func beginScrubbing() |
||||
func scrub(to position: Double) |
||||
func endScrubbing(at position: Double) |
||||
} |
||||
@ -0,0 +1,100 @@ |
||||
import Foundation |
||||
import Observation |
||||
import MusicShared |
||||
|
||||
@Observable |
||||
final class RemotePlaybackProvider: PlaybackProvider { |
||||
var isPlaying = false |
||||
var currentTime: Double = 0 |
||||
var duration: Double = 0 |
||||
var volume: Float = 0.65 |
||||
private(set) var isScrubbing = false |
||||
|
||||
var onTrackFinished: (() -> Void)? |
||||
var onPlaybackStateChanged: (() -> Void)? |
||||
|
||||
private weak var commandSender: RemoteCommandSender? |
||||
|
||||
init(commandSender: RemoteCommandSender) { |
||||
self.commandSender = commandSender |
||||
} |
||||
|
||||
func urlForTrack(_ track: Track) -> URL? { |
||||
nil |
||||
} |
||||
|
||||
func play(url: URL) { |
||||
// Remote mode uses sendPlayCommand(trackId:queueIds:) instead |
||||
} |
||||
|
||||
func sendPlayCommand(trackId: Int64, queueIds: [Int64]) { |
||||
commandSender?.sendCommand(.play(trackId: trackId, queueIds: queueIds)) |
||||
} |
||||
|
||||
func pause() { |
||||
isPlaying = false |
||||
commandSender?.sendCommand(.pause) |
||||
onPlaybackStateChanged?() |
||||
} |
||||
|
||||
func resume() { |
||||
isPlaying = true |
||||
commandSender?.sendCommand(.resume) |
||||
onPlaybackStateChanged?() |
||||
} |
||||
|
||||
func togglePlayPause() { |
||||
if isPlaying { pause() } else { resume() } |
||||
} |
||||
|
||||
func seek(to position: Double) { |
||||
currentTime = position |
||||
commandSender?.sendCommand(.seek(position: position)) |
||||
} |
||||
|
||||
func setVolume(_ level: Float) { |
||||
volume = level |
||||
commandSender?.sendCommand(.setVolume(level: level)) |
||||
} |
||||
|
||||
func stop() { |
||||
isPlaying = false |
||||
currentTime = 0 |
||||
duration = 0 |
||||
onPlaybackStateChanged?() |
||||
} |
||||
|
||||
func beginScrubbing() { |
||||
isScrubbing = true |
||||
} |
||||
|
||||
func scrub(to position: Double) { |
||||
currentTime = position |
||||
} |
||||
|
||||
func endScrubbing(at position: Double) { |
||||
currentTime = position |
||||
isScrubbing = false |
||||
commandSender?.sendCommand(.seek(position: position)) |
||||
} |
||||
|
||||
func sendNext() { |
||||
commandSender?.sendCommand(.next) |
||||
} |
||||
|
||||
func sendPrevious() { |
||||
commandSender?.sendCommand(.previous) |
||||
} |
||||
|
||||
func sendToggleShuffle() { |
||||
commandSender?.sendCommand(.toggleShuffle) |
||||
} |
||||
|
||||
func applyRemoteState(_ state: PlaybackStatePayload) { |
||||
isPlaying = state.isPlaying |
||||
currentTime = state.currentTime |
||||
duration = state.duration |
||||
volume = state.volume |
||||
onPlaybackStateChanged?() |
||||
} |
||||
} |
||||
@ -0,0 +1,303 @@ |
||||
import AVFoundation |
||||
import Foundation |
||||
import Observation |
||||
import MusicShared |
||||
import os |
||||
|
||||
@Observable |
||||
final class StreamingPlaybackProvider: PlaybackProvider { |
||||
var isPlaying = false |
||||
var currentTime: Double = 0 |
||||
var duration: Double = 0 |
||||
var volume: Float = 0.65 { |
||||
didSet { player?.volume = volume } |
||||
} |
||||
|
||||
private(set) var isScrubbing = false |
||||
|
||||
var playbackError: String? |
||||
var isBuffering = false |
||||
|
||||
var onTrackFinished: (() -> Void)? |
||||
var onPlaybackStateChanged: (() -> Void)? |
||||
|
||||
private(set) var player: AVPlayer? |
||||
private var timeObserver: Any? |
||||
private var endObserver: NSObjectProtocol? |
||||
private var failedObserver: NSObjectProtocol? |
||||
private var statusObservation: NSKeyValueObservation? |
||||
private var timeControlObservation: NSKeyValueObservation? |
||||
private var seekInProgress = false |
||||
private var pendingSeekTime: Double? |
||||
private var playTask: Task<Void, Never>? |
||||
|
||||
private let hostURL: String |
||||
private let apiKey: String |
||||
private let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingPlayback") |
||||
|
||||
init(hostURL: String, apiKey: String) { |
||||
self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL |
||||
self.apiKey = apiKey |
||||
} |
||||
|
||||
func urlForTrack(_ track: Track) -> URL? { |
||||
guard let trackId = track.id else { return nil } |
||||
// StreamingRoutes.trackFile already includes ?id=TRACKID |
||||
return URL(string: "\(hostURL)\(StreamingRoutes.trackFile(trackId: trackId))&token=\(apiKey)") |
||||
} |
||||
|
||||
func play(url: URL) { |
||||
cleanup() |
||||
playbackError = nil |
||||
isBuffering = true |
||||
isPlaying = true |
||||
onPlaybackStateChanged?() |
||||
|
||||
playTask = Task { [weak self] in |
||||
guard let self else { return } |
||||
|
||||
// Pre-flight: verify the URL is reachable before handing to AVPlayer |
||||
do { |
||||
var request = URLRequest(url: url) |
||||
request.httpMethod = "GET" |
||||
// Only fetch first byte to avoid downloading the whole file |
||||
request.setValue("bytes=0-0", forHTTPHeaderField: "Range") |
||||
let (data, response) = try await URLSession.shared.data(for: request) |
||||
|
||||
guard !Task.isCancelled else { return } |
||||
|
||||
if let http = response as? HTTPURLResponse, http.statusCode != 200 && http.statusCode != 206 { |
||||
let body = String(data: data.prefix(500), encoding: .utf8) ?? "" |
||||
let msg: String |
||||
switch http.statusCode { |
||||
case 401: msg = "Server rejected authentication" |
||||
case 404: msg = "Route not found (HTTP 404)" |
||||
case 500...599: msg = "Server error (\(http.statusCode))" |
||||
default: msg = "HTTP \(http.statusCode)" |
||||
} |
||||
self.logger.error("\(msg, privacy: .public) — body: \(body, privacy: .public)") |
||||
self.playbackError = msg |
||||
self.isPlaying = false |
||||
self.isBuffering = false |
||||
self.onPlaybackStateChanged?() |
||||
return |
||||
} |
||||
} catch { |
||||
guard !Task.isCancelled else { return } |
||||
self.logger.error("Network error: \(error.localizedDescription, privacy: .public)") |
||||
self.playbackError = "Network: \(error.localizedDescription)" |
||||
self.isPlaying = false |
||||
self.isBuffering = false |
||||
self.onPlaybackStateChanged?() |
||||
return |
||||
} |
||||
|
||||
guard !Task.isCancelled else { return } |
||||
self.logger.info("Pre-flight OK, starting AVPlayer for \(url.absoluteString, privacy: .public)") |
||||
self.startAVPlayer(url: url) |
||||
} |
||||
} |
||||
|
||||
func startAVPlayer(url: URL) { |
||||
let asset = AVURLAsset(url: url) |
||||
let item = AVPlayerItem(asset: asset) |
||||
player = AVPlayer(playerItem: item) |
||||
player?.volume = volume |
||||
|
||||
statusObservation = item.observe(\.status, options: [.new]) { [weak self] (playerItem: AVPlayerItem, _) in |
||||
DispatchQueue.main.async { |
||||
guard let self else { return } |
||||
switch playerItem.status { |
||||
case .failed: |
||||
let msg = playerItem.error?.localizedDescription ?? "Unknown playback error" |
||||
self.logger.error("AVPlayer failed: \(msg, privacy: .public)") |
||||
self.playbackError = msg |
||||
self.isPlaying = false |
||||
self.isBuffering = false |
||||
self.onPlaybackStateChanged?() |
||||
case .readyToPlay: |
||||
self.logger.info("Stream ready") |
||||
self.playbackError = nil |
||||
self.isBuffering = false |
||||
self.onPlaybackStateChanged?() |
||||
default: |
||||
break |
||||
} |
||||
} |
||||
} |
||||
|
||||
timeControlObservation = player?.observe(\.timeControlStatus, options: [.new]) { [weak self] (avPlayer: AVPlayer, _) in |
||||
DispatchQueue.main.async { |
||||
guard let self else { return } |
||||
switch avPlayer.timeControlStatus { |
||||
case .waitingToPlayAtSpecifiedRate: |
||||
self.isBuffering = true |
||||
case .playing: |
||||
self.isBuffering = false |
||||
case .paused: |
||||
self.isBuffering = false |
||||
@unknown default: |
||||
break |
||||
} |
||||
self.onPlaybackStateChanged?() |
||||
} |
||||
} |
||||
|
||||
timeObserver = player?.addPeriodicTimeObserver( |
||||
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), |
||||
queue: .main |
||||
) { [weak self] time in |
||||
guard let self, !self.isScrubbing else { return } |
||||
self.currentTime = time.seconds |
||||
if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite { |
||||
self.duration = dur.seconds |
||||
} |
||||
self.onPlaybackStateChanged?() |
||||
} |
||||
|
||||
endObserver = NotificationCenter.default.addObserver( |
||||
forName: .AVPlayerItemDidPlayToEndTime, |
||||
object: item, |
||||
queue: .main |
||||
) { [weak self] _ in |
||||
self?.isPlaying = false |
||||
self?.currentTime = 0 |
||||
self?.onPlaybackStateChanged?() |
||||
self?.onTrackFinished?() |
||||
} |
||||
|
||||
failedObserver = NotificationCenter.default.addObserver( |
||||
forName: .AVPlayerItemFailedToPlayToEndTime, |
||||
object: item, |
||||
queue: .main |
||||
) { [weak self] notification in |
||||
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error |
||||
let msg = error?.localizedDescription ?? "Playback interrupted" |
||||
self?.playbackError = msg |
||||
self?.isPlaying = false |
||||
self?.isBuffering = false |
||||
self?.onPlaybackStateChanged?() |
||||
} |
||||
|
||||
player?.play() |
||||
} |
||||
|
||||
func pause() { |
||||
player?.pause() |
||||
isPlaying = false |
||||
onPlaybackStateChanged?() |
||||
} |
||||
|
||||
func resume() { |
||||
player?.play() |
||||
isPlaying = true |
||||
onPlaybackStateChanged?() |
||||
} |
||||
|
||||
func togglePlayPause() { |
||||
if isPlaying { pause() } else { resume() } |
||||
} |
||||
|
||||
func seek(to time: Double) { |
||||
let clamped = max(0, min(time, duration)) |
||||
currentTime = clamped |
||||
player?.seek( |
||||
to: CMTime(seconds: clamped, preferredTimescale: 600), |
||||
toleranceBefore: .zero, |
||||
toleranceAfter: .zero |
||||
) |
||||
} |
||||
|
||||
func setVolume(_ level: Float) { |
||||
volume = level |
||||
} |
||||
|
||||
func beginScrubbing() { |
||||
isScrubbing = true |
||||
} |
||||
|
||||
func scrub(to time: Double) { |
||||
let clamped = max(0, min(time, duration)) |
||||
currentTime = clamped |
||||
pendingSeekTime = clamped |
||||
guard !seekInProgress else { return } |
||||
performPendingSeek() |
||||
} |
||||
|
||||
func endScrubbing(at time: Double) { |
||||
let clamped = max(0, min(time, duration)) |
||||
currentTime = clamped |
||||
pendingSeekTime = nil |
||||
seekInProgress = false |
||||
|
||||
player?.seek( |
||||
to: CMTime(seconds: clamped, preferredTimescale: 600), |
||||
toleranceBefore: .zero, |
||||
toleranceAfter: .zero |
||||
) { [weak self] _ in |
||||
DispatchQueue.main.async { |
||||
self?.isScrubbing = false |
||||
} |
||||
} |
||||
} |
||||
|
||||
func stop() { |
||||
cleanup() |
||||
isPlaying = false |
||||
isBuffering = false |
||||
playbackError = nil |
||||
currentTime = 0 |
||||
duration = 0 |
||||
onPlaybackStateChanged?() |
||||
} |
||||
|
||||
private func performPendingSeek() { |
||||
guard let time = pendingSeekTime else { return } |
||||
pendingSeekTime = nil |
||||
seekInProgress = true |
||||
|
||||
player?.seek( |
||||
to: CMTime(seconds: time, preferredTimescale: 600), |
||||
toleranceBefore: CMTime(seconds: 0.1, preferredTimescale: 600), |
||||
toleranceAfter: CMTime(seconds: 0.1, preferredTimescale: 600) |
||||
) { [weak self] _ in |
||||
DispatchQueue.main.async { |
||||
guard let self else { return } |
||||
self.seekInProgress = false |
||||
if self.pendingSeekTime != nil { |
||||
self.performPendingSeek() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func cleanup() { |
||||
playTask?.cancel() |
||||
playTask = nil |
||||
statusObservation?.invalidate() |
||||
statusObservation = nil |
||||
timeControlObservation?.invalidate() |
||||
timeControlObservation = nil |
||||
if let obs = timeObserver { |
||||
player?.removeTimeObserver(obs) |
||||
timeObserver = nil |
||||
} |
||||
if let obs = endObserver { |
||||
NotificationCenter.default.removeObserver(obs) |
||||
endObserver = nil |
||||
} |
||||
if let obs = failedObserver { |
||||
NotificationCenter.default.removeObserver(obs) |
||||
failedObserver = nil |
||||
} |
||||
player?.pause() |
||||
// Dissociate the item from the player to release its decode/render pipeline. |
||||
// Setting `player = nil` alone does NOT free the pipeline (ARC tears it down |
||||
// asynchronously); without this, pipelines accumulate across track switches |
||||
// until a new player can't acquire a decode session and fails with -16044. |
||||
player?.replaceCurrentItem(with: nil) |
||||
player = nil |
||||
} |
||||
|
||||
nonisolated deinit {} |
||||
} |
||||
@ -0,0 +1,42 @@ |
||||
import Foundation |
||||
import ID3TagEditor |
||||
|
||||
// Writes ID3 string frames into mp3 files in place using ID3TagEditor 5.5.0. |
||||
// Builds a v2.3 tag with the managed frames; unmodeled frames (e.g. artwork) are |
||||
// not preserved in v1 — acceptable; TagLib integration is a later task. |
||||
// rating is NOT written (DB-only in v1). |
||||
nonisolated struct ID3TagWriter: TagWriter { |
||||
func write(_ fields: EditableTrackFields, to url: URL) throws { |
||||
// Build a v2.3 tag. ID32v3TagBuilder is the correct class name in 5.5.0. |
||||
// All builder methods return Self so they can be chained, but we call them |
||||
// imperatively here because optional fields are conditionally added. |
||||
let builder = ID32v3TagBuilder() |
||||
_ = builder |
||||
.title(frame: ID3FrameWithStringContent(content: fields.title)) |
||||
.artist(frame: ID3FrameWithStringContent(content: fields.artist)) |
||||
.albumArtist(frame: ID3FrameWithStringContent(content: fields.albumArtist)) |
||||
.album(frame: ID3FrameWithStringContent(content: fields.album)) |
||||
.genre(frame: ID3FrameGenre(genre: nil, description: fields.genre)) |
||||
.composer(frame: ID3FrameWithStringContent(content: fields.composer)) |
||||
|
||||
// recordingYear takes ID3FrameWithIntegerContent in 5.5.0 (TYER frame). |
||||
if let y = fields.year { |
||||
_ = builder.recordingYear(frame: ID3FrameWithIntegerContent(value: y)) |
||||
} |
||||
// trackPosition / discPosition use ID3FramePartOfTotal. |
||||
if let n = fields.trackNumber { |
||||
_ = builder.trackPosition(frame: ID3FramePartOfTotal(part: n, total: nil)) |
||||
} |
||||
if let d = fields.discNumber { |
||||
_ = builder.discPosition(frame: ID3FramePartOfTotal(part: d, total: nil)) |
||||
} |
||||
// beatsPerMinute uses ID3FrameWithIntegerContent (TBPM frame). |
||||
if let b = fields.bpm { |
||||
_ = builder.beatsPerMinute(frame: ID3FrameWithIntegerContent(value: b)) |
||||
} |
||||
|
||||
let tag = builder.build() |
||||
// write(tag:to:andSaveTo:) overwrites in place when newPath is nil. |
||||
try ID3TagEditor().write(tag: tag, to: url.path) |
||||
} |
||||
} |
||||
@ -0,0 +1,53 @@ |
||||
import Foundation |
||||
import AVFoundation |
||||
|
||||
// Writes iTunes/common metadata into m4a-family files via a passthrough export |
||||
// to a temp file, then an atomic replace of the original. NOTE: passthrough |
||||
// export rewrites the metadata set, so unmodeled atoms may not survive — fine for v1. |
||||
nonisolated struct MP4TagWriter: TagWriter { |
||||
func write(_ fields: EditableTrackFields, to url: URL) throws { |
||||
let asset = AVURLAsset(url: url) |
||||
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else { |
||||
throw TagWriterError.exportUnavailable |
||||
} |
||||
let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) |
||||
.appendingPathComponent(UUID().uuidString + ".m4a") |
||||
export.outputURL = tmp |
||||
export.outputFileType = .m4a |
||||
export.metadata = Self.items(from: fields) |
||||
|
||||
let sema = DispatchSemaphore(value: 0) |
||||
var exportError: Error? |
||||
export.exportAsynchronously { |
||||
if export.status != .completed { exportError = export.error ?? TagWriterError.exportFailed } |
||||
sema.signal() |
||||
} |
||||
sema.wait() |
||||
if let exportError { try? FileManager.default.removeItem(at: tmp); throw exportError } |
||||
|
||||
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) |
||||
} |
||||
|
||||
private static func items(from f: EditableTrackFields) -> [AVMetadataItem] { |
||||
func item(_ id: AVMetadataIdentifier, _ value: (any NSCopying & NSObjectProtocol)?) -> AVMetadataItem? { |
||||
guard let value else { return nil } |
||||
let m = AVMutableMetadataItem() |
||||
m.identifier = id |
||||
m.value = value |
||||
return m |
||||
} |
||||
var out: [AVMetadataItem?] = [ |
||||
item(.commonIdentifierTitle, f.title as NSString), |
||||
item(.commonIdentifierArtist, f.artist as NSString), |
||||
item(.commonIdentifierAlbumName, f.album as NSString), |
||||
item(.iTunesMetadataAlbumArtist, f.albumArtist as NSString), |
||||
item(.iTunesMetadataUserGenre, f.genre as NSString), |
||||
item(.iTunesMetadataComposer, f.composer as NSString), |
||||
] |
||||
if let y = f.year { out.append(item(.iTunesMetadataReleaseDate, String(y) as NSString)) } |
||||
if let n = f.trackNumber { out.append(item(.iTunesMetadataTrackNumber, NSNumber(value: n))) } |
||||
if let d = f.discNumber { out.append(item(.iTunesMetadataDiscNumber, NSNumber(value: d))) } |
||||
if let b = f.bpm { out.append(item(.iTunesMetadataBeatsPerMin, NSNumber(value: b))) } |
||||
return out.compactMap { $0 } |
||||
} |
||||
} |
||||
@ -0,0 +1,20 @@ |
||||
import Foundation |
||||
|
||||
// Writes the editable, tag-mappable fields into an audio file. rating is |
||||
// intentionally NOT written (DB-only in v1). Implementations write atomically. |
||||
nonisolated protocol TagWriter: Sendable { |
||||
func write(_ fields: EditableTrackFields, to url: URL) throws |
||||
} |
||||
|
||||
nonisolated enum TagWriterError: Error { case exportUnavailable, exportFailed } |
||||
|
||||
nonisolated enum TagWriterFactory { |
||||
// Returns nil for formats with no v1 writer (flac/wav/aiff) → DB-only. |
||||
static func writer(for url: URL) -> TagWriter? { |
||||
switch url.pathExtension.lowercased() { |
||||
case "mp3": return ID3TagWriter() |
||||
case "m4a", "alac", "aac": return MP4TagWriter() |
||||
default: return nil |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,57 @@ |
||||
import Foundation |
||||
|
||||
nonisolated struct TrackEditWarning: Sendable, Equatable { |
||||
enum Kind: Sendable, Equatable { case dbOnlyUnsupported, fileWriteFailed } |
||||
let trackId: Int64? |
||||
let fileURL: String |
||||
let kind: Kind |
||||
let reason: String |
||||
} |
||||
|
||||
// Orchestrates a metadata save: apply edited fields → best-effort file-tag write |
||||
// → refresh file stats on success → DB update. The DB is ALWAYS updated; file |
||||
// writeback failures are collected as warnings, never blocking the library edit. |
||||
nonisolated final class TrackEditService: Sendable { |
||||
private let database: DatabaseService |
||||
private let writerFactory: @Sendable (URL) -> TagWriter? |
||||
|
||||
init(database: DatabaseService, |
||||
writerFactory: @escaping @Sendable (URL) -> TagWriter? = TagWriterFactory.writer) { |
||||
self.database = database |
||||
self.writerFactory = writerFactory |
||||
} |
||||
|
||||
func save(_ values: EditableTrackFields, |
||||
editing edited: Set<EditableTrackField>, |
||||
to tracks: [Track]) -> [TrackEditWarning] { |
||||
var warnings: [TrackEditWarning] = [] |
||||
for track in tracks { |
||||
var updated = values.apply(editing: edited, to: track) |
||||
// rating and dateAdded are DB-only (not file tags); only attempt a file |
||||
// write if some tag-mappable field actually changed. |
||||
let tagFieldsChanged = !edited.subtracting([.rating, .dateAdded]).isEmpty |
||||
|
||||
if let url = URL(string: track.fileURL), tagFieldsChanged { |
||||
if let writer = writerFactory(url) { |
||||
do { |
||||
try writer.write(values, to: url) |
||||
if let stats = try? TrackFileStats.compute(for: url) { |
||||
updated.fileSize = stats.fileSize |
||||
updated.dateModified = stats.dateModified |
||||
updated.fileHash = stats.fileHash |
||||
} |
||||
} catch { |
||||
warnings.append(.init(trackId: track.id, fileURL: track.fileURL, |
||||
kind: .fileWriteFailed, reason: error.localizedDescription)) |
||||
} |
||||
} else { |
||||
warnings.append(.init(trackId: track.id, fileURL: track.fileURL, |
||||
kind: .dbOnlyUnsupported, |
||||
reason: "Tag writing not supported for .\(url.pathExtension)")) |
||||
} |
||||
} |
||||
try? database.updateTrack(updated) |
||||
} |
||||
return warnings |
||||
} |
||||
} |
||||
@ -0,0 +1,22 @@ |
||||
import Foundation |
||||
|
||||
// Reads a file's size + modification date and derives the library fileHash. |
||||
// Centralizes the computation so ScannerService (import) and TrackEditService |
||||
// (post-writeback refresh) can never drift. Hash uses Track.computeHash so the |
||||
// format stays identical to import-time hashes. |
||||
nonisolated struct TrackFileStats: Sendable { |
||||
let fileSize: Int64 |
||||
let dateModified: Date |
||||
let fileHash: String |
||||
|
||||
static func compute(for url: URL) throws -> TrackFileStats { |
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path) |
||||
let fileSize = attrs[.size] as? Int64 ?? 0 |
||||
let modDate = attrs[.modificationDate] as? Date ?? Date() |
||||
return TrackFileStats( |
||||
fileSize: fileSize, |
||||
dateModified: modDate, |
||||
fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate) |
||||
) |
||||
} |
||||
} |
||||
@ -0,0 +1,80 @@ |
||||
import AVFoundation |
||||
import Foundation |
||||
|
||||
final class HLSSegmenter: Sendable { |
||||
let fileURL: URL |
||||
let duration: Double |
||||
|
||||
init(fileURL: URL) throws { |
||||
self.fileURL = fileURL |
||||
let asset = AVURLAsset(url: fileURL) |
||||
let durationCM = asset.duration |
||||
guard durationCM.isValid, !durationCM.isIndefinite else { |
||||
throw HLSSegmenterError.invalidDuration |
||||
} |
||||
self.duration = durationCM.seconds |
||||
} |
||||
|
||||
func segment(at index: Int, segmentDuration: Double) async throws -> Data? { |
||||
let startTime = Double(index) * segmentDuration |
||||
guard startTime < duration else { return nil } |
||||
|
||||
let endTime = min(startTime + segmentDuration, duration) |
||||
let timeRange = CMTimeRange( |
||||
start: CMTime(seconds: startTime, preferredTimescale: 600), |
||||
end: CMTime(seconds: endTime, preferredTimescale: 600) |
||||
) |
||||
|
||||
let asset = AVURLAsset(url: fileURL) |
||||
|
||||
guard let track = try await asset.loadTracks(withMediaType: .audio).first else { |
||||
throw HLSSegmenterError.noAudioTrack |
||||
} |
||||
|
||||
let reader = try AVAssetReader(asset: asset) |
||||
reader.timeRange = timeRange |
||||
|
||||
// Try passthrough first (for MP3 sources), fall back to PCM if needed |
||||
let output: AVAssetReaderOutput |
||||
let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: nil) |
||||
if reader.canAdd(trackOutput) { |
||||
reader.add(trackOutput) |
||||
output = trackOutput |
||||
} else { |
||||
let pcmOutput = AVAssetReaderTrackOutput(track: track, outputSettings: [ |
||||
AVFormatIDKey: kAudioFormatLinearPCM, |
||||
AVSampleRateKey: 44100, |
||||
AVNumberOfChannelsKey: 2, |
||||
AVLinearPCMBitDepthKey: 16, |
||||
AVLinearPCMIsFloatKey: false, |
||||
AVLinearPCMIsBigEndianKey: false, |
||||
]) |
||||
reader.add(pcmOutput) |
||||
output = pcmOutput |
||||
} |
||||
|
||||
reader.startReading() |
||||
|
||||
var segmentData = Data() |
||||
while let sampleBuffer = output.copyNextSampleBuffer() { |
||||
if let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) { |
||||
let length = CMBlockBufferGetDataLength(blockBuffer) |
||||
var bytes = [UInt8](repeating: 0, count: length) |
||||
CMBlockBufferCopyDataBytes(blockBuffer, atOffset: 0, dataLength: length, destination: &bytes) |
||||
segmentData.append(contentsOf: bytes) |
||||
} |
||||
} |
||||
|
||||
guard reader.status == .completed else { |
||||
throw HLSSegmenterError.readFailed(reader.error) |
||||
} |
||||
|
||||
return segmentData |
||||
} |
||||
} |
||||
|
||||
enum HLSSegmenterError: Error { |
||||
case invalidDuration |
||||
case noAudioTrack |
||||
case readFailed(Error?) |
||||
} |
||||
@ -0,0 +1,219 @@ |
||||
import Foundation |
||||
import Observation |
||||
import MusicShared |
||||
import os |
||||
|
||||
@MainActor |
||||
@Observable |
||||
final class StreamingClient { |
||||
enum State: Equatable { |
||||
case disconnected |
||||
case connecting |
||||
case downloadingDB |
||||
case connected(hostName: String) |
||||
case error(message: String) |
||||
|
||||
var isConnected: Bool { |
||||
if case .connected = self { return true } |
||||
return false |
||||
} |
||||
} |
||||
|
||||
var state: State = .disconnected |
||||
var onDBReady: (() -> Void)? |
||||
private(set) var serverCapabilities: [String] = [] |
||||
|
||||
private var hostURL: String = "" |
||||
private var apiKey: String = "" |
||||
private var webSocketTask: URLSessionWebSocketTask? |
||||
private let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingClient") |
||||
|
||||
static var streamingDBPath: String { |
||||
let appSupport = FileManager.default.urls( |
||||
for: .applicationSupportDirectory, in: .userDomainMask |
||||
).first!.appendingPathComponent("Music", isDirectory: true) |
||||
return appSupport.appendingPathComponent("streaming_db.sqlite").path |
||||
} |
||||
|
||||
func connect(hostURL: String, apiKey: String) async { |
||||
self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL |
||||
self.apiKey = apiKey |
||||
state = .connecting |
||||
|
||||
do { |
||||
let authResponse = try await authenticate() |
||||
logger.info("Authenticated with host: \(authResponse.hostName) (protocol v\(authResponse.protocolVersion), capabilities: \(authResponse.capabilities ?? []))") |
||||
serverCapabilities = authResponse.capabilities ?? [] |
||||
|
||||
state = .downloadingDB |
||||
try await downloadDatabase() |
||||
logger.info("Database downloaded") |
||||
|
||||
state = .connected(hostName: authResponse.hostName) |
||||
} catch { |
||||
logger.error("Connection failed: \(error.localizedDescription)") |
||||
state = .error(message: error.localizedDescription) |
||||
} |
||||
} |
||||
|
||||
func disconnect() { |
||||
webSocketTask?.cancel(with: .normalClosure, reason: nil) |
||||
webSocketTask = nil |
||||
deleteStreamingDB() |
||||
state = .disconnected |
||||
} |
||||
|
||||
func requestDBRefresh() { |
||||
sendCommand(.refreshDB) |
||||
} |
||||
|
||||
// MARK: - Auth |
||||
|
||||
private func authenticate() async throws -> AuthResponse { |
||||
let url = URL(string: "\(hostURL)\(StreamingRoutes.auth)")! |
||||
var request = URLRequest(url: url) |
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request) |
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw StreamingClientError.invalidResponse |
||||
} |
||||
|
||||
if httpResponse.statusCode == 401 { |
||||
throw StreamingClientError.unauthorized |
||||
} |
||||
|
||||
guard httpResponse.statusCode == 200 else { |
||||
throw StreamingClientError.serverError(httpResponse.statusCode) |
||||
} |
||||
|
||||
return try JSONDecoder().decode(AuthResponse.self, from: data) |
||||
} |
||||
|
||||
// MARK: - DB Download |
||||
|
||||
private func downloadDatabase() async throws { |
||||
let url = URL(string: "\(hostURL)\(StreamingRoutes.db)")! |
||||
var request = URLRequest(url: url) |
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request) |
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { |
||||
throw StreamingClientError.dbDownloadFailed |
||||
} |
||||
|
||||
let dirURL = URL(fileURLWithPath: Self.streamingDBPath).deletingLastPathComponent() |
||||
try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) |
||||
try data.write(to: URL(fileURLWithPath: Self.streamingDBPath)) |
||||
|
||||
logger.info("Database saved (\(data.count) bytes)") |
||||
} |
||||
|
||||
// MARK: - WebSocket |
||||
|
||||
private func connectWebSocket() { |
||||
let wsURLString = hostURL |
||||
.replacingOccurrences(of: "https://", with: "wss://") |
||||
.replacingOccurrences(of: "http://", with: "ws://") |
||||
guard let url = URL(string: "\(wsURLString)\(StreamingRoutes.ws)") else { return } |
||||
|
||||
var request = URLRequest(url: url) |
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") |
||||
|
||||
let task = URLSession.shared.webSocketTask(with: request) |
||||
task.resume() |
||||
self.webSocketTask = task |
||||
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" |
||||
let handshake = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: appVersion) |
||||
if let data = try? JSONEncoder().encode(handshake), |
||||
let string = String(data: data, encoding: .utf8) { |
||||
task.send(.string(string)) { [weak self] error in |
||||
if let error { |
||||
self?.logger.error("Failed to send handshake: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
} |
||||
|
||||
receiveWebSocketMessages() |
||||
} |
||||
|
||||
private func receiveWebSocketMessages() { |
||||
webSocketTask?.receive { [weak self] result in |
||||
Task { @MainActor [weak self] in |
||||
guard let self else { return } |
||||
switch result { |
||||
case .success(let message): |
||||
self.handleWebSocketMessage(message) |
||||
self.receiveWebSocketMessages() |
||||
case .failure(let error): |
||||
self.logger.error("WebSocket error: \(error.localizedDescription)") |
||||
if self.state.isConnected { |
||||
self.state = .error(message: "Connection lost") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) { |
||||
let data: Data |
||||
switch message { |
||||
case .string(let text): |
||||
guard let d = text.data(using: .utf8) else { return } |
||||
data = d |
||||
case .data(let d): |
||||
data = d |
||||
@unknown default: |
||||
return |
||||
} |
||||
|
||||
do { |
||||
let event = try JSONDecoder().decode(HostEvent.self, from: data) |
||||
switch event { |
||||
case .playbackState: |
||||
break |
||||
case .dbReady: |
||||
onDBReady?() |
||||
case .error(let message): |
||||
logger.error("Host error: \(message)") |
||||
} |
||||
} catch { |
||||
logger.error("Failed to decode event: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
private func sendCommand(_ command: RemoteCommand) { |
||||
guard let data = try? JSONEncoder().encode(command), |
||||
let string = String(data: data, encoding: .utf8) else { return } |
||||
webSocketTask?.send(.string(string)) { [weak self] error in |
||||
if let error { |
||||
self?.logger.error("Failed to send command: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
} |
||||
|
||||
private func deleteStreamingDB() { |
||||
let path = Self.streamingDBPath |
||||
if FileManager.default.fileExists(atPath: path) { |
||||
try? FileManager.default.removeItem(atPath: path) |
||||
logger.info("Deleted streaming DB") |
||||
} |
||||
} |
||||
} |
||||
|
||||
enum StreamingClientError: LocalizedError { |
||||
case invalidResponse |
||||
case unauthorized |
||||
case serverError(Int) |
||||
case dbDownloadFailed |
||||
|
||||
var errorDescription: String? { |
||||
switch self { |
||||
case .invalidResponse: return "Invalid server response" |
||||
case .unauthorized: return "Invalid API key" |
||||
case .serverError(let code): return "Server error (\(code))" |
||||
case .dbDownloadFailed: return "Failed to download library" |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,83 @@ |
||||
import SwiftUI |
||||
|
||||
struct StreamingConnectionSheet: View { |
||||
@Binding var hostURL: String |
||||
@Binding var apiKey: String |
||||
@Bindable var client: StreamingClient |
||||
@Binding var isPresented: Bool |
||||
var onConnect: () -> Void |
||||
|
||||
private var isConnecting: Bool { |
||||
switch client.state { |
||||
case .connecting, .downloadingDB: return true |
||||
default: return false |
||||
} |
||||
} |
||||
|
||||
var body: some View { |
||||
VStack(spacing: 16) { |
||||
Text("Connect to Streaming Host") |
||||
.font(.headline) |
||||
|
||||
TextField("Host URL (e.g. https://music.example.com)", text: $hostURL) |
||||
.textFieldStyle(.roundedBorder) |
||||
.disabled(isConnecting) |
||||
|
||||
SecureField("API Key", text: $apiKey) |
||||
.textFieldStyle(.roundedBorder) |
||||
.disabled(isConnecting) |
||||
|
||||
statusView |
||||
|
||||
HStack { |
||||
Button("Cancel") { |
||||
if isConnecting { |
||||
client.disconnect() |
||||
} |
||||
isPresented = false |
||||
} |
||||
|
||||
Button(isConnecting ? "Connecting..." : "Connect") { |
||||
onConnect() |
||||
} |
||||
.disabled(hostURL.isEmpty || apiKey.isEmpty || isConnecting) |
||||
.keyboardShortcut(.defaultAction) |
||||
} |
||||
} |
||||
.padding(24) |
||||
.frame(width: 420) |
||||
.onChange(of: client.state) { _, newState in |
||||
if newState.isConnected { |
||||
isPresented = false |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ViewBuilder |
||||
private var statusView: some View { |
||||
switch client.state { |
||||
case .connecting: |
||||
HStack(spacing: 8) { |
||||
ProgressView().controlSize(.small) |
||||
Text("Authenticating...").foregroundStyle(.secondary) |
||||
} |
||||
case .downloadingDB: |
||||
HStack(spacing: 8) { |
||||
ProgressView().controlSize(.small) |
||||
Text("Downloading library...").foregroundStyle(.secondary) |
||||
} |
||||
case .error(let message): |
||||
Text(message) |
||||
.foregroundStyle(.red) |
||||
.font(.system(size: 12)) |
||||
case .connected: |
||||
if !client.serverCapabilities.contains("file-streaming") { |
||||
Label("Host needs update — streaming may not work", systemImage: "exclamationmark.triangle.fill") |
||||
.foregroundStyle(.orange) |
||||
.font(.system(size: 12)) |
||||
} |
||||
default: |
||||
EmptyView() |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,418 @@ |
||||
import Foundation |
||||
import MusicShared |
||||
import os |
||||
|
||||
// MARK: - StreamingServer |
||||
|
||||
/// Hummingbird-based HTTP server that exposes the music library for streaming. |
||||
/// |
||||
/// Endpoints: |
||||
/// - `GET /auth` — validate API key, return `AuthResponse` JSON |
||||
/// - `GET /db` — backup SQLite and serve the file |
||||
/// - `GET /tracks/:trackId/stream.m3u8` — HLS manifest |
||||
/// - `GET /tracks/:trackId/segments/:index.mp3` — audio segment data |
||||
/// |
||||
/// WebSocket at `/ws` is planned but deferred until the upstream |
||||
/// swift-websocket NIOSSL dependency issue is resolved. |
||||
@MainActor |
||||
@Observable |
||||
final class StreamingServer { |
||||
|
||||
// MARK: - Observable State |
||||
|
||||
var isRunning = false |
||||
private(set) var actualPort: Int? |
||||
|
||||
// MARK: - Dependencies |
||||
|
||||
private let db: DatabaseService |
||||
private let apiKey: String |
||||
private let requestedPort: Int |
||||
|
||||
/// Cache of HLSSegmenter instances keyed by track ID to avoid re-parsing AVAsset metadata. |
||||
private let segmenterCache = SegmenterCache() |
||||
|
||||
/// Continuation-based hook so `start()` can wait for the OS-assigned port. |
||||
private var portContinuation: CheckedContinuation<Int, Never>? |
||||
|
||||
/// Task running the Hummingbird application; cancelled on `stop()`. |
||||
private var serverTask: Task<Void, any Error>? |
||||
|
||||
// MARK: - Init |
||||
|
||||
init(db: DatabaseService, apiKey: String, port: Int = StreamingConstants.defaultPort) { |
||||
self.db = db |
||||
self.apiKey = apiKey |
||||
self.requestedPort = port |
||||
} |
||||
|
||||
// MARK: - Lifecycle |
||||
|
||||
func start() async throws { |
||||
guard !isRunning else { return } |
||||
|
||||
// Capture immutable/sendable values for use in Sendable closures |
||||
let db = self.db |
||||
let apiKey = self.apiKey |
||||
let port = self.requestedPort |
||||
let segmenterCache = self.segmenterCache |
||||
|
||||
let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingServer") |
||||
|
||||
// Build the HTTP router |
||||
let router = Router() |
||||
|
||||
// GET /ping — diagnostic endpoint |
||||
router.get("ping") { _, _ -> Response in |
||||
Response(status: .ok, body: .init(byteBuffer: ByteBuffer(string: "pong"))) |
||||
} |
||||
|
||||
// GET /ping/:value — diagnostic for parameterized routes |
||||
router.get("ping/:value") { _, context -> Response in |
||||
let value = context.parameters.get("value") ?? "nil" |
||||
return Response(status: .ok, body: .init(byteBuffer: ByteBuffer(string: "echo: \(value)"))) |
||||
} |
||||
|
||||
// GET /auth |
||||
router.get("auth") { request, _ -> Response in |
||||
// Validate auth |
||||
guard let authHeader = request.headers[.authorization], |
||||
authHeader == "Bearer \(apiKey)" else { |
||||
return Response(status: .unauthorized) |
||||
} |
||||
|
||||
let hostName = Host.current().localizedName ?? "Music Server" |
||||
let authResponse = AuthResponse( |
||||
hostName: hostName, |
||||
protocolVersion: StreamingConstants.protocolVersion, |
||||
capabilities: ["file-streaming"] |
||||
) |
||||
let data = try JSONEncoder().encode(authResponse) |
||||
return Response( |
||||
status: .ok, |
||||
headers: [.contentType: "application/json"], |
||||
body: .init(byteBuffer: ByteBuffer(bytes: data)) |
||||
) |
||||
} |
||||
|
||||
// GET /db |
||||
router.get("db") { [db] request, _ -> Response in |
||||
// Validate auth |
||||
guard let authHeader = request.headers[.authorization], |
||||
authHeader == "Bearer \(apiKey)" else { |
||||
return Response(status: .unauthorized) |
||||
} |
||||
|
||||
let tempURL = FileManager.default.temporaryDirectory |
||||
.appendingPathComponent(UUID().uuidString + ".sqlite") |
||||
defer { try? FileManager.default.removeItem(at: tempURL) } |
||||
|
||||
try db.backup(to: tempURL.path) |
||||
let data = try Data(contentsOf: tempURL) |
||||
|
||||
return Response( |
||||
status: .ok, |
||||
headers: [.contentType: "application/octet-stream"], |
||||
body: .init(byteBuffer: ByteBuffer(bytes: data)) |
||||
) |
||||
} |
||||
|
||||
// GET /file?id=TRACKID&token=APIKEY — direct file streaming (progressive download) |
||||
// |
||||
// Supports HTTP byte-range requests (RFC 7233). AVPlayer (plain AVURLAsset, |
||||
// no custom resource loader) only treats a progressive HTTP stream as |
||||
// *seekable* when the server answers `Range` requests with `206 Partial |
||||
// Content` + `Content-Range` + `Accept-Ranges: bytes`. We therefore always |
||||
// advertise `Accept-Ranges: bytes` and serve a single requested slice when a |
||||
// valid `Range: bytes=START-END` header is present, reading only those bytes |
||||
// off disk via a FileHandle (never loading the whole file for a partial read). |
||||
router.get("file") { [db] request, _ -> Response in |
||||
let hasBearer = request.headers[.authorization] == "Bearer \(apiKey)" |
||||
let hasToken = request.uri.queryParameters.get("token") == apiKey |
||||
guard hasBearer || hasToken else { |
||||
return Response(status: .unauthorized) |
||||
} |
||||
|
||||
guard let idString = request.uri.queryParameters.get("id"), |
||||
let trackId = Int64(idString) else { |
||||
throw HTTPError(.badRequest, message: "Missing or invalid 'id' parameter") |
||||
} |
||||
|
||||
let tracks = try db.fetchTracksByIds([trackId]) |
||||
guard let track = tracks.first else { |
||||
throw HTTPError(.notFound, message: "Track \(trackId) not found") |
||||
} |
||||
|
||||
let fileURL = resolveStoredFileURL(track.fileURL) |
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { |
||||
throw HTTPError(.notFound, message: "File not found on disk") |
||||
} |
||||
|
||||
let contentType = Self.audioContentType(for: fileURL.pathExtension) |
||||
|
||||
// Total size without loading the file into memory. |
||||
let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) |
||||
let fileSize = (attrs[.size] as? NSNumber)?.int64Value ?? 0 |
||||
|
||||
// Serves the entire file with 200 OK + Accept-Ranges advertisement. |
||||
// Used when there's no Range header, or when the header is malformed |
||||
// or asks for multiple ranges (graceful fallback — never crash). |
||||
func fullBodyResponse() throws -> Response { |
||||
let data = try Data(contentsOf: fileURL) |
||||
return Response( |
||||
status: .ok, |
||||
headers: [ |
||||
.contentType: contentType, |
||||
.contentLength: String(data.count), |
||||
.acceptRanges: "bytes", |
||||
], |
||||
body: .init(byteBuffer: ByteBuffer(bytes: data)) |
||||
) |
||||
} |
||||
|
||||
// No Range header → full body, but still advertise range support. |
||||
guard let rangeHeader = request.headers[.range] else { |
||||
return try fullBodyResponse() |
||||
} |
||||
|
||||
// Multiple ranges (comma-separated) are unsupported here → fall back. |
||||
guard !rangeHeader.contains(",") else { |
||||
return try fullBodyResponse() |
||||
} |
||||
|
||||
switch Self.parseByteRange(rangeHeader, fileSize: fileSize) { |
||||
case .full: |
||||
// Malformed/unparseable Range → graceful 200 full-body fallback. |
||||
return try fullBodyResponse() |
||||
|
||||
case .unsatisfiable: |
||||
return Response( |
||||
status: .rangeNotSatisfiable, |
||||
headers: [.contentRange: "bytes */\(fileSize)"] |
||||
) |
||||
|
||||
case let .range(start, end): |
||||
// Read ONLY the requested slice off disk. |
||||
let handle = try FileHandle(forReadingFrom: fileURL) |
||||
defer { try? handle.close() } |
||||
try handle.seek(toOffset: UInt64(start)) |
||||
let length = Int(end - start + 1) |
||||
let slice = try handle.read(upToCount: length) ?? Data() |
||||
|
||||
return Response( |
||||
status: .partialContent, |
||||
headers: [ |
||||
.contentType: contentType, |
||||
.contentLength: String(slice.count), |
||||
.acceptRanges: "bytes", |
||||
.contentRange: "bytes \(start)-\(end)/\(fileSize)", |
||||
], |
||||
body: .init(byteBuffer: ByteBuffer(bytes: slice)) |
||||
) |
||||
} |
||||
} |
||||
|
||||
// GET /tracks/:trackId/stream.m3u8 |
||||
router.get("tracks/:trackId/stream.m3u8") { [db, segmenterCache] request, context -> Response in |
||||
// Validate auth |
||||
guard let authHeader = request.headers[.authorization], |
||||
authHeader == "Bearer \(apiKey)" else { |
||||
return Response(status: .unauthorized) |
||||
} |
||||
|
||||
let trackId = try context.parameters.require("trackId", as: Int64.self) |
||||
|
||||
let segmenter = try await segmenterCache.segmenter(for: trackId, db: db) |
||||
let manifest = HLSManifestGenerator.manifest( |
||||
trackId: trackId, |
||||
duration: segmenter.duration, |
||||
segmentDuration: StreamingConstants.segmentDuration, |
||||
token: apiKey |
||||
) |
||||
|
||||
return Response( |
||||
status: .ok, |
||||
headers: [.contentType: "application/vnd.apple.mpegurl"], |
||||
body: .init(byteBuffer: ByteBuffer(string: manifest)) |
||||
) |
||||
} |
||||
|
||||
// GET /tracks/:trackId/segments/:index |
||||
router.get("tracks/:trackId/segments/:index") { [db, segmenterCache] request, context -> Response in |
||||
let hasBearer = request.headers[.authorization] == "Bearer \(apiKey)" |
||||
let hasToken = request.uri.queryParameters.get("token") == apiKey |
||||
guard hasBearer || hasToken else { |
||||
return Response(status: .unauthorized) |
||||
} |
||||
|
||||
let trackId = try context.parameters.require("trackId", as: Int64.self) |
||||
// The index parameter may include ".mp3" suffix from the URL; strip it |
||||
guard var indexString = context.parameters.get("index") else { |
||||
throw HTTPError(.badRequest) |
||||
} |
||||
if indexString.hasSuffix(".mp3") { |
||||
indexString = String(indexString.dropLast(4)) |
||||
} |
||||
guard let index = Int(indexString) else { |
||||
throw HTTPError(.badRequest, message: "Invalid segment index") |
||||
} |
||||
|
||||
let segmenter = try await segmenterCache.segmenter(for: trackId, db: db) |
||||
guard let data = try await segmenter.segment( |
||||
at: index, |
||||
segmentDuration: StreamingConstants.segmentDuration |
||||
) else { |
||||
throw HTTPError(.notFound) |
||||
} |
||||
|
||||
return Response( |
||||
status: .ok, |
||||
headers: [.contentType: "audio/mpeg"], |
||||
body: .init(byteBuffer: ByteBuffer(bytes: data)) |
||||
) |
||||
} |
||||
|
||||
let app = Application( |
||||
router: router, |
||||
configuration: .init(address: .hostname("127.0.0.1", port: port)), |
||||
onServerRunning: { @Sendable [weak self] channel in |
||||
let boundPort = channel.localAddress?.port ?? port |
||||
await MainActor.run { |
||||
self?.actualPort = boundPort |
||||
self?.isRunning = true |
||||
self?.portContinuation?.resume(returning: boundPort) |
||||
self?.portContinuation = nil |
||||
} |
||||
} |
||||
) |
||||
|
||||
// Start in a detached task so start() can return after the port is known |
||||
serverTask = Task.detached { |
||||
try await app.run() |
||||
} |
||||
|
||||
// Wait for the port to be assigned (important for port: 0 in tests) |
||||
let assignedPort = await withCheckedContinuation { (continuation: CheckedContinuation<Int, Never>) in |
||||
// If the port was already set by onServerRunning (unlikely but possible), |
||||
// resume immediately |
||||
if let port = self.actualPort { |
||||
continuation.resume(returning: port) |
||||
} else { |
||||
self.portContinuation = continuation |
||||
} |
||||
} |
||||
|
||||
self.actualPort = assignedPort |
||||
self.isRunning = true |
||||
} |
||||
|
||||
func stop() { |
||||
serverTask?.cancel() |
||||
serverTask = nil |
||||
isRunning = false |
||||
actualPort = nil |
||||
segmenterCache.clear() |
||||
} |
||||
|
||||
/// Outcome of parsing a single-range `Range` header against a known file size. |
||||
private enum ByteRangeResult { |
||||
/// Serve the whole file (no range / unparseable header). |
||||
case full |
||||
/// Serve `start...end` inclusive (both already clamped to `0..<fileSize`). |
||||
case range(start: Int64, end: Int64) |
||||
/// Range is syntactically a range but lies outside the file → 416. |
||||
case unsatisfiable |
||||
} |
||||
|
||||
/// Parses a single-range HTTP `Range` header of the form `bytes=START-END`, |
||||
/// resolving the three accepted shapes against `fileSize`: |
||||
/// - `bytes=10-19` → start=10, end=19 (inclusive) |
||||
/// - `bytes=10-` → start=10, end=fileSize-1 (open-ended) |
||||
/// - `bytes=-500` → last 500 bytes: start=fileSize-500, end=fileSize-1 (suffix) |
||||
/// `end` is clamped to `fileSize-1`. Any malformed/unparseable header returns |
||||
/// `.full` so the caller can fall back to a 200 full-body response. |
||||
nonisolated private static func parseByteRange(_ header: String, fileSize: Int64) -> ByteRangeResult { |
||||
let trimmed = header.trimmingCharacters(in: .whitespaces) |
||||
guard trimmed.hasPrefix("bytes=") else { return .full } |
||||
let spec = trimmed.dropFirst("bytes=".count) |
||||
guard let dashIndex = spec.firstIndex(of: "-") else { return .full } |
||||
|
||||
let startPart = spec[spec.startIndex..<dashIndex].trimmingCharacters(in: .whitespaces) |
||||
let endPart = spec[spec.index(after: dashIndex)...].trimmingCharacters(in: .whitespaces) |
||||
|
||||
if startPart.isEmpty { |
||||
// Suffix form: bytes=-N → last N bytes. |
||||
guard let suffixLength = Int64(endPart), suffixLength > 0 else { return .full } |
||||
guard fileSize > 0 else { return .unsatisfiable } |
||||
let start = max(0, fileSize - suffixLength) |
||||
return .range(start: start, end: fileSize - 1) |
||||
} |
||||
|
||||
guard let start = Int64(startPart) else { return .full } |
||||
// Out-of-bounds start is unsatisfiable per RFC 7233. |
||||
guard start < fileSize else { return .unsatisfiable } |
||||
|
||||
let end: Int64 |
||||
if endPart.isEmpty { |
||||
// Open-ended: bytes=N- → through EOF. |
||||
end = fileSize - 1 |
||||
} else { |
||||
guard let parsedEnd = Int64(endPart) else { return .full } |
||||
end = min(parsedEnd, fileSize - 1) |
||||
} |
||||
|
||||
guard start <= end else { return .unsatisfiable } |
||||
return .range(start: start, end: end) |
||||
} |
||||
|
||||
nonisolated private static func audioContentType(for ext: String) -> String { |
||||
switch ext.lowercased() { |
||||
case "mp3": return "audio/mpeg" |
||||
case "m4a", "aac": return "audio/mp4" |
||||
case "flac": return "audio/flac" |
||||
case "wav": return "audio/wav" |
||||
case "ogg": return "audio/ogg" |
||||
case "aiff", "aif": return "audio/aiff" |
||||
default: return "application/octet-stream" |
||||
} |
||||
} |
||||
} |
||||
|
||||
// MARK: - SegmenterCache |
||||
|
||||
/// Thread-safe cache of `HLSSegmenter` instances keyed by track ID. |
||||
private final class SegmenterCache: Sendable { |
||||
private let storage = OSAllocatedUnfairLock(initialState: [Int64: HLSSegmenter]()) |
||||
|
||||
func segmenter(for trackId: Int64, db: DatabaseService) throws -> HLSSegmenter { |
||||
// Check cache first |
||||
if let cached = storage.withLock({ $0[trackId] }) { |
||||
return cached |
||||
} |
||||
// Resolve the track's file URL from the database |
||||
let tracks = try db.fetchTracksByIds([trackId]) |
||||
guard let track = tracks.first else { |
||||
throw HTTPError(.notFound, message: "Track \(trackId) not found") |
||||
} |
||||
let fileURL = resolveStoredFileURL(track.fileURL) |
||||
let segmenter = try HLSSegmenter(fileURL: fileURL) |
||||
storage.withLock { $0[trackId] = segmenter } |
||||
return segmenter |
||||
} |
||||
|
||||
func clear() { |
||||
storage.withLock { $0.removeAll() } |
||||
} |
||||
} |
||||
|
||||
/// Reconstructs a filesystem `URL` from the `fileURL` string stored in the |
||||
/// database. The scanner persists `url.absoluteString` (e.g. "file:///…"), so it |
||||
/// must be parsed as a URL; `URL(fileURLWithPath:)` would treat the whole |
||||
/// "file://…" string as a relative path (prepending the CWD) and never resolve |
||||
/// the file. Bare/legacy path strings fall back to `URL(fileURLWithPath:)`. |
||||
fileprivate func resolveStoredFileURL(_ stored: String) -> URL { |
||||
if let url = URL(string: stored), url.isFileURL { |
||||
return url |
||||
} |
||||
return URL(fileURLWithPath: stored) |
||||
} |
||||
@ -0,0 +1,125 @@ |
||||
import Foundation |
||||
import os |
||||
import Observation |
||||
|
||||
@MainActor |
||||
@Observable |
||||
final class TunnelManager { |
||||
enum TunnelMode: String, Codable { |
||||
case quick |
||||
case named |
||||
} |
||||
|
||||
enum TunnelState: Equatable { |
||||
case stopped |
||||
case starting |
||||
case running(url: String) |
||||
case failed(message: String) |
||||
} |
||||
|
||||
var state: TunnelState = .stopped |
||||
var tunnelURL: String? { |
||||
if case .running(let url) = state { return url } |
||||
return nil |
||||
} |
||||
|
||||
private var process: Process? |
||||
private var outputPipe: Pipe? |
||||
private let logger = Logger(subsystem: "com.music.streaming", category: "tunnel") |
||||
|
||||
static func isCloudflaredInstalled() -> Bool { |
||||
cloudflaredPath != nil |
||||
} |
||||
|
||||
static var cloudflaredPath: String? { |
||||
if FileManager.default.fileExists(atPath: "/opt/homebrew/bin/cloudflared") { |
||||
return "/opt/homebrew/bin/cloudflared" |
||||
} |
||||
if FileManager.default.fileExists(atPath: "/usr/local/bin/cloudflared") { |
||||
return "/usr/local/bin/cloudflared" |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func startQuickTunnel(localPort: Int) throws { |
||||
try startTunnel( |
||||
arguments: ["tunnel", "--url", "http://localhost:\(localPort)"], |
||||
logMessage: "Started cloudflared quick tunnel on port \(localPort)", |
||||
preserveFailedState: true |
||||
) |
||||
} |
||||
|
||||
func startNamedTunnel(tunnelName: String, localPort: Int) throws { |
||||
try startTunnel( |
||||
arguments: ["tunnel", "run", "--url", "http://localhost:\(localPort)", tunnelName], |
||||
logMessage: "Started cloudflared named tunnel '\(tunnelName)' on port \(localPort)", |
||||
preserveFailedState: false |
||||
) |
||||
} |
||||
|
||||
func stop() { |
||||
process?.terminate() |
||||
process = nil |
||||
outputPipe?.fileHandleForReading.readabilityHandler = nil |
||||
outputPipe = nil |
||||
state = .stopped |
||||
logger.info("Stopped cloudflared tunnel") |
||||
} |
||||
|
||||
// MARK: - Private |
||||
|
||||
private func startTunnel(arguments: [String], logMessage: String, preserveFailedState: Bool) throws { |
||||
guard let path = Self.cloudflaredPath else { |
||||
state = .failed(message: "cloudflared not found. Install with: brew install cloudflared") |
||||
return |
||||
} |
||||
|
||||
state = .starting |
||||
|
||||
let process = Process() |
||||
process.executableURL = URL(fileURLWithPath: path) |
||||
process.arguments = arguments |
||||
|
||||
let pipe = Pipe() |
||||
process.standardError = pipe |
||||
|
||||
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in |
||||
let data = handle.availableData |
||||
guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return } |
||||
Task { @MainActor [weak self] in |
||||
self?.parseOutput(line) |
||||
} |
||||
} |
||||
|
||||
process.terminationHandler = { [weak self] proc in |
||||
Task { @MainActor [weak self] in |
||||
guard let self else { return } |
||||
if case .running = self.state { |
||||
// Was running normally — just mark stopped |
||||
self.state = .stopped |
||||
} else if preserveFailedState { |
||||
// Never reached .running — report the exit code as a failure |
||||
self.state = .failed(message: "cloudflared exited with code \(proc.terminationStatus)") |
||||
} else { |
||||
self.state = .stopped |
||||
} |
||||
} |
||||
} |
||||
|
||||
try process.run() |
||||
self.process = process |
||||
self.outputPipe = pipe |
||||
logger.info("\(logMessage)") |
||||
} |
||||
|
||||
private func parseOutput(_ output: String) { |
||||
let lines = output.components(separatedBy: .newlines) |
||||
for line in lines { |
||||
if let range = line.range(of: "https://[^ ]+", options: .regularExpression) { |
||||
let url = String(line[range]) |
||||
state = .running(url: url) |
||||
logger.info("Tunnel URL: \(url)") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,67 @@ |
||||
import SwiftUI |
||||
|
||||
// The right-docked "Up Next" panel. The manual "Queue" section is reorderable and |
||||
// removable; the "Next from" section is the read-only upcoming context (double-click |
||||
// a row to jump to it). |
||||
struct QueueView: View { |
||||
var player: PlayerViewModel |
||||
|
||||
var body: some View { |
||||
List { |
||||
if player.manualQueue.isEmpty && player.upcomingContext.isEmpty { |
||||
Text("Queue is empty.\nRight-click a track → Add to Queue.") |
||||
.font(.system(size: 12)) |
||||
.foregroundStyle(.secondary) |
||||
.multilineTextAlignment(.center) |
||||
.frame(maxWidth: .infinity, alignment: .center) |
||||
.padding(.vertical, 24) |
||||
.listRowSeparator(.hidden) |
||||
} |
||||
|
||||
if !player.manualQueue.isEmpty { |
||||
Section("Queue") { |
||||
ForEach(player.manualQueue) { entry in |
||||
HStack(spacing: 8) { |
||||
trackRow(entry.track) |
||||
Spacer() |
||||
Button { |
||||
if let idx = player.manualQueue.firstIndex(where: { $0.id == entry.id }) { |
||||
player.removeFromQueue(at: IndexSet(integer: idx)) |
||||
} |
||||
} label: { |
||||
Image(systemName: "xmark.circle.fill") |
||||
.foregroundStyle(.tertiary) |
||||
} |
||||
.buttonStyle(.plain) |
||||
} |
||||
} |
||||
.onMove(perform: player.moveInQueue) |
||||
} |
||||
} |
||||
|
||||
if !player.upcomingContext.isEmpty { |
||||
Section("Next from: \(player.contextName ?? "Library")") { |
||||
ForEach(Array(player.upcomingContext.enumerated()), id: \.offset) { _, track in |
||||
trackRow(track) |
||||
.contentShape(Rectangle()) |
||||
.onTapGesture(count: 2) { player.play(track) } |
||||
} |
||||
} |
||||
} |
||||
} |
||||
.listStyle(.inset) |
||||
.frame(width: 280) |
||||
} |
||||
|
||||
private func trackRow(_ track: Track) -> some View { |
||||
VStack(alignment: .leading, spacing: 2) { |
||||
Text(track.title) |
||||
.font(.system(size: 12, weight: .medium)) |
||||
.lineLimit(1) |
||||
Text(track.artist) |
||||
.font(.system(size: 10)) |
||||
.foregroundStyle(.secondary) |
||||
.lineLimit(1) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,149 @@ |
||||
import SwiftUI |
||||
|
||||
struct SmartPlaylistBuilderSheet: View { |
||||
var editingPlaylist: SmartPlaylist? |
||||
var onSave: (String, [SmartPlaylistCondition]) -> Void |
||||
var onCancel: () -> Void |
||||
|
||||
@State private var name: String |
||||
@State private var conditions: [SmartPlaylistCondition] |
||||
|
||||
init( |
||||
editingPlaylist: SmartPlaylist? = nil, |
||||
onSave: @escaping (String, [SmartPlaylistCondition]) -> Void, |
||||
onCancel: @escaping () -> Void |
||||
) { |
||||
self.editingPlaylist = editingPlaylist |
||||
self.onSave = onSave |
||||
self.onCancel = onCancel |
||||
let defaultCondition = SmartPlaylistCondition(field: .artist, op: .equals, value: .string("")) |
||||
_name = State(initialValue: editingPlaylist?.name ?? "") |
||||
_conditions = State(initialValue: editingPlaylist?.conditions ?? [defaultCondition]) |
||||
} |
||||
|
||||
private var canSave: Bool { |
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && |
||||
conditions.allSatisfy { !$0.isEmpty } |
||||
} |
||||
|
||||
var body: some View { |
||||
VStack(alignment: .leading, spacing: 16) { |
||||
Text(editingPlaylist == nil ? "New Smart Playlist" : "Edit Smart Playlist") |
||||
.font(.headline) |
||||
|
||||
VStack(alignment: .leading, spacing: 4) { |
||||
Text("Name") |
||||
.font(.caption) |
||||
.foregroundStyle(.secondary) |
||||
TextField("Playlist name", text: $name) |
||||
.textFieldStyle(.roundedBorder) |
||||
} |
||||
|
||||
VStack(alignment: .leading, spacing: 6) { |
||||
Text("Conditions (all must match)") |
||||
.font(.caption) |
||||
.foregroundStyle(.secondary) |
||||
|
||||
ForEach(conditions.indices, id: \.self) { index in |
||||
ConditionRowView( |
||||
condition: $conditions[index], |
||||
canRemove: conditions.count > 1, |
||||
onRemove: { conditions.remove(at: index) } |
||||
) |
||||
} |
||||
|
||||
Button("+ Add Condition") { |
||||
conditions.append(SmartPlaylistCondition(field: .artist, op: .equals, value: .string(""))) |
||||
} |
||||
.buttonStyle(.plain) |
||||
.foregroundStyle(Color.accentColor) |
||||
.font(.system(size: 12)) |
||||
} |
||||
|
||||
Divider() |
||||
|
||||
HStack { |
||||
Spacer() |
||||
Button("Cancel", action: onCancel) |
||||
Button("Save") { |
||||
onSave(name.trimmingCharacters(in: .whitespaces), conditions) |
||||
} |
||||
.disabled(!canSave) |
||||
.keyboardShortcut(.defaultAction) |
||||
} |
||||
} |
||||
.padding(20) |
||||
.frame(width: 540) |
||||
} |
||||
} |
||||
|
||||
private struct ConditionRowView: View { |
||||
@Binding var condition: SmartPlaylistCondition |
||||
var canRemove: Bool |
||||
var onRemove: () -> Void |
||||
|
||||
var body: some View { |
||||
HStack(spacing: 8) { |
||||
Picker("", selection: $condition.field) { |
||||
ForEach(TrackField.allCases) { field in |
||||
Text(field.displayName).tag(field) |
||||
} |
||||
} |
||||
.labelsHidden() |
||||
.frame(maxWidth: 130) |
||||
.onChange(of: condition.field) { _, newField in |
||||
condition.op = newField.validOperators[0] |
||||
condition.value = newField.defaultValue |
||||
} |
||||
|
||||
Picker("", selection: $condition.op) { |
||||
ForEach(condition.field.validOperators) { op in |
||||
Text(op.displayName).tag(op) |
||||
} |
||||
} |
||||
.labelsHidden() |
||||
.frame(maxWidth: 130) |
||||
|
||||
valueField |
||||
|
||||
Button(action: onRemove) { |
||||
Image(systemName: "minus.circle.fill") |
||||
.foregroundStyle(Color.secondary.opacity(canRemove ? 1.0 : 0.3)) |
||||
} |
||||
.buttonStyle(.plain) |
||||
.disabled(!canRemove) |
||||
} |
||||
} |
||||
|
||||
@ViewBuilder |
||||
private var valueField: some View { |
||||
switch condition.field.fieldType { |
||||
case .string: |
||||
TextField("Value", text: Binding( |
||||
get: { if case .string(let s) = condition.value { return s } else { return "" } }, |
||||
set: { condition.value = .string($0) } |
||||
)) |
||||
.textFieldStyle(.roundedBorder) |
||||
case .int: |
||||
TextField("Value", text: Binding( |
||||
get: { if case .int(let i) = condition.value { return String(i) } else { return "0" } }, |
||||
set: { condition.value = .int(Int($0) ?? 0) } |
||||
)) |
||||
.textFieldStyle(.roundedBorder) |
||||
.frame(maxWidth: 100) |
||||
case .double: |
||||
TextField("Value", text: Binding( |
||||
get: { if case .double(let d) = condition.value { return String(d) } else { return "0" } }, |
||||
set: { condition.value = .double(Double($0) ?? 0) } |
||||
)) |
||||
.textFieldStyle(.roundedBorder) |
||||
.frame(maxWidth: 100) |
||||
case .date: |
||||
DatePicker("", selection: Binding( |
||||
get: { if case .date(let d) = condition.value { return d } else { return Date() } }, |
||||
set: { condition.value = .date($0) } |
||||
), displayedComponents: .date) |
||||
.labelsHidden() |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
import SwiftUI |
||||
|
||||
// Attaches a context menu matching the track table's right-click menu. |
||||
// No-ops silently when track or config is nil so callers can pass optionals freely. |
||||
// The menu is rendered from `config.entries(...)` — the SAME source of truth the |
||||
// AppKit table menu uses — so the two menus can never drift. |
||||
struct TrackContextMenuModifier: ViewModifier { |
||||
let track: Track? |
||||
let config: TrackContextMenuConfig? |
||||
|
||||
func body(content: Content) -> some View { |
||||
content.contextMenu { |
||||
if let track, let config { |
||||
// No multi-selection in the control bar, so the target set is just |
||||
// the now-playing track. |
||||
TrackMenuEntryList(entries: config.entries(primary: track, selection: [track])) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Recursively renders `[TrackMenuEntry]` as SwiftUI menu content. |
||||
struct TrackMenuEntryList: View { |
||||
let entries: [TrackMenuEntry] |
||||
|
||||
var body: some View { |
||||
ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in |
||||
switch entry { |
||||
case .separator: |
||||
Divider() |
||||
case .button(let title, let action): |
||||
Button(title, action: action) |
||||
case .submenu(let title, let items): |
||||
Menu(title) { TrackMenuEntryList(entries: items) } |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
extension View { |
||||
func trackContextMenu(track: Track?, config: TrackContextMenuConfig?) -> some View { |
||||
modifier(TrackContextMenuModifier(track: track, config: config)) |
||||
} |
||||
} |
||||
@ -0,0 +1,156 @@ |
||||
import SwiftUI |
||||
|
||||
// Get Info dialog. Edits one or many tracks. For multi-edit, fields that differ |
||||
// across tracks show a "Mixed" placeholder and only fields the user touches are |
||||
// applied. onSave hands back the edited values + the set of edited fields. |
||||
struct TrackInfoSheet: View { |
||||
let tracks: [Track] |
||||
var onSave: (EditableTrackFields, Set<EditableTrackField>) -> Void |
||||
var onCancel: () -> Void |
||||
|
||||
@State private var fields: EditableTrackFields |
||||
@State private var mixed: Set<EditableTrackField> |
||||
@State private var edited: Set<EditableTrackField> = [] |
||||
@State private var tab = 0 |
||||
|
||||
init(tracks: [Track], |
||||
onSave: @escaping (EditableTrackFields, Set<EditableTrackField>) -> Void, |
||||
onCancel: @escaping () -> Void) { |
||||
self.tracks = tracks |
||||
self.onSave = onSave |
||||
self.onCancel = onCancel |
||||
let (values, mixed) = EditableTrackFields.shared(across: tracks) |
||||
_fields = State(initialValue: values) |
||||
_mixed = State(initialValue: mixed) |
||||
} |
||||
|
||||
private var isMulti: Bool { tracks.count > 1 } |
||||
private var hasUnsupported: Bool { |
||||
tracks.contains { t in |
||||
["flac", "wav", "aiff"].contains((URL(string: t.fileURL)?.pathExtension ?? "").lowercased()) |
||||
} |
||||
} |
||||
|
||||
var body: some View { |
||||
VStack(alignment: .leading, spacing: 16) { |
||||
Text(isMulti ? "Get Info — \(tracks.count) tracks" : "Get Info") |
||||
.font(.headline) |
||||
|
||||
if hasUnsupported { |
||||
Text("Edits save to your library only — tag writing isn't supported for some selected formats yet.") |
||||
.font(.caption).foregroundStyle(.secondary) |
||||
} |
||||
|
||||
Picker("", selection: $tab) { |
||||
Text("Details").tag(0) |
||||
if !isMulti { Text("File").tag(1) } |
||||
} |
||||
.pickerStyle(.segmented) |
||||
.labelsHidden() |
||||
|
||||
if tab == 0 { detailsTab } else { fileTab } |
||||
|
||||
Divider() |
||||
HStack { |
||||
Spacer() |
||||
Button("Cancel", action: onCancel) |
||||
Button("Save") { onSave(fields, edited) } |
||||
.keyboardShortcut(.defaultAction) |
||||
} |
||||
} |
||||
.padding(20) |
||||
.frame(width: 460) |
||||
} |
||||
|
||||
// Binding helper that marks a field edited whenever it changes. |
||||
private func text(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, String>) -> Binding<String> { |
||||
Binding( |
||||
get: { mixed.contains(field) && !edited.contains(field) ? "" : fields[keyPath: keyPath] }, |
||||
set: { fields[keyPath: keyPath] = $0; edited.insert(field) } |
||||
) |
||||
} |
||||
private func int(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, Int?>) -> Binding<String> { |
||||
Binding( |
||||
get: { mixed.contains(field) && !edited.contains(field) ? "" : (fields[keyPath: keyPath].map(String.init) ?? "") }, |
||||
set: { fields[keyPath: keyPath] = Int($0.filter(\.isNumber)); edited.insert(field) } |
||||
) |
||||
} |
||||
private func placeholder(_ field: EditableTrackField) -> String { |
||||
mixed.contains(field) && !edited.contains(field) ? "Mixed" : "" |
||||
} |
||||
|
||||
private var detailsTab: some View { |
||||
detailsTextFields |
||||
} |
||||
|
||||
@ViewBuilder private var detailsTextFields: some View { |
||||
VStack(alignment: .leading, spacing: 8) { |
||||
labeled("Title") { TextField(placeholder(.title), text: text(.title, \.title)) } |
||||
labeled("Artist") { TextField(placeholder(.artist), text: text(.artist, \.artist)) } |
||||
labeled("Album Artist") { TextField(placeholder(.albumArtist), text: text(.albumArtist, \.albumArtist)) } |
||||
labeled("Album") { TextField(placeholder(.album), text: text(.album, \.album)) } |
||||
labeled("Genre") { TextField(placeholder(.genre), text: text(.genre, \.genre)) } |
||||
labeled("Composer") { TextField(placeholder(.composer), text: text(.composer, \.composer)) } |
||||
detailsNumericRow |
||||
labeled("Rating") { |
||||
Stepper(value: Binding( |
||||
get: { fields.rating }, |
||||
set: { fields.rating = max(0, min(5, $0)); edited.insert(.rating) } |
||||
), in: 0...5) { Text(String(repeating: "★", count: fields.rating)) } |
||||
} |
||||
detailsDateRow |
||||
} |
||||
.textFieldStyle(.roundedBorder) |
||||
} |
||||
|
||||
// Date Added is library-managed (not a file tag); editing it is DB-only. |
||||
// For multi-select, the label shows "(Mixed)" until the user touches it. |
||||
@ViewBuilder private var detailsDateRow: some View { |
||||
let isMixed = mixed.contains(.dateAdded) && !edited.contains(.dateAdded) |
||||
labeled(isMixed ? "Date Added (Mixed)" : "Date Added") { |
||||
DatePicker("", selection: Binding( |
||||
get: { fields.dateAdded }, |
||||
set: { fields.dateAdded = $0; edited.insert(.dateAdded) } |
||||
), displayedComponents: [.date]) |
||||
.labelsHidden() |
||||
} |
||||
} |
||||
|
||||
@ViewBuilder private var detailsNumericRow: some View { |
||||
HStack(spacing: 12) { |
||||
labeled("Year") { TextField(placeholder(.year), text: int(.year, \.year)).frame(width: 70) } |
||||
labeled("Track") { TextField(placeholder(.trackNumber), text: int(.trackNumber, \.trackNumber)).frame(width: 50) } |
||||
labeled("Disc") { TextField(placeholder(.discNumber), text: int(.discNumber, \.discNumber)).frame(width: 50) } |
||||
labeled("BPM") { TextField(placeholder(.bpm), text: int(.bpm, \.bpm)).frame(width: 60) } |
||||
} |
||||
} |
||||
|
||||
@ViewBuilder private var fileTab: some View { |
||||
if let t = tracks.first { |
||||
VStack(alignment: .leading, spacing: 6) { |
||||
row("Kind", t.fileFormat.uppercased()) |
||||
row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "—") |
||||
row("Sample Rate", t.sampleRate.map { "\($0) Hz" } ?? "—") |
||||
row("Size", ByteCountFormatter.string(fromByteCount: t.fileSize, countStyle: .file)) |
||||
row("Duration", String(format: "%d:%02d", Int(t.duration) / 60, Int(t.duration) % 60)) |
||||
row("Plays", "\(t.playCount)") |
||||
row("Added", t.dateAdded.formatted(date: .abbreviated, time: .omitted)) |
||||
row("Where", URL(string: t.fileURL)?.path ?? t.fileURL) |
||||
} |
||||
.font(.system(size: 12)) |
||||
} |
||||
} |
||||
|
||||
private func labeled<C: View>(_ title: String, @ViewBuilder _ content: () -> C) -> some View { |
||||
VStack(alignment: .leading, spacing: 2) { |
||||
Text(title).font(.caption).foregroundStyle(.secondary) |
||||
content() |
||||
} |
||||
} |
||||
private func row(_ k: String, _ v: String) -> some View { |
||||
HStack(alignment: .top) { |
||||
Text(k).foregroundStyle(.secondary).frame(width: 90, alignment: .leading) |
||||
Text(v).textSelection(.enabled) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1 @@ |
||||
.build/ |
||||
@ -0,0 +1,221 @@ |
||||
{ |
||||
"pins" : [ |
||||
{ |
||||
"identity" : "async-http-client", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/swift-server/async-http-client.git", |
||||
"state" : { |
||||
"revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", |
||||
"version" : "1.33.1" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "hummingbird", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/hummingbird-project/hummingbird.git", |
||||
"state" : { |
||||
"revision" : "2f407402799c2217df69b01582f3a44856fef012", |
||||
"version" : "2.24.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-algorithms", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-algorithms.git", |
||||
"state" : { |
||||
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", |
||||
"version" : "1.2.1" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-asn1", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-asn1.git", |
||||
"state" : { |
||||
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", |
||||
"version" : "1.7.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-async-algorithms", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-async-algorithms.git", |
||||
"state" : { |
||||
"revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", |
||||
"version" : "1.1.4" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-atomics", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-atomics.git", |
||||
"state" : { |
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", |
||||
"version" : "1.3.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-certificates", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-certificates.git", |
||||
"state" : { |
||||
"revision" : "bde8ca32a096825dfce37467137c903418c1893d", |
||||
"version" : "1.19.1" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-collections", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-collections.git", |
||||
"state" : { |
||||
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", |
||||
"version" : "1.5.1" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-configuration", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-configuration.git", |
||||
"state" : { |
||||
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", |
||||
"version" : "1.2.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-crypto", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-crypto.git", |
||||
"state" : { |
||||
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", |
||||
"version" : "4.5.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-distributed-tracing", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-distributed-tracing.git", |
||||
"state" : { |
||||
"revision" : "dc4030184203ffafbb2ec614352487235d747fe0", |
||||
"version" : "1.4.1" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-http-structured-headers", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-http-structured-headers.git", |
||||
"state" : { |
||||
"revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", |
||||
"version" : "1.7.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-http-types", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-http-types.git", |
||||
"state" : { |
||||
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", |
||||
"version" : "1.5.1" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-log", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-log.git", |
||||
"state" : { |
||||
"revision" : "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63", |
||||
"version" : "1.13.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-metrics", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-metrics.git", |
||||
"state" : { |
||||
"revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", |
||||
"version" : "2.11.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-nio", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-nio.git", |
||||
"state" : { |
||||
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", |
||||
"version" : "2.100.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-nio-extras", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-nio-extras.git", |
||||
"state" : { |
||||
"revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506", |
||||
"version" : "1.34.1" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-nio-http2", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-nio-http2.git", |
||||
"state" : { |
||||
"revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", |
||||
"version" : "1.44.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-nio-ssl", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-nio-ssl.git", |
||||
"state" : { |
||||
"revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", |
||||
"version" : "2.37.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-nio-transport-services", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-nio-transport-services.git", |
||||
"state" : { |
||||
"revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", |
||||
"version" : "1.28.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-numerics", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-numerics.git", |
||||
"state" : { |
||||
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", |
||||
"version" : "1.1.1" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-service-context", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-service-context.git", |
||||
"state" : { |
||||
"revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", |
||||
"version" : "1.3.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-service-lifecycle", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/swift-server/swift-service-lifecycle.git", |
||||
"state" : { |
||||
"revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", |
||||
"version" : "2.11.0" |
||||
} |
||||
}, |
||||
{ |
||||
"identity" : "swift-system", |
||||
"kind" : "remoteSourceControl", |
||||
"location" : "https://github.com/apple/swift-system.git", |
||||
"state" : { |
||||
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", |
||||
"version" : "1.6.4" |
||||
} |
||||
} |
||||
], |
||||
"version" : 2 |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
// swift-tools-version: 5.9 |
||||
import PackageDescription |
||||
|
||||
let package = Package( |
||||
name: "MusicShared", |
||||
platforms: [ |
||||
.macOS(.v14), |
||||
.iOS(.v17), |
||||
], |
||||
products: [ |
||||
.library( |
||||
name: "MusicShared", |
||||
targets: ["MusicShared"] |
||||
), |
||||
], |
||||
dependencies: [ |
||||
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), |
||||
// Explicit transitive dependencies to work around Xcode SPM linker bug |
||||
// where product frameworks fail to link their own transitive deps in |
||||
// the build-for-testing action. |
||||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), |
||||
.package(url: "https://github.com/apple/swift-nio-transport-services.git", from: "1.20.0"), |
||||
.package(url: "https://github.com/apple/swift-service-context.git", from: "1.0.0"), |
||||
], |
||||
targets: [ |
||||
.target( |
||||
name: "MusicShared", |
||||
dependencies: [ |
||||
.product(name: "Hummingbird", package: "hummingbird"), |
||||
// Explicit transitive deps — see comment on package dependencies above. |
||||
.product(name: "NIOFoundationCompat", package: "swift-nio"), |
||||
.product(name: "NIOHTTP1", package: "swift-nio"), |
||||
.product(name: "NIOTransportServices", package: "swift-nio-transport-services"), |
||||
.product(name: "ServiceContextModule", package: "swift-service-context"), |
||||
], |
||||
path: "Sources/MusicShared" |
||||
), |
||||
.testTarget( |
||||
name: "MusicSharedTests", |
||||
dependencies: ["MusicShared"], |
||||
path: "Tests/MusicSharedTests" |
||||
), |
||||
] |
||||
) |
||||
@ -0,0 +1,27 @@ |
||||
import Foundation |
||||
|
||||
public struct AuthResponse: Codable, Equatable, Sendable { |
||||
public var hostName: String |
||||
public var protocolVersion: Int |
||||
public var capabilities: [String]? |
||||
|
||||
public init(hostName: String, protocolVersion: Int, capabilities: [String]? = nil) { |
||||
self.hostName = hostName |
||||
self.protocolVersion = protocolVersion |
||||
self.capabilities = capabilities |
||||
} |
||||
|
||||
public var supportsDirectFileStreaming: Bool { |
||||
capabilities?.contains("file-streaming") ?? false |
||||
} |
||||
} |
||||
|
||||
public struct DBMetadata: Codable, Equatable, Sendable { |
||||
public var checksum: String |
||||
public var trackCount: Int |
||||
|
||||
public init(checksum: String, trackCount: Int) { |
||||
self.checksum = checksum |
||||
self.trackCount = trackCount |
||||
} |
||||
} |
||||
@ -0,0 +1,19 @@ |
||||
import Foundation |
||||
|
||||
public enum AppRole: String, Codable, CaseIterable, Sendable { |
||||
case local |
||||
case remoteHost |
||||
case remoteClient |
||||
case streamHost |
||||
case streamClient |
||||
} |
||||
|
||||
extension AppRole { |
||||
public var isHost: Bool { self == .remoteHost || self == .streamHost } |
||||
public var isClient: Bool { self == .remoteClient || self == .streamClient } |
||||
public var isLocal: Bool { self == .local } |
||||
public var usesLocalAudio: Bool { self == .local || self == .remoteHost || self == .streamClient } |
||||
public var isReadOnlyLibrary: Bool { self == .remoteClient || self == .streamClient } |
||||
public var needsNetworkServer: Bool { self == .remoteHost || self == .streamHost } |
||||
public var isStreaming: Bool { self == .streamHost || self == .streamClient } |
||||
} |
||||
@ -0,0 +1,3 @@ |
||||
// Re-export Hummingbird so the app target can use it |
||||
// without adding a separate package dependency. |
||||
@_exported import Hummingbird |
||||
@ -0,0 +1,43 @@ |
||||
import Foundation |
||||
|
||||
public enum HLSManifestGenerator: Sendable { |
||||
public struct TimeRange: Equatable, Sendable { |
||||
public var start: Double |
||||
public var duration: Double |
||||
} |
||||
|
||||
public static func manifest(trackId: Int64, duration: Double, segmentDuration: Double, token: String? = nil) -> String { |
||||
let count = segmentCount(duration: duration, segmentDuration: segmentDuration) |
||||
let targetDuration = Int(segmentDuration.rounded(.up)) |
||||
|
||||
var lines: [String] = [ |
||||
"#EXTM3U", |
||||
"#EXT-X-VERSION:3", |
||||
"#EXT-X-TARGETDURATION:\(targetDuration)", |
||||
"#EXT-X-MEDIA-SEQUENCE:0", |
||||
] |
||||
|
||||
let tokenQuery = token.map { "?token=\($0)" } ?? "" |
||||
for i in 0..<count { |
||||
let range = segmentTimeRange(index: i, trackDuration: duration, segmentDuration: segmentDuration) |
||||
lines.append(String(format: "#EXTINF:%.3f,", range.duration)) |
||||
lines.append("segments/\(i).mp3\(tokenQuery)") |
||||
} |
||||
|
||||
lines.append("#EXT-X-ENDLIST") |
||||
lines.append("") |
||||
return lines.joined(separator: "\n") |
||||
} |
||||
|
||||
public static func segmentCount(duration: Double, segmentDuration: Double) -> Int { |
||||
guard duration > 0, segmentDuration > 0 else { return 0 } |
||||
return Int((duration / segmentDuration).rounded(.up)) |
||||
} |
||||
|
||||
public static func segmentTimeRange(index: Int, trackDuration: Double, segmentDuration: Double) -> TimeRange { |
||||
let start = Double(index) * segmentDuration |
||||
let remaining = trackDuration - start |
||||
let duration = min(segmentDuration, remaining) |
||||
return TimeRange(start: start, duration: duration) |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@ |
||||
import Foundation |
||||
|
||||
public enum StreamingConstants: Sendable { |
||||
public static let defaultPort: Int = 8420 |
||||
public static let segmentDuration: Double = 6.0 |
||||
public static let protocolVersion: Int = 2 |
||||
} |
||||
@ -0,0 +1,27 @@ |
||||
import Foundation |
||||
|
||||
public enum StreamingRoutes: Sendable { |
||||
public static let auth = "/auth" |
||||
public static let db = "/db" |
||||
public static let ws = "/ws" |
||||
|
||||
public static func trackFile(trackId: Int64) -> String { |
||||
"/file?id=\(trackId)" |
||||
} |
||||
|
||||
public static func trackManifest(trackId: Int64) -> String { |
||||
"/tracks/\(trackId)/stream.m3u8" |
||||
} |
||||
|
||||
public static func trackSegment(trackId: Int64, index: Int) -> String { |
||||
"/tracks/\(trackId)/segments/\(index).mp3" |
||||
} |
||||
|
||||
public static func trackManifestPattern() -> String { |
||||
"/tracks/:trackId/stream.m3u8" |
||||
} |
||||
|
||||
public static func trackSegmentPattern() -> String { |
||||
"/tracks/:trackId/segments/:index" |
||||
} |
||||
} |
||||
@ -0,0 +1,78 @@ |
||||
import Testing |
||||
@testable import MusicShared |
||||
|
||||
struct HLSManifestGeneratorTests { |
||||
// Generates a manifest for a 16-second track with 6s segments. |
||||
// Expects 3 segments: 6s, 6s, 4s (remainder). |
||||
@Test func generatesCorrectManifestForTypicalTrack() { |
||||
let manifest = HLSManifestGenerator.manifest( |
||||
trackId: 42, |
||||
duration: 16.0, |
||||
segmentDuration: 6.0 |
||||
) |
||||
|
||||
#expect(manifest.contains("#EXTM3U")) |
||||
#expect(manifest.contains("#EXT-X-VERSION:3")) |
||||
#expect(manifest.contains("#EXT-X-TARGETDURATION:6")) |
||||
#expect(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0")) |
||||
#expect(manifest.contains("#EXTINF:6.000,")) |
||||
#expect(manifest.contains("#EXTINF:4.000,")) |
||||
#expect(manifest.contains("segments/0.mp3")) |
||||
#expect(manifest.contains("segments/1.mp3")) |
||||
#expect(manifest.contains("segments/2.mp3")) |
||||
#expect(!manifest.contains("segments/3.mp3")) |
||||
#expect(manifest.contains("#EXT-X-ENDLIST")) |
||||
} |
||||
|
||||
// A track whose duration is an exact multiple of the segment duration. |
||||
// Expects no short final segment. |
||||
@Test func exactMultipleOfSegmentDuration() { |
||||
let manifest = HLSManifestGenerator.manifest( |
||||
trackId: 1, |
||||
duration: 12.0, |
||||
segmentDuration: 6.0 |
||||
) |
||||
|
||||
let segmentCount = manifest.components(separatedBy: "#EXTINF:6.000,").count - 1 |
||||
#expect(segmentCount == 2) |
||||
#expect(!manifest.contains("segments/2.mp3")) |
||||
} |
||||
|
||||
// A very short track (shorter than one segment). |
||||
// Expects a single segment with the track's full duration. |
||||
@Test func veryShortTrack() { |
||||
let manifest = HLSManifestGenerator.manifest( |
||||
trackId: 7, |
||||
duration: 2.5, |
||||
segmentDuration: 6.0 |
||||
) |
||||
|
||||
#expect(manifest.contains("#EXTINF:2.500,")) |
||||
#expect(manifest.contains("segments/0.mp3")) |
||||
#expect(!manifest.contains("segments/1.mp3")) |
||||
} |
||||
|
||||
// Segment count helper returns the correct number of segments. |
||||
@Test func segmentCountCalculation() { |
||||
#expect(HLSManifestGenerator.segmentCount(duration: 16.0, segmentDuration: 6.0) == 3) |
||||
#expect(HLSManifestGenerator.segmentCount(duration: 12.0, segmentDuration: 6.0) == 2) |
||||
#expect(HLSManifestGenerator.segmentCount(duration: 2.5, segmentDuration: 6.0) == 1) |
||||
#expect(HLSManifestGenerator.segmentCount(duration: 6.0, segmentDuration: 6.0) == 1) |
||||
} |
||||
|
||||
// Time range for a given segment index returns correct start and duration. |
||||
@Test func segmentTimeRange() { |
||||
// Track: 16s, segment: 6s → segments at 0-6, 6-12, 12-16 |
||||
let range0 = HLSManifestGenerator.segmentTimeRange( |
||||
index: 0, trackDuration: 16.0, segmentDuration: 6.0 |
||||
) |
||||
#expect(range0.start == 0.0) |
||||
#expect(range0.duration == 6.0) |
||||
|
||||
let range2 = HLSManifestGenerator.segmentTimeRange( |
||||
index: 2, trackDuration: 16.0, segmentDuration: 6.0 |
||||
) |
||||
#expect(range2.start == 12.0) |
||||
#expect(range2.duration == 4.0) |
||||
} |
||||
} |
||||
@ -1,6 +1,6 @@ |
||||
import Foundation |
||||
import Testing |
||||
@testable import Music |
||||
@testable import MusicShared |
||||
|
||||
struct RemoteProtocolTests { |
||||
private let encoder: JSONEncoder = { |
||||
@ -0,0 +1,42 @@ |
||||
import Testing |
||||
import Foundation |
||||
import GRDB |
||||
@testable import Music |
||||
|
||||
@MainActor |
||||
struct DBBackupFTS5Tests { |
||||
|
||||
// Pins down the root cause: does DatabaseService.backup() produce a copy whose |
||||
// FTS5 `tracks_ft` table is functional? GRDB's ValueObservation introspects the |
||||
// whole schema on start, and that introspection throws "no such table: tracks_ft" |
||||
// if the FTS5 shadow tables didn't survive the copy. |
||||
@Test |
||||
func backupCopyHasFunctionalFTS5Table() throws { |
||||
// 1. Build a source DB (DatabasePool/WAL, like the running app) with 3 tracks. |
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) |
||||
defer { try? FileManager.default.removeItem(at: tempDir) } |
||||
let srcPath = tempDir.appendingPathComponent("src.sqlite").path |
||||
let src = try DatabaseService(path: srcPath) |
||||
for i in 1...3 { |
||||
var t = Track.fixture(fileURL: "/s\(i).mp3", title: "Song \(i)") |
||||
try src.insert(&t) |
||||
} |
||||
|
||||
// 2. Copy the DB exactly as the host serves it today. |
||||
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path |
||||
try src.backup(to: copyPath) |
||||
|
||||
// 3. Open the copy and run the same schema-introspection query GRDB runs when |
||||
// a ValueObservation starts. If FTS5 didn't transfer, this throws. |
||||
let copy = try DatabaseService(path: copyPath) |
||||
try copy.dbPool.read { db in |
||||
_ = try Row.fetchAll( |
||||
db, |
||||
sql: "SELECT rootpage, sql FROM sqlite_master WHERE (type = 'table' OR type = 'view')" |
||||
) |
||||
// And an actual FTS5 query must work. |
||||
_ = try Row.fetchAll(db, sql: "SELECT * FROM tracks_ft LIMIT 1") |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,82 @@ |
||||
import Foundation |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
// Verifies the pure single/multi-track edit logic: extraction, change detection, |
||||
// shared-vs-mixed across many tracks, and applying only edited fields. |
||||
struct EditableTrackFieldsTests { |
||||
@Test func initCopiesEditableValues() { |
||||
// Step 1: build fields from a fixture track. |
||||
let t = Track.fixture(title: "A", artist: "B", album: "C", year: 2001, rating: 3) |
||||
let f = EditableTrackFields(from: t) |
||||
// Step 2: editable values match. |
||||
#expect(f.title == "A"); #expect(f.artist == "B") |
||||
#expect(f.album == "C"); #expect(f.year == 2001); #expect(f.rating == 3) |
||||
} |
||||
|
||||
@Test func changedFieldsDetectsOnlyDifferences() { |
||||
// Step 1: two field sets differing only in genre + bpm. |
||||
let a = EditableTrackFields(from: .fixture(genre: "Rock", bpm: 120)) |
||||
var b = a; b.genre = "Jazz"; b.bpm = 90 |
||||
// Step 2: change set is exactly {genre, bpm}. |
||||
#expect(a.changedFields(to: b) == [.genre, .bpm]) |
||||
} |
||||
|
||||
@Test func sharedMarksDifferingFieldsMixed() { |
||||
// Step 1: two tracks share artist but differ in genre. |
||||
let t1 = Track.fixture(artist: "Same", genre: "Rock") |
||||
let t2 = Track.fixture(artist: "Same", genre: "Pop") |
||||
// Step 2: shared() returns common artist and flags genre as mixed. |
||||
let (values, mixed) = EditableTrackFields.shared(across: [t1, t2]) |
||||
#expect(values.artist == "Same") |
||||
#expect(mixed.contains(.genre)) |
||||
#expect(!mixed.contains(.artist)) |
||||
} |
||||
|
||||
@Test func sharedAcrossThreeTracksAccumulatesMixed() { |
||||
// Step 1: three tracks all share the same album, but title differs on the |
||||
// third track and genre differs on the second — so both title and |
||||
// genre must end up "mixed", while album stays shared. |
||||
let t1 = Track.fixture(title: "Same", album: "One Album", genre: "Rock") |
||||
let t2 = Track.fixture(title: "Same", album: "One Album", genre: "Pop") |
||||
let t3 = Track.fixture(title: "Different", album: "One Album", genre: "Rock") |
||||
// Step 2: shared() over all three. |
||||
let (values, mixed) = EditableTrackFields.shared(across: [t1, t2, t3]) |
||||
// Step 3: album is shared (not mixed); title + genre are mixed. |
||||
#expect(values.album == "One Album") |
||||
#expect(!mixed.contains(.album)) |
||||
#expect(mixed.contains(.title)) |
||||
#expect(mixed.contains(.genre)) |
||||
} |
||||
|
||||
@Test func applyOnlyWritesEditedFields() { |
||||
// Step 1: a track and a fields object that changes album only. |
||||
let t = Track.fixture(album: "Old", genre: "Rock") |
||||
var f = EditableTrackFields(from: t); f.album = "New"; f.genre = "IGNORED" |
||||
// Step 2: applying with editing={.album} changes album, leaves genre. |
||||
let out = f.apply(editing: [.album], to: t) |
||||
#expect(out.album == "New") |
||||
#expect(out.genre == "Rock") |
||||
} |
||||
|
||||
@Test func applyEmptyEditSetReturnsUnchanged() { |
||||
let t = Track.fixture(title: "Keep") |
||||
let f = EditableTrackFields(from: t) |
||||
#expect(f.apply(editing: [], to: t) == t) |
||||
} |
||||
|
||||
@Test func dateAddedIsEditable() { |
||||
// Step 1: a track with a known dateAdded; init copies it. |
||||
let original = Date(timeIntervalSince1970: 1_000_000) |
||||
let t = Track.fixture(dateAdded: original) |
||||
var f = EditableTrackFields(from: t) |
||||
#expect(f.dateAdded == original) |
||||
// Step 2: changing dateAdded is detected as the only changed field. |
||||
let newDate = Date(timeIntervalSince1970: 2_000_000) |
||||
f.dateAdded = newDate |
||||
#expect(EditableTrackFields(from: t).changedFields(to: f) == [.dateAdded]) |
||||
// Step 3: applying with editing={.dateAdded} writes the new date onto the track. |
||||
let out = f.apply(editing: [.dateAdded], to: t) |
||||
#expect(out.dateAdded == newDate) |
||||
} |
||||
} |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,89 @@ |
||||
import Testing |
||||
import Foundation |
||||
@testable import Music |
||||
|
||||
@MainActor |
||||
struct HLSSegmenterTests { |
||||
// Creates a segmenter for a test MP3 file and verifies it reports the correct duration. |
||||
@Test func readsDurationFromFile() async throws { |
||||
let url = try TestFixtures.shortMP3URL() |
||||
let segmenter = try HLSSegmenter(fileURL: url) |
||||
|
||||
// The test fixture is ~3 seconds long |
||||
#expect(segmenter.duration > 2.0) |
||||
#expect(segmenter.duration < 5.0) |
||||
} |
||||
|
||||
// Extracts the first segment and verifies it returns non-empty data. |
||||
@Test func extractsFirstSegment() async throws { |
||||
let url = try TestFixtures.shortMP3URL() |
||||
let segmenter = try HLSSegmenter(fileURL: url) |
||||
|
||||
let data = try #require(await segmenter.segment(at: 0, segmentDuration: 6.0)) |
||||
#expect(!data.isEmpty) |
||||
} |
||||
|
||||
// Requesting a segment index beyond the track duration returns nil. |
||||
@Test func outOfRangeSegmentReturnsNil() async throws { |
||||
let url = try TestFixtures.shortMP3URL() |
||||
let segmenter = try HLSSegmenter(fileURL: url) |
||||
|
||||
let data = try await segmenter.segment(at: 999, segmentDuration: 6.0) |
||||
#expect(data == nil) |
||||
} |
||||
} |
||||
|
||||
enum TestFixtures { |
||||
// Returns the URL of a short test audio file (M4A/AAC). |
||||
// macOS cannot encode MP3, so we use AAC which AVAssetReader handles identically. |
||||
static func shortMP3URL() throws -> URL { |
||||
let tempDir = FileManager.default.temporaryDirectory |
||||
let url = tempDir.appendingPathComponent("test_fixture.m4a") |
||||
|
||||
if !FileManager.default.fileExists(atPath: url.path) { |
||||
let wavURL = tempDir.appendingPathComponent("test_fixture.wav") |
||||
let sampleRate = 44100 |
||||
let channels = 1 |
||||
let durationSamples = sampleRate * 3 |
||||
let bytesPerSample = 2 |
||||
let dataSize = durationSamples * channels * bytesPerSample |
||||
|
||||
var wavData = Data() |
||||
func appendString(_ s: String) { wavData.append(contentsOf: s.utf8) } |
||||
func appendUInt32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { wavData.append(contentsOf: $0) } } |
||||
func appendUInt16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { wavData.append(contentsOf: $0) } } |
||||
|
||||
appendString("RIFF") |
||||
appendUInt32(UInt32(36 + dataSize)) |
||||
appendString("WAVE") |
||||
appendString("fmt ") |
||||
appendUInt32(16) |
||||
appendUInt16(1) |
||||
appendUInt16(UInt16(channels)) |
||||
appendUInt32(UInt32(sampleRate)) |
||||
appendUInt32(UInt32(sampleRate * channels * bytesPerSample)) |
||||
appendUInt16(UInt16(channels * bytesPerSample)) |
||||
appendUInt16(UInt16(bytesPerSample * 8)) |
||||
appendString("data") |
||||
appendUInt32(UInt32(dataSize)) |
||||
wavData.append(Data(count: dataSize)) |
||||
|
||||
try wavData.write(to: wavURL) |
||||
|
||||
let process = Process() |
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") |
||||
process.arguments = [wavURL.path, url.path, "-f", "m4af", "-d", "aac"] |
||||
try process.run() |
||||
process.waitUntilExit() |
||||
|
||||
try? FileManager.default.removeItem(at: wavURL) |
||||
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { |
||||
throw NSError(domain: "TestFixtures", code: 1, |
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to create test audio fixture"]) |
||||
} |
||||
} |
||||
|
||||
return url |
||||
} |
||||
} |
||||
@ -0,0 +1,71 @@ |
||||
import Testing |
||||
import Foundation |
||||
import AVFoundation |
||||
@testable import Music |
||||
|
||||
// Reproduces the "CoreMediaErrorDomain error -16044 after streaming a few tracks" |
||||
// bug (plus the accompanying HALC_ProxyIOContext overload / out-of-order log spew). |
||||
// |
||||
// Root cause: setting `player = nil` does NOT release an AVPlayer's decode/render |
||||
// pipeline — it is the *association* of an AVPlayerItem with an AVPlayer that owns |
||||
// the pipeline, and ARC tears it down asynchronously. The teardown path paused and |
||||
// nilled the player but never called `replaceCurrentItem(with: nil)`, so each track |
||||
// switch leaked a still-associated pipeline. After a handful of tracks they exceed |
||||
// CoreMedia's small concurrent-pipeline limit and a new player can't acquire a |
||||
// decode session, failing with -16044. |
||||
// |
||||
// The invariant proven here: tearing down the player must dissociate its item so |
||||
// the pipeline is released immediately. |
||||
@MainActor |
||||
struct PlaybackPipelineTeardownTests { |
||||
|
||||
// Verifies the streaming provider releases the previous player's pipeline on teardown. |
||||
// Steps: |
||||
// 1. Create a StreamingPlaybackProvider (host/key unused — we drive AVPlayer |
||||
// directly with a local file URL to bypass the network pre-flight). |
||||
// 2. Start an AVPlayer on a real local audio fixture and capture a strong |
||||
// reference to it, then confirm the pipeline is established (currentItem set). |
||||
// 3. Tear down via stop() — the same cleanup path a queue advance runs. |
||||
// 4. The captured player must have been dissociated from its item (currentItem |
||||
// == nil). Before the fix it is still set, so pipelines accumulate. |
||||
@Test func streamingProviderReleasesPipelineOnTeardown() throws { |
||||
// 1. Provider with throwaway connection details. |
||||
let provider = StreamingPlaybackProvider(hostURL: "http://unused.invalid", apiKey: "unused") |
||||
|
||||
// 2. Start playback on a real local file and grab the AVPlayer it created. |
||||
let fixture = try TestFixtures.shortMP3URL() |
||||
provider.startAVPlayer(url: fixture) |
||||
let firstPlayer = try #require(provider.player) |
||||
#expect(firstPlayer.currentItem != nil) |
||||
|
||||
// 3. Tear down (queue advance / stop runs this path). |
||||
provider.stop() |
||||
|
||||
// 4. The pipeline must be released: the item is dissociated from the player. |
||||
#expect(firstPlayer.currentItem == nil) |
||||
} |
||||
|
||||
// Verifies the local-playback provider (AudioService) has the same fix. |
||||
// Steps: |
||||
// 1. Create an AudioService. |
||||
// 2. Play a real local audio fixture and capture the AVPlayer; confirm the |
||||
// pipeline is established (currentItem set). |
||||
// 3. Tear down via stop(). |
||||
// 4. The captured player must have been dissociated from its item. |
||||
@Test func audioServiceReleasesPipelineOnTeardown() throws { |
||||
// 1. Local playback provider. |
||||
let provider = AudioService() |
||||
|
||||
// 2. Play a real local file and grab the AVPlayer it created. |
||||
let fixture = try TestFixtures.shortMP3URL() |
||||
provider.play(url: fixture) |
||||
let firstPlayer = try #require(provider.player) |
||||
#expect(firstPlayer.currentItem != nil) |
||||
|
||||
// 3. Tear down. |
||||
provider.stop() |
||||
|
||||
// 4. The pipeline must be released: the item is dissociated from the player. |
||||
#expect(firstPlayer.currentItem == nil) |
||||
} |
||||
} |
||||
@ -0,0 +1,54 @@ |
||||
import Foundation |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
// Reproduces the playlist-bar duplication bug. |
||||
// |
||||
// Regular playlists and smart playlists live in two separate SQLite tables, each |
||||
// with its own `autoIncrementedPrimaryKey("id")`. The two id sequences are |
||||
// independent, so a regular playlist and a smart playlist routinely share the |
||||
// same `id` value (both start at 1, 2, 3, ...). PlaylistViewModel.allPlaylists |
||||
// merges the two kinds into one [any PlaylistRepresentable] collection, and |
||||
// PlaylistBarView's `ForEach(playlists, id: \.id)` keyed off the bare `id`. |
||||
// |
||||
// When two items share an id, SwiftUI collapses them into a single identity: |
||||
// it renders one row twice and ties both buttons to the same view, so selecting |
||||
// or updating one leaks to the other (the reported "shown twice / name changes |
||||
// on both buttons" symptom). The fix is a type-disambiguated `listIdentity` that |
||||
// stays unique across the merged collection. |
||||
struct PlaylistBarIdentityTests { |
||||
|
||||
// Step 1: build a regular playlist and a smart playlist that share id == 1, |
||||
// exactly as the two independent autoincrement tables would produce. |
||||
// Step 2: collect the identities the playlist bar uses to key its ForEach. |
||||
// Step 3: assert the two identities are distinct, so SwiftUI keeps two rows. |
||||
@Test func regularAndSmartPlaylistWithSameIdHaveDistinctListIdentity() { |
||||
let regular = Playlist.fixture(id: 1, name: "Rock") |
||||
let smart = SmartPlaylist.fixture(id: 1, name: "Recently Added") |
||||
|
||||
let items: [any PlaylistRepresentable] = [regular, smart] |
||||
let identities = items.map(\.listIdentity) |
||||
|
||||
#expect(Set(identities).count == items.count) |
||||
} |
||||
|
||||
// Step 1: build the merged collection the way allPlaylists does, with regular |
||||
// and smart playlists whose ids overlap across the two tables. |
||||
// Step 2: map every item to its list identity. |
||||
// Step 3: assert all identities are unique across the whole collection — the |
||||
// invariant SwiftUI ForEach needs to avoid duplicate/leaking rows. |
||||
@Test func mergedPlaylistsHaveUniqueListIdentities() { |
||||
let regulars: [any PlaylistRepresentable] = [ |
||||
Playlist.fixture(id: 1, name: "Rock"), |
||||
Playlist.fixture(id: 2, name: "Jazz"), |
||||
] |
||||
let smarts: [any PlaylistRepresentable] = [ |
||||
SmartPlaylist.fixture(id: 1, name: "Recently Added"), |
||||
SmartPlaylist.fixture(id: 2, name: "Top Rated"), |
||||
] |
||||
let all = regulars + smarts |
||||
let identities = all.map(\.listIdentity) |
||||
|
||||
#expect(Set(identities).count == all.count) |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@ |
||||
import Testing |
||||
import Foundation |
||||
@testable import Music |
||||
|
||||
@MainActor |
||||
struct PlaylistViewModelTests { |
||||
|
||||
// Verifies createPlaylistAndAddTrack does the full job in one call: |
||||
// 1. Seed a track into an in-memory DB and build a PlaylistViewModel over it. |
||||
// 2. Call createPlaylistAndAddTrack with a name and the seeded track. |
||||
// 3. The returned playlist has the given name and a real (non-nil) id. |
||||
// 4. The DB shows that playlist now contains exactly the seeded track. |
||||
// 5. The new playlist is recorded as the last-used playlist. |
||||
@Test func createPlaylistAndAddTrackCreatesPlaylistAndAddsTrack() throws { |
||||
// 1. Seed a track and build the view model. |
||||
let db = try DatabaseService(inMemory: true) |
||||
var track = Track.fixture(fileURL: "/song.mp3", title: "Song A") |
||||
try db.insert(&track) |
||||
let vm = PlaylistViewModel(db: db) |
||||
|
||||
// 2. Create a new playlist and add the track in one step. |
||||
let created = try vm.createPlaylistAndAddTrack(name: "Road Trip", track: track) |
||||
|
||||
// 3. The returned playlist is well-formed (a real id was assigned, name matches). |
||||
let createdId = try #require(created.id) |
||||
#expect(created.name == "Road Trip") |
||||
|
||||
// 4. The playlist contains exactly the seeded track. |
||||
let tracks = try db.fetchPlaylistTracks(playlistId: createdId) |
||||
#expect(tracks.count == 1) |
||||
#expect(tracks[0].id == track.id) |
||||
|
||||
// 5. The new playlist became the last-used playlist. |
||||
#expect(vm.lastUsedPlaylistId == createdId) |
||||
} |
||||
} |
||||
@ -0,0 +1,123 @@ |
||||
import Testing |
||||
import Foundation |
||||
import GRDB |
||||
@testable import Music |
||||
|
||||
/// Tests for the remote-DB transfer integrity guard. The bug under test: connecting to |
||||
/// a remote host downloaded a database that opened with "database disk image is malformed" |
||||
/// (SQLITE_CORRUPT) on `SELECT * FROM sqlite_master`. The fix validates the database with |
||||
/// `PRAGMA quick_check` on both ends (host before serving, client after writing) so a |
||||
/// malformed image is rejected with a clear error instead of crashing the UI. |
||||
@MainActor |
||||
struct RemoteDBIntegrityTests { |
||||
|
||||
/// Build a real DatabaseService (DatabasePool/WAL, like the running app) at a fresh |
||||
/// temp path, populated with `count` tracks, and return (tempDir, dbPath). |
||||
private func makePopulatedDB(count: Int) throws -> (dir: URL, path: String) { |
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) |
||||
let path = tempDir.appendingPathComponent("src.sqlite").path |
||||
let db = try DatabaseService(path: path) |
||||
for i in 1...count { |
||||
var t = Track.fixture(fileURL: "/track\(i).mp3", title: "Song number \(i) with several words") |
||||
try db.insert(&t) |
||||
} |
||||
return (tempDir, path) |
||||
} |
||||
|
||||
@Test |
||||
func backupCopyIsWellFormedAndSurvivesHTTPFraming() throws { |
||||
// 1. Build a source DB large enough to span many pages, like the real ~5.8MB library. |
||||
let (tempDir, srcPath) = try makePopulatedDB(count: 2000) |
||||
defer { try? FileManager.default.removeItem(at: tempDir) } |
||||
|
||||
// 2. Produce the served copy exactly as the host does (VACUUM INTO via backup()). |
||||
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path |
||||
try DatabaseService(path: srcPath).backup(to: copyPath) |
||||
|
||||
// 3. The copy must pass the integrity gate the host now applies before serving. |
||||
#expect(DatabaseService.isWellFormedDatabase(atPath: copyPath)) |
||||
|
||||
// 4. Frame the copy into an HTTP response byte-for-byte like HostServer.sendHTTP, |
||||
// then parse it back like RemoteClient.handleDBData (split on the first \r\n\r\n). |
||||
let bodyBytes = try Data(contentsOf: URL(fileURLWithPath: copyPath)) |
||||
var response = Data("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: \(bodyBytes.count)\r\nConnection: close\r\n\r\n".utf8) |
||||
response.append(bodyBytes) |
||||
let separator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A] |
||||
let sepRange = try #require(response.range(of: Data(separator))) |
||||
let parsedBody = Data(response[sepRange.upperBound...]) |
||||
|
||||
// 5. The framed-then-parsed body must be byte-identical to the original copy. |
||||
#expect(parsedBody == bodyBytes) |
||||
|
||||
// 6. Writing it out yields a DB that passes the gate, opens cleanly, and has all rows. |
||||
let recvPath = tempDir.appendingPathComponent("received.sqlite").path |
||||
try parsedBody.write(to: URL(fileURLWithPath: recvPath)) |
||||
#expect(DatabaseService.isWellFormedDatabase(atPath: recvPath)) |
||||
let reopened = try DatabaseService(path: recvPath) |
||||
let n = try reopened.dbPool.read { db in try Int.fetchOne(db, sql: "SELECT count(*) FROM tracks") } |
||||
#expect(n == 2000) |
||||
} |
||||
|
||||
@Test |
||||
func truncatedDownloadIsRejected() throws { |
||||
// Reproduces the user's exact symptom: a page-aligned but incomplete SQLite image. |
||||
// 1. Build a valid served copy. |
||||
let (tempDir, srcPath) = try makePopulatedDB(count: 2000) |
||||
defer { try? FileManager.default.removeItem(at: tempDir) } |
||||
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path |
||||
try DatabaseService(path: srcPath).backup(to: copyPath) |
||||
let full = try Data(contentsOf: URL(fileURLWithPath: copyPath)) |
||||
|
||||
// 2. Keep the first half — still a whole number of 4096-byte pages with a valid |
||||
// SQLite header, exactly the shape of the user's "5828608 bytes" malformed file. |
||||
let truncatedLen = (full.count / 2 / 4096) * 4096 |
||||
let truncated = Data(full.prefix(truncatedLen)) |
||||
let truncPath = tempDir.appendingPathComponent("truncated.sqlite").path |
||||
try truncated.write(to: URL(fileURLWithPath: truncPath)) |
||||
|
||||
// 3. The gate must reject it. Before the fix the client handed this straight to |
||||
// GRDB, which crashed with "database disk image is malformed". |
||||
#expect(DatabaseService.isWellFormedDatabase(atPath: truncPath) == false) |
||||
} |
||||
|
||||
@Test |
||||
func corruptInteriorBytesAreRejected() throws { |
||||
// 1. Build a valid served copy. |
||||
let (tempDir, srcPath) = try makePopulatedDB(count: 2000) |
||||
defer { try? FileManager.default.removeItem(at: tempDir) } |
||||
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path |
||||
try DatabaseService(path: srcPath).backup(to: copyPath) |
||||
var bytes = try Data(contentsOf: URL(fileURLWithPath: copyPath)) |
||||
|
||||
// 2. Leave the SQLite header intact (so it's still recognized as a database — this |
||||
// yields SQLITE_CORRUPT/error 11, like the user saw, not "not a database"/error 26) |
||||
// but smash a stretch of an interior b-tree page. |
||||
for i in 4096..<4600 { bytes[i] = 0xFF } |
||||
let corruptPath = tempDir.appendingPathComponent("corrupt.sqlite").path |
||||
try bytes.write(to: URL(fileURLWithPath: corruptPath)) |
||||
|
||||
// 3. The gate must reject the malformed image. |
||||
#expect(DatabaseService.isWellFormedDatabase(atPath: corruptPath) == false) |
||||
} |
||||
|
||||
@Test |
||||
func emptyMissingAndGarbageFilesAreRejected() throws { |
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) |
||||
defer { try? FileManager.default.removeItem(at: tempDir) } |
||||
|
||||
// 1. A missing file is not well-formed. |
||||
#expect(DatabaseService.isWellFormedDatabase(atPath: tempDir.appendingPathComponent("nope.sqlite").path) == false) |
||||
|
||||
// 2. An empty file (0 bytes) is not well-formed. |
||||
let emptyPath = tempDir.appendingPathComponent("empty.sqlite").path |
||||
try Data().write(to: URL(fileURLWithPath: emptyPath)) |
||||
#expect(DatabaseService.isWellFormedDatabase(atPath: emptyPath) == false) |
||||
|
||||
// 3. A short text body (e.g. an HTTP error message written as if it were a DB) is rejected. |
||||
let garbagePath = tempDir.appendingPathComponent("garbage.sqlite").path |
||||
try Data("Failed to read database".utf8).write(to: URL(fileURLWithPath: garbagePath)) |
||||
#expect(DatabaseService.isWellFormedDatabase(atPath: garbagePath) == false) |
||||
} |
||||
} |
||||
@ -0,0 +1,98 @@ |
||||
import Testing |
||||
import Foundation |
||||
import MusicShared |
||||
import Network |
||||
@testable import Music |
||||
|
||||
@MainActor |
||||
struct RemoteLibraryDisplayTests { |
||||
|
||||
// Reproduces the reported remote-mode bug: "connect + DB download seem fine, |
||||
// but no tracks show up". This exercises the exact step enterRemoteMode performs |
||||
// after the download — pointing a LibraryViewModel at the downloaded DB — and |
||||
// asserts the view model's `tracks` array actually populates. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func remoteLibraryViewModelPopulatesTracksAfterDownload() async throws { |
||||
// 1. Create a host database and insert three tracks (the "host library"). |
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) |
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) |
||||
defer { try? FileManager.default.removeItem(at: tempDir) } |
||||
let hostDBPath = tempDir.appendingPathComponent("host.sqlite").path |
||||
let hostDB = try DatabaseService(path: hostDBPath) |
||||
for i in 1...3 { |
||||
var t = Track.fixture(fileURL: "/song\(i).mp3", title: "Song \(i)") |
||||
try hostDB.insert(&t) |
||||
} |
||||
|
||||
// 2. Start the HostServer configured with the DB so GET /db serves a backup copy. |
||||
let server = HostServer(dbPath: hostDBPath) |
||||
server.configure(player: nil, db: hostDB) |
||||
try server.start() |
||||
try await Task.sleep(for: .milliseconds(200)) |
||||
let port = server.actualPort! |
||||
defer { server.stop() } |
||||
|
||||
// 3. Download the DB over HTTP and write the body to disk, exactly as |
||||
// RemoteClient.handleDBData does when a remote connects to a host. |
||||
let body = try await httpGet(host: "127.0.0.1", port: port, path: "/db") |
||||
let downloadedPath = tempDir.appendingPathComponent("remote_db.sqlite").path |
||||
try body.write(to: URL(fileURLWithPath: downloadedPath)) |
||||
|
||||
// 4. Point a LibraryViewModel at the downloaded DB — this is precisely what |
||||
// MusicApp.enterRemoteMode() does after a successful connect. |
||||
let remoteDB = try DatabaseService(path: downloadedPath) |
||||
let library = LibraryViewModel(db: remoteDB) |
||||
|
||||
// 5. The LibraryViewModel loads via an async GRDB ValueObservation, so poll |
||||
// briefly for the observation to deliver its initial value. |
||||
try await waitUntil { library.tracks.count == 3 } |
||||
|
||||
// 6. The remote library MUST display all three downloaded tracks. If this |
||||
// fails with 0 tracks, it reproduces the "no tracks after connect" bug. |
||||
#expect(library.tracks.count == 3) |
||||
} |
||||
|
||||
// MARK: - Helpers |
||||
|
||||
/// Polls `condition` on the main actor until it becomes true or the timeout elapses. |
||||
private func waitUntil( |
||||
timeout: Duration = .seconds(3), |
||||
_ condition: @MainActor () -> Bool |
||||
) async throws { |
||||
let deadline = ContinuousClock.now + timeout |
||||
while ContinuousClock.now < deadline { |
||||
if condition() { return } |
||||
try await Task.sleep(for: .milliseconds(50)) |
||||
} |
||||
} |
||||
|
||||
/// Performs a simple HTTP GET using NWConnection and returns the response body. |
||||
private func httpGet(host: String, port: UInt16, path: String) async throws -> Data { |
||||
try await withCheckedThrowingContinuation { continuation in |
||||
let connection = NWConnection( |
||||
host: NWEndpoint.Host(host), |
||||
port: NWEndpoint.Port(rawValue: port)!, |
||||
using: .tcp |
||||
) |
||||
connection.stateUpdateHandler = { state in |
||||
if case .ready = state { |
||||
let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n" |
||||
connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) |
||||
connection.receiveMessage { data, _, _, error in |
||||
if let error { |
||||
continuation.resume(throwing: error) |
||||
} else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) { |
||||
continuation.resume(returning: Data(data[range.upperBound...])) |
||||
} else { |
||||
continuation.resume(returning: data ?? Data()) |
||||
} |
||||
connection.cancel() |
||||
} |
||||
} else if case .failed(let error) = state { |
||||
continuation.resume(throwing: error) |
||||
} |
||||
} |
||||
connection.start(queue: .main) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,343 @@ |
||||
import Testing |
||||
import Foundation |
||||
import MusicShared |
||||
@testable import Music |
||||
|
||||
@MainActor |
||||
struct StreamingIntegrationTests { |
||||
static let testAPIKey = "integration-test-key" |
||||
|
||||
// Full flow: start server, authenticate, download DB, verify track is present. |
||||
// Steps: |
||||
// 1. Create an in-memory DB and insert a test track |
||||
// 2. Start StreamingServer on a random port |
||||
// 3. Authenticate via GET /auth |
||||
// 4. Download DB via GET /db |
||||
// 5. Save downloaded DB to disk and verify the track is present |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func fullConnectionFlow() async throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
var track = Track.fixture(id: nil, fileURL: "/tmp/test.mp3", title: "Test Song") |
||||
try db.insert(&track) |
||||
|
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
let baseURL = "http://127.0.0.1:\(port)" |
||||
|
||||
// 3. Authenticate |
||||
var authReq = URLRequest(url: URL(string: "\(baseURL)/auth")!) |
||||
authReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") |
||||
let (authData, authResp) = try await URLSession.shared.data(for: authReq) |
||||
let authHTTP = try #require(authResp as? HTTPURLResponse) |
||||
#expect(authHTTP.statusCode == 200) |
||||
let authResponse = try JSONDecoder().decode(AuthResponse.self, from: authData) |
||||
#expect(authResponse.protocolVersion == StreamingConstants.protocolVersion) |
||||
|
||||
// 4. Download DB |
||||
var dbReq = URLRequest(url: URL(string: "\(baseURL)/db")!) |
||||
dbReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") |
||||
let (dbData, dbResp) = try await URLSession.shared.data(for: dbReq) |
||||
let dbHTTP = try #require(dbResp as? HTTPURLResponse) |
||||
#expect(dbHTTP.statusCode == 200) |
||||
#expect(dbData.count > 0) |
||||
|
||||
// 5. Verify downloaded DB contains the track |
||||
let tempPath = FileManager.default.temporaryDirectory |
||||
.appendingPathComponent("integration_test_\(UUID().uuidString).sqlite").path |
||||
defer { try? FileManager.default.removeItem(atPath: tempPath) } |
||||
try dbData.write(to: URL(fileURLWithPath: tempPath)) |
||||
let downloadedDb = try DatabaseService(path: tempPath) |
||||
let tracks = try downloadedDb.fetchTracks(search: "", sortColumn: "title", ascending: true) |
||||
#expect(tracks.count == 1) |
||||
#expect(tracks[0].title == "Test Song") |
||||
} |
||||
|
||||
// Verifies that requests without auth get 401. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func unauthenticatedRequestsRejected() async throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
|
||||
let request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/auth")!) |
||||
let (_, response) = try await URLSession.shared.data(for: request) |
||||
let httpResponse = try #require(response as? HTTPURLResponse) |
||||
#expect(httpResponse.statusCode == 401) |
||||
} |
||||
|
||||
// Verifies the /tracks/:trackId/file endpoint serves audio data. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func fileEndpointServesTrack() async throws { |
||||
// 1. Create DB with a test track pointing to a real audio file |
||||
let db = try DatabaseService(inMemory: true) |
||||
let fixtureURL = try TestFixtures.shortMP3URL() |
||||
var track = Track.fixture(id: nil, fileURL: fixtureURL.path, title: "Stream Test") |
||||
try db.insert(&track) |
||||
let trackId = try #require(track.id) |
||||
|
||||
// 2. Start server |
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
let baseURL = "http://127.0.0.1:\(port)" |
||||
|
||||
// 3. Request via Bearer auth |
||||
var bearerReq = URLRequest(url: URL(string: "\(baseURL)/file?id=\(trackId)")!) |
||||
bearerReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") |
||||
let (bearerData, bearerResp) = try await URLSession.shared.data(for: bearerReq) |
||||
let bearerHTTP = try #require(bearerResp as? HTTPURLResponse) |
||||
#expect(bearerHTTP.statusCode == 200) |
||||
#expect(bearerData.count > 0) |
||||
|
||||
// 4. Request via token query param |
||||
let tokenURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! |
||||
let (tokenData, tokenResp) = try await URLSession.shared.data(for: URLRequest(url: tokenURL)) |
||||
let tokenHTTP = try #require(tokenResp as? HTTPURLResponse) |
||||
#expect(tokenHTTP.statusCode == 200) |
||||
#expect(tokenData.count == bearerData.count) |
||||
|
||||
// 5. Unauthenticated request should be 401 |
||||
let noAuthURL = URL(string: "\(baseURL)/file?id=\(trackId)")! |
||||
let (_, noAuthResp) = try await URLSession.shared.data(for: URLRequest(url: noAuthURL)) |
||||
let noAuthHTTP = try #require(noAuthResp as? HTTPURLResponse) |
||||
#expect(noAuthHTTP.statusCode == 401) |
||||
} |
||||
|
||||
// Verifies that wrong API key gets 401. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func wrongApiKeyRejected() async throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/auth")!) |
||||
request.setValue("Bearer wrong-key", forHTTPHeaderField: "Authorization") |
||||
let (_, response) = try await URLSession.shared.data(for: request) |
||||
let httpResponse = try #require(response as? HTTPURLResponse) |
||||
#expect(httpResponse.statusCode == 401) |
||||
} |
||||
|
||||
// Reproduces the real-world "File not found on disk" (HTTP 404) bug. |
||||
// |
||||
// The production scanner (ScannerService) stores `fileURL` as |
||||
// `url.absoluteString` — e.g. "file:///Users/.../song.m4a" — WITH the |
||||
// "file://" scheme and percent-encoding. StreamingServer reconstructed it |
||||
// with `URL(fileURLWithPath:)`, which treats that whole string as a raw |
||||
// (relative) path, prepends the CWD, and skips percent-decoding, so the file |
||||
// is never found on disk. The earlier `fileEndpointServesTrack` test masked |
||||
// this because it stored `fixtureURL.path` (a bare path) instead. |
||||
// |
||||
// Steps: |
||||
// 1. Create a DB with a track whose fileURL is stored EXACTLY as the scanner |
||||
// stores it: `fixtureURL.absoluteString` (not `.path`). |
||||
// 2. Start the streaming server. |
||||
// 3. Request GET /file?id=<trackId> with a valid token. |
||||
// 4. Expect HTTP 200 with the file's bytes. Before the fix this returns 404 |
||||
// with body {"error":{"message":"File not found on disk"}}. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func fileEndpointServesTrackStoredAsAbsoluteString() async throws { |
||||
// 1. Insert a track using the production storage format (absoluteString). |
||||
let db = try DatabaseService(inMemory: true) |
||||
let fixtureURL = try TestFixtures.shortMP3URL() |
||||
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Abs Stream Test") |
||||
try db.insert(&track) |
||||
let trackId = try #require(track.id) |
||||
|
||||
// 2. Start the server. |
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
let baseURL = "http://127.0.0.1:\(port)" |
||||
|
||||
// 3. Request the file via the token query parameter. |
||||
let url = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! |
||||
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: url)) |
||||
let http = try #require(resp as? HTTPURLResponse) |
||||
|
||||
// 4. Must serve the bytes, not 404. |
||||
#expect(http.statusCode == 200) |
||||
#expect(data.count > 0) |
||||
} |
||||
|
||||
// Same root cause as above, exercised through the HLS path (SegmenterCache |
||||
// also used URL(fileURLWithPath:) on the stored fileURL). |
||||
// |
||||
// Steps: |
||||
// 1. Insert a track whose fileURL is stored as `absoluteString`. |
||||
// 2. Start the server. |
||||
// 3. Request GET /tracks/<id>/segments/0.mp3 with a valid token. |
||||
// 4. Expect HTTP 200 with segment bytes (before the fix the segmenter cannot |
||||
// open the file, so this returns a 404/error). |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func segmentEndpointServesTrackStoredAsAbsoluteString() async throws { |
||||
// 1. Insert a track using the production storage format (absoluteString). |
||||
let db = try DatabaseService(inMemory: true) |
||||
let fixtureURL = try TestFixtures.shortMP3URL() |
||||
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Abs HLS Test") |
||||
try db.insert(&track) |
||||
let trackId = try #require(track.id) |
||||
|
||||
// 2. Start the server. |
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
let baseURL = "http://127.0.0.1:\(port)" |
||||
|
||||
// 3. Request the first HLS segment via the token query parameter. |
||||
let url = URL(string: "\(baseURL)/tracks/\(trackId)/segments/0.mp3?token=\(Self.testAPIKey)")! |
||||
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: url)) |
||||
let http = try #require(resp as? HTTPURLResponse) |
||||
|
||||
// 4. Must serve segment bytes, not 404. |
||||
#expect(http.statusCode == 200) |
||||
#expect(data.count > 0) |
||||
} |
||||
|
||||
// Reproduces the "seeking does not work in streaming mode" bug. |
||||
// |
||||
// The client plays the /file endpoint through a plain AVURLAsset/AVPlayer |
||||
// with no custom resource loader, so AVPlayer relies on the OS's default |
||||
// HTTP transport. AVPlayer only treats a progressive HTTP asset as |
||||
// *seekable* when the server honors HTTP byte-range requests: a `Range` |
||||
// request must be answered with `206 Partial Content`, a matching |
||||
// `Content-Range`, `Accept-Ranges: bytes`, a `Content-Length` equal to the |
||||
// slice length, and a body containing ONLY the requested slice. The /file |
||||
// endpoint currently ignores the Range header and always returns the whole |
||||
// file with `200 OK`, so AVPlayer concludes the stream has no random access |
||||
// and silently refuses to seek. |
||||
// |
||||
// Steps: |
||||
// 1. Insert a track pointing at a real audio fixture and start the server. |
||||
// 2. Download the full file once to learn its total byte length and exact |
||||
// bytes (the ground truth we slice against). |
||||
// 3. Request a middle byte range (Range: bytes=10-19) from /file. |
||||
// 4. Expect 206 Partial Content, Content-Range: bytes 10-19/<total>, |
||||
// Accept-Ranges: bytes, Content-Length: 10, and a body byte-for-byte |
||||
// equal to fullBytes[10...19]. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func fileEndpointHonorsRangeRequests() async throws { |
||||
// 1. Insert a track + start the server. |
||||
let db = try DatabaseService(inMemory: true) |
||||
let fixtureURL = try TestFixtures.shortMP3URL() |
||||
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Range Test") |
||||
try db.insert(&track) |
||||
let trackId = try #require(track.id) |
||||
|
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
let baseURL = "http://127.0.0.1:\(port)" |
||||
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! |
||||
|
||||
// 2. Download the full file to establish ground-truth length + bytes. |
||||
let (fullData, fullResp) = try await URLSession.shared.data(for: URLRequest(url: fileURL)) |
||||
let fullHTTP = try #require(fullResp as? HTTPURLResponse) |
||||
#expect(fullHTTP.statusCode == 200) |
||||
let total = fullData.count |
||||
#expect(total > 20) // fixture must be large enough to slice a middle range |
||||
|
||||
// 3. Request bytes 10-19 (10 bytes, inclusive range). |
||||
var rangeReq = URLRequest(url: fileURL) |
||||
rangeReq.setValue("bytes=10-19", forHTTPHeaderField: "Range") |
||||
let (rangeData, rangeResp) = try await URLSession.shared.data(for: rangeReq) |
||||
let rangeHTTP = try #require(rangeResp as? HTTPURLResponse) |
||||
|
||||
// 4. Assert proper Partial Content semantics. |
||||
#expect(rangeHTTP.statusCode == 206) |
||||
#expect(rangeHTTP.value(forHTTPHeaderField: "Accept-Ranges") == "bytes") |
||||
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Range") == "bytes 10-19/\(total)") |
||||
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Length") == "10") |
||||
#expect(rangeData.count == 10) |
||||
#expect(rangeData == fullData.subdata(in: 10..<20)) |
||||
} |
||||
|
||||
// A plain GET with no Range header must still succeed AND advertise |
||||
// `Accept-Ranges: bytes` up front, so AVPlayer knows before scrubbing that |
||||
// the stream supports random access. Without this header AVPlayer never |
||||
// enables seeking even if it would have gotten 206s. |
||||
// |
||||
// Steps: |
||||
// 1. Insert a track + start the server. |
||||
// 2. GET /file with no Range header. |
||||
// 3. Expect 200 OK, Accept-Ranges: bytes, and the full body. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func fileEndpointAdvertisesByteRangesWithoutRangeHeader() async throws { |
||||
// 1. Insert a track + start the server. |
||||
let db = try DatabaseService(inMemory: true) |
||||
let fixtureURL = try TestFixtures.shortMP3URL() |
||||
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Accept-Ranges Test") |
||||
try db.insert(&track) |
||||
let trackId = try #require(track.id) |
||||
|
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
let baseURL = "http://127.0.0.1:\(port)" |
||||
|
||||
// 2. Plain GET, no Range header. |
||||
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! |
||||
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: fileURL)) |
||||
let http = try #require(resp as? HTTPURLResponse) |
||||
|
||||
// 3. Full body returned, but server advertises range support. |
||||
#expect(http.statusCode == 200) |
||||
#expect(http.value(forHTTPHeaderField: "Accept-Ranges") == "bytes") |
||||
#expect(data.count > 0) |
||||
} |
||||
|
||||
// An open-ended range (Range: bytes=N-) means "from byte N to the end" — |
||||
// the form AVPlayer most commonly issues while streaming forward. The |
||||
// server must return 206 with the tail of the file and a Content-Range |
||||
// whose end is total-1. |
||||
// |
||||
// Steps: |
||||
// 1. Insert a track + start the server. |
||||
// 2. Download the full file for ground-truth length + bytes. |
||||
// 3. Request Range: bytes=10- (byte 10 through EOF). |
||||
// 4. Expect 206, Content-Range: bytes 10-<total-1>/<total>, and a body |
||||
// equal to fullBytes[10...]. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func fileEndpointHonorsOpenEndedRange() async throws { |
||||
// 1. Insert a track + start the server. |
||||
let db = try DatabaseService(inMemory: true) |
||||
let fixtureURL = try TestFixtures.shortMP3URL() |
||||
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Open Range Test") |
||||
try db.insert(&track) |
||||
let trackId = try #require(track.id) |
||||
|
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
let baseURL = "http://127.0.0.1:\(port)" |
||||
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")! |
||||
|
||||
// 2. Ground-truth full file. |
||||
let (fullData, _) = try await URLSession.shared.data(for: URLRequest(url: fileURL)) |
||||
let total = fullData.count |
||||
#expect(total > 10) |
||||
|
||||
// 3. Open-ended range from byte 10. |
||||
var rangeReq = URLRequest(url: fileURL) |
||||
rangeReq.setValue("bytes=10-", forHTTPHeaderField: "Range") |
||||
let (rangeData, rangeResp) = try await URLSession.shared.data(for: rangeReq) |
||||
let rangeHTTP = try #require(rangeResp as? HTTPURLResponse) |
||||
|
||||
// 4. Tail of the file, correctly described. |
||||
#expect(rangeHTTP.statusCode == 206) |
||||
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Range") == "bytes 10-\(total - 1)/\(total)") |
||||
#expect(rangeData.count == total - 10) |
||||
#expect(rangeData == fullData.subdata(in: 10..<total)) |
||||
} |
||||
} |
||||
@ -0,0 +1,82 @@ |
||||
import Testing |
||||
import Foundation |
||||
import AVFoundation |
||||
import MusicShared |
||||
@testable import Music |
||||
|
||||
// End-to-end reproduction of the reported bug: |
||||
// "After streaming a full track, AVPlayer fails with 'Operation Stopped' |
||||
// and the player does NOT auto-advance to the next track." |
||||
// |
||||
// These tests drive the *real* StreamingPlaybackProvider against the *real* |
||||
// StreamingServer, exactly as the app does, and assert that playing a track to |
||||
// its natural end results in a clean finish (onTrackFinished fires, no error). |
||||
@MainActor |
||||
struct StreamingPlaybackEndToEndTests { |
||||
static let testAPIKey = "e2e-test-key" |
||||
|
||||
// Spins the main run loop (where AVPlayer delivers its callbacks) until |
||||
// `condition` is true or `timeout` seconds elapse. Returns true if the |
||||
// condition was met. Polls in small slices so an async @MainActor test |
||||
// lets AVPlayer's main-queue observers run. |
||||
private func waitUntil(timeout: Double, _ condition: () -> Bool) async -> Bool { |
||||
let slices = Int(timeout / 0.05) |
||||
for _ in 0..<slices { |
||||
if condition() { return true } |
||||
try? await Task.sleep(nanoseconds: 50_000_000) // 50 ms |
||||
} |
||||
return condition() |
||||
} |
||||
|
||||
// Reproduces the auto-advance failure at end-of-track. |
||||
// Steps: |
||||
// 1. Create an in-memory DB and insert a track pointing at a real audio |
||||
// fixture, stored the way the production scanner stores it (absoluteString). |
||||
// 2. Start the real StreamingServer on an OS-assigned port. |
||||
// 3. Create the real StreamingPlaybackProvider pointed at that server. |
||||
// 4. Install an onTrackFinished callback that records the clean-finish signal |
||||
// (this is the callback PlayerViewModel uses to advance to the next track). |
||||
// 5. Begin playback of the track's /file URL and let it play to the end. |
||||
// 6. Wait until EITHER a clean finish fires OR a playback error appears. |
||||
// 7. Assert: no playback error occurred AND the clean-finish callback fired. |
||||
// On the current (buggy) server this fails — playback ends in an error |
||||
// instead of a clean finish, so the next track never plays. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func playingTrackToEndFiresCleanFinish() async throws { |
||||
// 1. DB + fixture track (stored as absoluteString, like the real scanner). |
||||
let db = try DatabaseService(inMemory: true) |
||||
let fixtureURL = try TestFixtures.shortMP3URL() |
||||
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "E2E Track") |
||||
try db.insert(&track) |
||||
|
||||
// 2. Start the real streaming server. |
||||
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
let port = try #require(server.actualPort) |
||||
|
||||
// 3. Real provider against the real server. |
||||
let provider = StreamingPlaybackProvider(hostURL: "http://127.0.0.1:\(port)", apiKey: Self.testAPIKey) |
||||
|
||||
// 4. Record the clean-finish signal (drives PlayerViewModel.next()). |
||||
var finishedCleanly = false |
||||
provider.onTrackFinished = { finishedCleanly = true } |
||||
|
||||
// 5. Start playback of the track. |
||||
let url = try #require(provider.urlForTrack(track)) |
||||
provider.play(url: url) |
||||
|
||||
// 6. Wait for a terminal outcome: clean finish or error. The fixture is a |
||||
// few seconds long; allow generous headroom for buffering. |
||||
_ = await waitUntil(timeout: 25) { |
||||
finishedCleanly || provider.playbackError != nil |
||||
} |
||||
|
||||
// --- Diagnostics (printed regardless of pass/fail) --- |
||||
print("E2E DIAGNOSTIC -> finishedCleanly=\(finishedCleanly), playbackError=\(String(describing: provider.playbackError)), providerDuration=\(provider.duration), currentTime=\(provider.currentTime)") |
||||
|
||||
// 7. A fully streamed track must end cleanly so the queue can advance. |
||||
#expect(provider.playbackError == nil) |
||||
#expect(finishedCleanly == true) |
||||
} |
||||
} |
||||
@ -0,0 +1,147 @@ |
||||
import Foundation |
||||
import MusicShared |
||||
import Network |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
@MainActor |
||||
struct StreamingServerTests { |
||||
|
||||
// MARK: - Helpers |
||||
|
||||
/// Creates a StreamingServer backed by an in-memory database on port 0 |
||||
/// (OS-assigned) with a known API key for testing. |
||||
private func makeServer() throws -> StreamingServer { |
||||
let db = try DatabaseService(inMemory: true) |
||||
return StreamingServer(db: db, apiKey: "test-key-12345", port: 0) |
||||
} |
||||
|
||||
/// Performs a simple HTTP GET using NWConnection and returns the full |
||||
/// response (headers + body) as raw Data, then splits out just the body. |
||||
private func httpGet( |
||||
host: String, |
||||
port: Int, |
||||
path: String, |
||||
headers: [String: String] = [:] |
||||
) async throws -> (statusCode: Int, body: Data) { |
||||
try await withCheckedThrowingContinuation { continuation in |
||||
let connection = NWConnection( |
||||
host: NWEndpoint.Host(host), |
||||
port: NWEndpoint.Port(rawValue: UInt16(port))!, |
||||
using: .tcp |
||||
) |
||||
connection.stateUpdateHandler = { state in |
||||
if case .ready = state { |
||||
// Build the HTTP request |
||||
var request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n" |
||||
for (key, value) in headers { |
||||
request += "\(key): \(value)\r\n" |
||||
} |
||||
request += "\r\n" |
||||
|
||||
connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) |
||||
|
||||
// Receive the full response (Connection: close ensures everything arrives) |
||||
connection.receiveMessage { data, _, _, error in |
||||
defer { connection.cancel() } |
||||
|
||||
if let error { |
||||
continuation.resume(throwing: error) |
||||
return |
||||
} |
||||
|
||||
guard let data else { |
||||
continuation.resume(returning: (statusCode: 0, body: Data())) |
||||
return |
||||
} |
||||
|
||||
// Parse the status code from the first line |
||||
let responseString = String(data: data, encoding: .utf8) ?? "" |
||||
let firstLine = responseString.split(separator: "\r\n").first ?? "" |
||||
let parts = firstLine.split(separator: " ") |
||||
let statusCode = parts.count >= 2 ? Int(parts[1]) ?? 0 : 0 |
||||
|
||||
// Extract body after the header/body separator |
||||
if let range = data.range(of: Data("\r\n\r\n".utf8)) { |
||||
continuation.resume(returning: (statusCode: statusCode, body: Data(data[range.upperBound...]))) |
||||
} else { |
||||
continuation.resume(returning: (statusCode: statusCode, body: Data())) |
||||
} |
||||
} |
||||
} else if case .failed(let error) = state { |
||||
continuation.resume(throwing: error) |
||||
} |
||||
} |
||||
connection.start(queue: .main) |
||||
} |
||||
} |
||||
|
||||
// MARK: - Tests |
||||
|
||||
// 1. Sends GET /auth with a valid Bearer key. |
||||
// 2. Expects a 200 status code. |
||||
// 3. Decodes the body as AuthResponse and verifies protocolVersion matches. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func authEndpointAcceptsValidKey() async throws { |
||||
let server = try makeServer() |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
|
||||
let port = server.actualPort! |
||||
let (statusCode, body) = try await httpGet( |
||||
host: "127.0.0.1", |
||||
port: port, |
||||
path: "/auth", |
||||
headers: ["Authorization": "Bearer test-key-12345"] |
||||
) |
||||
|
||||
#expect(statusCode == 200) |
||||
|
||||
let authResponse = try JSONDecoder().decode(AuthResponse.self, from: body) |
||||
#expect(authResponse.protocolVersion == StreamingConstants.protocolVersion) |
||||
#expect(!authResponse.hostName.isEmpty) |
||||
} |
||||
|
||||
// 1. Sends GET /auth WITHOUT an Authorization header. |
||||
// 2. Expects a 401 Unauthorized status code. |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func authEndpointRejectsNoKey() async throws { |
||||
let server = try makeServer() |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
|
||||
let port = server.actualPort! |
||||
let (statusCode, _) = try await httpGet( |
||||
host: "127.0.0.1", |
||||
port: port, |
||||
path: "/auth" |
||||
// No Authorization header |
||||
) |
||||
|
||||
#expect(statusCode == 401) |
||||
} |
||||
|
||||
// 1. Sends GET /db with a valid key using URLSession (binary response |
||||
// requires proper HTTP framing that NWConnection.receiveMessage lacks). |
||||
// 2. Expects a 200 status code. |
||||
// 3. Verifies the response body starts with the SQLite magic header "SQLite format 3". |
||||
@Test(.timeLimit(.minutes(1))) |
||||
func dbEndpointReturnsDatabaseFile() async throws { |
||||
let server = try makeServer() |
||||
try await server.start() |
||||
defer { server.stop() } |
||||
|
||||
let port = server.actualPort! |
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/db")!) |
||||
request.setValue("Bearer test-key-12345", forHTTPHeaderField: "Authorization") |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request) |
||||
let httpResponse = try #require(response as? HTTPURLResponse) |
||||
|
||||
#expect(httpResponse.statusCode == 200) |
||||
|
||||
// SQLite files start with "SQLite format 3\0" (16 bytes) |
||||
let header = String(data: data.prefix(16), encoding: .utf8) ?? "" |
||||
#expect(header.hasPrefix("SQLite format 3")) |
||||
} |
||||
} |
||||
@ -0,0 +1,76 @@ |
||||
import Foundation |
||||
import AVFoundation |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
// Locates the test bundle from a struct suite (struct suites don't have a Bundle.self, |
||||
// so we use a final class defined in the same file). |
||||
private final class BundleToken {} |
||||
|
||||
// Verifies format routing and that writing tags round-trips through a real file |
||||
// without corrupting audio. |
||||
struct TagWriterTests { |
||||
|
||||
// Step 1: Locate a resource file in the test bundle using BundleToken as the anchor. |
||||
private func fixtureURL(_ name: String, _ ext: String) -> URL? { |
||||
Bundle(for: BundleToken.self).url(forResource: name, withExtension: ext) |
||||
} |
||||
|
||||
// Step 2: Copy the fixture to a temp path so the test can mutate it without |
||||
// modifying the bundle resource. |
||||
private func tempCopy(of url: URL) throws -> URL { |
||||
let dst = URL(fileURLWithPath: NSTemporaryDirectory()) |
||||
.appendingPathComponent(UUID().uuidString + "." + url.pathExtension) |
||||
try FileManager.default.copyItem(at: url, to: dst) |
||||
return dst |
||||
} |
||||
|
||||
// Step 3: Read the common "title" key from an audio file using AVFoundation metadata. |
||||
private func readCommonTitle(_ url: URL) async throws -> String? { |
||||
let md = try await AVURLAsset(url: url).load(.metadata) |
||||
let items = AVMetadataItem.metadataItems(from: md, withKey: AVMetadataKey.commonKeyTitle, keySpace: .common) |
||||
return try await items.first?.load(.stringValue) |
||||
} |
||||
|
||||
// Verifies that TagWriterFactory routes ".mp3" → ID3TagWriter, ".m4a" → MP4TagWriter, |
||||
// and returns nil for unsupported formats. |
||||
@Test func factoryRoutesByExtension() { |
||||
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.mp3")) is ID3TagWriter) |
||||
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.m4a")) is MP4TagWriter) |
||||
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.flac")) == nil) |
||||
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.wav")) == nil) |
||||
} |
||||
|
||||
// Step 1: Locate the sample.m4a fixture in the test bundle. |
||||
// Step 2: Copy it to a temp file so the bundle resource is not mutated. |
||||
// Step 3: Build EditableTrackFields with a specific title and artist. |
||||
// Step 4: Write the fields via MP4TagWriter. |
||||
// Step 5: Read the common title back via AVFoundation and assert it matches. |
||||
// Step 6: Assert the audio track is still present (file not corrupted). |
||||
@Test func m4aRoundTrips() async throws { |
||||
let src = try #require(fixtureURL("sample", "m4a"), "missing sample.m4a fixture") |
||||
let url = try tempCopy(of: src) |
||||
defer { try? FileManager.default.removeItem(at: url) } |
||||
var f = EditableTrackFields(from: .fixture()) |
||||
f.title = "Round Trip"; f.artist = "The Verifier" |
||||
try MP4TagWriter().write(f, to: url) |
||||
#expect(try await readCommonTitle(url) == "Round Trip") |
||||
let tracks = try await AVURLAsset(url: url).loadTracks(withMediaType: .audio) |
||||
#expect(!tracks.isEmpty) // audio track survived the write |
||||
} |
||||
|
||||
// Step 1: Check if sample.mp3 fixture is available; skip trivially if absent. |
||||
// Step 2: Copy it to a temp file. |
||||
// Step 3: Build EditableTrackFields with a specific title. |
||||
// Step 4: Write the fields via ID3TagWriter. |
||||
// Step 5: Read the common title back via AVFoundation and assert it matches. |
||||
@Test func mp3RoundTrips() async throws { |
||||
guard let src = fixtureURL("sample", "mp3") else { return } // no fixture → trivially pass |
||||
let url = try tempCopy(of: src) |
||||
defer { try? FileManager.default.removeItem(at: url) } |
||||
var f = EditableTrackFields(from: .fixture()) |
||||
f.title = "ID3 Round Trip"; f.artist = "Tagger" |
||||
try ID3TagWriter().write(f, to: url) |
||||
#expect(try await readCommonTitle(url) == "ID3 Round Trip") |
||||
} |
||||
} |
||||
@ -0,0 +1,96 @@ |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
struct TrackContextMenuConfigTests { |
||||
// Builds a config with all fields set and verifies: |
||||
// - stored playlists, lastUsedPlaylistName, selectedPlaylist match the inputs |
||||
// - onAddToPlaylist callback fires with the correct track and playlist |
||||
// - onAddToLastPlaylist callback fires with the correct track |
||||
// - onRemoveFromPlaylist callback fires with the correct track |
||||
// - when optional callbacks are nil, optionally calling them is safe |
||||
|
||||
@Test func storesPropertiesAndFiresCallbacks() { |
||||
// 1. Create fixture data |
||||
let pl1 = Playlist.fixture(id: 1, name: "Favorites") |
||||
let pl2 = Playlist.fixture(id: 2, name: "Chill") |
||||
let track = Track.fixture(id: 42, title: "Test") |
||||
|
||||
var addedTrack: Track? = nil |
||||
var addedPlaylist: Playlist? = nil |
||||
var lastTrack: Track? = nil |
||||
var removedTrack: Track? = nil |
||||
|
||||
// 2. Build config with all callbacks |
||||
let config = TrackContextMenuConfig( |
||||
playlists: [pl1, pl2], |
||||
lastUsedPlaylistName: "Favorites", |
||||
selectedPlaylist: pl1, |
||||
onAddToPlaylist: { t, p in addedTrack = t; addedPlaylist = p }, |
||||
onAddToLastPlaylist: { t in lastTrack = t }, |
||||
onRemoveFromPlaylist: { t in removedTrack = t } |
||||
) |
||||
|
||||
// 3. Verify stored properties |
||||
#expect(config.playlists.count == 2) |
||||
#expect(config.playlists[0].name == "Favorites") |
||||
#expect(config.lastUsedPlaylistName == "Favorites") |
||||
#expect(config.selectedPlaylist == pl1) |
||||
|
||||
// 4. Invoke callbacks and verify they fire correctly |
||||
config.onAddToPlaylist(track, pl2) |
||||
config.onAddToLastPlaylist?(track) |
||||
config.onRemoveFromPlaylist?(track) |
||||
|
||||
#expect(addedTrack?.id == track.id) |
||||
#expect(addedPlaylist?.id == pl2.id) |
||||
#expect(lastTrack?.id == track.id) |
||||
#expect(removedTrack?.id == track.id) |
||||
} |
||||
|
||||
@Test func nilOptionalCallbacksAreSafe() { |
||||
// Verifies that a config with nil optional callbacks does not crash |
||||
// when you call them via optional chaining (the normal usage pattern) |
||||
let pl = Playlist.fixture(id: 1, name: "Rock") |
||||
let track = Track.fixture() |
||||
|
||||
let config = TrackContextMenuConfig( |
||||
playlists: [pl], |
||||
lastUsedPlaylistName: nil, |
||||
selectedPlaylist: nil, |
||||
onAddToPlaylist: { _, _ in }, |
||||
onAddToLastPlaylist: nil, |
||||
onRemoveFromPlaylist: nil |
||||
) |
||||
|
||||
// These must not crash |
||||
config.onAddToLastPlaylist?(track) |
||||
config.onRemoveFromPlaylist?(track) |
||||
|
||||
#expect(config.lastUsedPlaylistName == nil) |
||||
#expect(config.selectedPlaylist == nil) |
||||
} |
||||
|
||||
// Verifies the queue callbacks fire with the right track. |
||||
@Test func queueCallbacksFire() { |
||||
let track = Track.fixture(id: 7, title: "Q") |
||||
var playNextTrack: Track? = nil |
||||
var addQueueTrack: Track? = nil |
||||
|
||||
let config = TrackContextMenuConfig( |
||||
playlists: [], |
||||
lastUsedPlaylistName: nil, |
||||
selectedPlaylist: nil, |
||||
onAddToPlaylist: { _, _ in }, |
||||
onAddToLastPlaylist: nil, |
||||
onRemoveFromPlaylist: nil, |
||||
onPlayNext: { t in playNextTrack = t }, |
||||
onAddToQueue: { t in addQueueTrack = t } |
||||
) |
||||
|
||||
config.onPlayNext?(track) |
||||
config.onAddToQueue?(track) |
||||
|
||||
#expect(playNextTrack?.id == 7) |
||||
#expect(addQueueTrack?.id == 7) |
||||
} |
||||
} |
||||
@ -0,0 +1,101 @@ |
||||
import Foundation |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
// Verifies the save orchestration: DB always updated; file writeback best-effort; |
||||
// stats refreshed on success; warnings on unsupported format / writer failure. |
||||
struct TrackEditServiceTests { |
||||
|
||||
// A spy writer we can make succeed or throw. |
||||
struct SpyWriter: TagWriter { |
||||
let shouldThrow: Bool |
||||
func write(_ fields: EditableTrackFields, to url: URL) throws { |
||||
if shouldThrow { throw TagWriterError.exportFailed } |
||||
// simulate a real write by appending a byte so size/mtime change. |
||||
let h = try FileHandle(forWritingTo: url); try h.seekToEnd() |
||||
try h.write(contentsOf: Data([0])); try h.close() |
||||
} |
||||
} |
||||
|
||||
private func tempTrack(ext: String) throws -> Track { |
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()) |
||||
.appendingPathComponent(UUID().uuidString + "." + ext) |
||||
try Data(repeating: 1, count: 100).write(to: url) |
||||
return .fixture(fileURL: url.absoluteString, fileFormat: ext) |
||||
} |
||||
|
||||
@Test func supportedFormatSuccessUpdatesDBAndRefreshesStats() throws { |
||||
// Step 1: DB + a real temp file + an edit changing the title. |
||||
let db = try DatabaseService(inMemory: true) |
||||
var t = try tempTrack(ext: "mp3"); try db.insert(&t) |
||||
let original = EditableTrackFields(from: t) |
||||
var edited = original; edited.title = "Edited" |
||||
// Step 2: save via a succeeding writer (injected). |
||||
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: false) }) |
||||
let warnings = svc.save(edited, editing: original.changedFields(to: edited), to: [t]) |
||||
// Step 3: no warnings; DB has new title and refreshed hash (file changed). |
||||
#expect(warnings.isEmpty) |
||||
let f = try #require(db.fetchTracksByIds([t.id!]).first) |
||||
#expect(f.title == "Edited") |
||||
#expect(f.fileHash != t.fileHash) |
||||
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) |
||||
} |
||||
|
||||
@Test func unsupportedFormatSavesDBOnlyWithWarning() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
var t = try tempTrack(ext: "flac"); try db.insert(&t) |
||||
var edited = EditableTrackFields(from: t); edited.album = "DB Only" |
||||
let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) // nil for flac |
||||
let warnings = svc.save(edited, editing: [.album], to: [t]) |
||||
#expect(warnings.count == 1) |
||||
#expect(warnings.first?.kind == .dbOnlyUnsupported) |
||||
#expect(try #require(db.fetchTracksByIds([t.id!]).first).album == "DB Only") |
||||
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) |
||||
} |
||||
|
||||
@Test func writerThrowsSavesDBOnlyWithFailureWarning() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
var t = try tempTrack(ext: "mp3"); try db.insert(&t) |
||||
var edited = EditableTrackFields(from: t); edited.genre = "Still Saved" |
||||
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) }) |
||||
let warnings = svc.save(edited, editing: [.genre], to: [t]) |
||||
#expect(warnings.first?.kind == .fileWriteFailed) |
||||
#expect(try #require(db.fetchTracksByIds([t.id!]).first).genre == "Still Saved") |
||||
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) |
||||
} |
||||
|
||||
@Test func dateAddedOnlyEditIsDBOnlyNoFileWrite() throws { |
||||
// Step 1: insert an mp3 track and remember its file hash. |
||||
let db = try DatabaseService(inMemory: true) |
||||
var t = try tempTrack(ext: "mp3"); try db.insert(&t) |
||||
let originalHash = t.fileHash |
||||
// Step 2: edit ONLY dateAdded, with a writer that THROWS if ever called — |
||||
// proving dateAdded is DB-only and triggers no file write. |
||||
var edited = EditableTrackFields(from: t) |
||||
let newDate = Date(timeIntervalSince1970: 1_000_000) |
||||
edited.dateAdded = newDate |
||||
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) }) |
||||
let warnings = svc.save(edited, editing: [.dateAdded], to: [t]) |
||||
// Step 3: no warnings (no write attempted); DB has the new date; file untouched. |
||||
#expect(warnings.isEmpty) |
||||
let f = try #require(db.fetchTracksByIds([t.id!]).first) |
||||
#expect(f.dateAdded == newDate) |
||||
#expect(f.fileHash == originalHash) |
||||
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) |
||||
} |
||||
|
||||
@Test func multiTrackAppliesOnlyEditedFields() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
var a = try tempTrack(ext: "flac"); a.album = "OldA"; a.genre = "RockA"; try db.insert(&a) |
||||
var b = try tempTrack(ext: "flac"); b.album = "OldB"; b.genre = "RockB"; try db.insert(&b) |
||||
var edited = EditableTrackFields(from: a); edited.album = "Shared" |
||||
let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) |
||||
_ = svc.save(edited, editing: [.album], to: [a, b]) |
||||
// album applied to both; each genre untouched. |
||||
#expect(try #require(db.fetchTracksByIds([a.id!]).first).album == "Shared") |
||||
#expect(try #require(db.fetchTracksByIds([b.id!]).first).album == "Shared") |
||||
#expect(try #require(db.fetchTracksByIds([b.id!]).first).genre == "RockB") |
||||
try? FileManager.default.removeItem(at: URL(string: a.fileURL)!) |
||||
try? FileManager.default.removeItem(at: URL(string: b.fileURL)!) |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@ |
||||
import Foundation |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
// Verifies the shared file-stat helper reads size/mod-date from disk and |
||||
// produces a fileHash identical to Track.computeHash (the existing canonical formula). |
||||
struct TrackFileStatsTests { |
||||
@Test func compute_matchesTrackComputeHash() throws { |
||||
// Step 1: write a temp file with known bytes. |
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()) |
||||
.appendingPathComponent(UUID().uuidString + ".bin") |
||||
try Data(repeating: 0xAB, count: 1234).write(to: url) |
||||
defer { try? FileManager.default.removeItem(at: url) } |
||||
|
||||
// Step 2: compute stats via the helper. |
||||
let stats = try TrackFileStats.compute(for: url) |
||||
|
||||
// Step 3: independently read attrs and assert the helper agrees. |
||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path) |
||||
let size = attrs[.size] as? Int64 ?? -1 |
||||
let mod = attrs[.modificationDate] as? Date ?? Date.distantPast |
||||
#expect(stats.fileSize == size) |
||||
#expect(stats.dateModified == mod) |
||||
#expect(stats.fileHash == Track.computeHash(fileSize: size, modificationDate: mod)) |
||||
} |
||||
} |
||||
@ -0,0 +1,343 @@ |
||||
# Track Drag-to-Playlist Implementation Plan |
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
||||
|
||||
**Goal:** Drag a single track from the track table onto a regular playlist chip to add it. |
||||
|
||||
**Architecture:** The NSTableView drag source writes the track's `Int64` ID to the pasteboard under a custom type. Each regular playlist chip in `PlaylistBarView` becomes a SwiftUI drop target that reads the ID and calls the existing `PlaylistViewModel.addTrack` method. A highlight shows when a valid drag hovers over a chip. |
||||
|
||||
**Tech Stack:** SwiftUI, AppKit (NSTableView/NSPasteboard), GRDB |
||||
|
||||
--- |
||||
|
||||
### Task 1: Add custom pasteboard type and update drag source |
||||
|
||||
**Files:** |
||||
- Modify: `Music/Views/TrackTableView.swift:4` (add constant) |
||||
- Modify: `Music/Views/TrackTableView.swift:133-140` (register drag types) |
||||
- Modify: `Music/Views/TrackTableView.swift:372-375` (pasteboardWriterForRow) |
||||
|
||||
- [ ] **Step 1: Add the custom pasteboard type constant** |
||||
|
||||
At the top of `TrackTableView.swift`, after line 6 (`private let defaultVisibleColumnIds`), add: |
||||
|
||||
```swift |
||||
private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID") |
||||
``` |
||||
|
||||
- [ ] **Step 2: Update `pasteboardWriterForRow` to always write track ID** |
||||
|
||||
Replace the current implementation (lines 372-375): |
||||
|
||||
```swift |
||||
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { |
||||
guard parent.onReorder != nil else { return nil } |
||||
return "\(row)" as NSString |
||||
} |
||||
``` |
||||
|
||||
With: |
||||
|
||||
```swift |
||||
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? { |
||||
let item = NSPasteboardItem() |
||||
if let trackId = parent.tracks[row].id { |
||||
item.setString(String(trackId), forType: trackIdPasteboardType) |
||||
} |
||||
if parent.onReorder != nil { |
||||
item.setString(String(row), forType: .string) |
||||
} |
||||
return item |
||||
} |
||||
``` |
||||
|
||||
- [ ] **Step 3: Update `validateDrop` and `acceptDrop` to read `.string` type explicitly** |
||||
|
||||
The current `acceptDrop` reads from the first pasteboard item's generic string. Now that we write two types, it must read `.string` specifically. |
||||
|
||||
Replace `acceptDrop` (lines 382-393): |
||||
|
||||
```swift |
||||
func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool { |
||||
guard let onReorder = parent.onReorder else { return false } |
||||
guard let item = info.draggingPasteboard.pasteboardItems?.first, |
||||
let rowString = item.string(forType: .string), |
||||
let sourceRow = Int(rowString) else { return false } |
||||
|
||||
let destination = sourceRow < row ? row - 1 : row |
||||
guard sourceRow != destination else { return false } |
||||
|
||||
onReorder(sourceRow, destination) |
||||
return true |
||||
} |
||||
``` |
||||
|
||||
- [ ] **Step 4: Update drag type registration in `updateNSView`** |
||||
|
||||
Replace the drag registration block (lines 133-140): |
||||
|
||||
```swift |
||||
if context.coordinator.parent.onReorder != nil { |
||||
if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) { |
||||
tableView.registerForDraggedTypes([.string]) |
||||
tableView.draggingDestinationFeedbackStyle = .gap |
||||
} |
||||
} else { |
||||
tableView.unregisterDraggedTypes() |
||||
} |
||||
``` |
||||
|
||||
With: |
||||
|
||||
```swift |
||||
let needsReorder = context.coordinator.parent.onReorder != nil |
||||
let wantedTypes: [NSPasteboard.PasteboardType] = needsReorder |
||||
? [trackIdPasteboardType, .string] |
||||
: [trackIdPasteboardType] |
||||
if Set(tableView.registeredDraggedTypes) != Set(wantedTypes) { |
||||
tableView.registerForDraggedTypes(wantedTypes) |
||||
tableView.draggingDestinationFeedbackStyle = needsReorder ? .gap : .none |
||||
} |
||||
``` |
||||
|
||||
- [ ] **Step 5: Build and verify** |
||||
|
||||
Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5` |
||||
|
||||
Expected: BUILD SUCCEEDED |
||||
|
||||
- [ ] **Step 6: Commit** |
||||
|
||||
```bash |
||||
git add Music/Views/TrackTableView.swift |
||||
git commit -m "feat: write track ID to pasteboard on drag start" |
||||
``` |
||||
|
||||
--- |
||||
|
||||
### Task 2: Add drop target to PlaylistBarView |
||||
|
||||
**Files:** |
||||
- Modify: `Music/Views/PlaylistBarView.swift:3-14` (add callback prop) |
||||
- Modify: `Music/Views/PlaylistBarView.swift:26-51` (add `.onDrop` to playlist chips) |
||||
- Modify: `Music/Views/PlaylistBarView.swift:59-96` (add `isDropTarget` param to `PlaylistButton`) |
||||
|
||||
- [ ] **Step 1: Add `onDropTrack` callback and `UniformTypeIdentifiers` import** |
||||
|
||||
Add at the top of the file: |
||||
|
||||
```swift |
||||
import UniformTypeIdentifiers |
||||
``` |
||||
|
||||
Add a new property to `PlaylistBarView` after the existing callbacks (after line 14): |
||||
|
||||
```swift |
||||
var onDropTrack: ((Int64, Playlist) -> Void)? |
||||
``` |
||||
|
||||
Also add a private constant for the UTType (inside the file, outside the struct — near the import): |
||||
|
||||
```swift |
||||
private let trackIdUTType = UTType("com.music.trackID")! |
||||
``` |
||||
|
||||
- [ ] **Step 2: Add `.onDrop` modifier to regular playlist chips** |
||||
|
||||
Replace the `ForEach` block (lines 26-52) with: |
||||
|
||||
```swift |
||||
ForEach(playlists, id: \.listIdentity) { item in |
||||
let isRegular = item is Playlist |
||||
PlaylistChip( |
||||
item: item, |
||||
isSelected: selectedItem?.listIdentity == item.listIdentity, |
||||
isRemoteMode: isRemoteMode, |
||||
acceptsDrop: isRegular, |
||||
trackIdUTType: trackIdUTType, |
||||
onTap: { |
||||
if selectedItem?.listIdentity == item.listIdentity { |
||||
onDeselect() |
||||
} else { |
||||
onSelect(item) |
||||
} |
||||
}, |
||||
onDropTrack: isRegular ? { trackId in |
||||
onDropTrack?(trackId, item as! Playlist) |
||||
} : nil, |
||||
onRename: { onRename(item) }, |
||||
onDelete: { onDelete(item) }, |
||||
onEditQuery: (item as? SmartPlaylist).map { smart in { onEditQuery(smart) } }, |
||||
onEditConditions: (item as? SmartPlaylist).map { smart in { onEditConditions(smart) } } |
||||
) |
||||
} |
||||
``` |
||||
|
||||
- [ ] **Step 3: Create a `PlaylistChip` wrapper view to manage drop state** |
||||
|
||||
Add this view between `PlaylistBarView` and `PlaylistButton` (it owns the `@State` for `isTargeted`): |
||||
|
||||
```swift |
||||
private struct PlaylistChip: View { |
||||
let item: any PlaylistRepresentable |
||||
let isSelected: Bool |
||||
let isRemoteMode: Bool |
||||
let acceptsDrop: Bool |
||||
let trackIdUTType: UTType |
||||
let onTap: () -> Void |
||||
var onDropTrack: ((Int64) -> Void)? |
||||
let onRename: () -> Void |
||||
let onDelete: () -> Void |
||||
var onEditQuery: (() -> Void)? |
||||
var onEditConditions: (() -> Void)? |
||||
|
||||
@State private var isDropTargeted = false |
||||
|
||||
var body: some View { |
||||
PlaylistButton( |
||||
name: item.name, |
||||
isSelected: isSelected, |
||||
isSmart: item.isSmartPlaylist, |
||||
isDropTarget: isDropTargeted, |
||||
action: onTap |
||||
) |
||||
.if(acceptsDrop) { view in |
||||
view.onDrop(of: [trackIdUTType], isTargeted: $isDropTargeted) { providers in |
||||
guard let provider = providers.first else { return false } |
||||
provider.loadItem(forTypeIdentifier: trackIdUTType.identifier) { data, _ in |
||||
guard let data = data as? Data, |
||||
let str = String(data: data, encoding: .utf8), |
||||
let trackId = Int64(str) else { return } |
||||
DispatchQueue.main.async { |
||||
onDropTrack?(trackId) |
||||
} |
||||
} |
||||
return true |
||||
} |
||||
} |
||||
.contextMenu { |
||||
if !isRemoteMode { |
||||
Button("Rename...") { onRename() } |
||||
if let onEditConditions { |
||||
Button("Edit...") { onEditConditions() } |
||||
} else if let onEditQuery { |
||||
Button("Edit Search Query...") { onEditQuery() } |
||||
} |
||||
Button("Delete") { onDelete() } |
||||
} |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
- [ ] **Step 4: Add the `.if` view extension if it doesn't already exist** |
||||
|
||||
Check if `View+if` already exists in the project. If not, add it at the bottom of `PlaylistBarView.swift`: |
||||
|
||||
```swift |
||||
private extension View { |
||||
@ViewBuilder |
||||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View { |
||||
if condition { transform(self) } else { self } |
||||
} |
||||
} |
||||
``` |
||||
|
||||
- [ ] **Step 5: Add `isDropTarget` parameter to PlaylistButton** |
||||
|
||||
Update `PlaylistButton` to accept and use the highlight state. Add the parameter: |
||||
|
||||
```swift |
||||
var isDropTarget: Bool = false |
||||
``` |
||||
|
||||
Update the `.background` and `.overlay` in the button body to respond to `isDropTarget`: |
||||
|
||||
```swift |
||||
.background( |
||||
isDropTarget ? tintColor.opacity(0.3) : |
||||
isSelected ? tintColor.opacity(0.2) : |
||||
Color.secondary.opacity(0.1) |
||||
) |
||||
``` |
||||
|
||||
```swift |
||||
.overlay( |
||||
RoundedRectangle(cornerRadius: 4) |
||||
.stroke( |
||||
isDropTarget ? tintColor : |
||||
isSelected ? tintColor : |
||||
Color.secondary.opacity(0.3), |
||||
lineWidth: isDropTarget ? 2 : 1 |
||||
) |
||||
) |
||||
``` |
||||
|
||||
- [ ] **Step 6: Build and verify** |
||||
|
||||
Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5` |
||||
|
||||
Expected: BUILD SUCCEEDED |
||||
|
||||
- [ ] **Step 7: Commit** |
||||
|
||||
```bash |
||||
git add Music/Views/PlaylistBarView.swift |
||||
git commit -m "feat: add drop target on playlist chips for track drag" |
||||
``` |
||||
|
||||
--- |
||||
|
||||
### Task 3: Wire up in ContentView |
||||
|
||||
**Files:** |
||||
- Modify: `Music/ContentView.swift:211-261` (add `onDropTrack` to PlaylistBarView call) |
||||
|
||||
- [ ] **Step 1: Add `onDropTrack` closure to PlaylistBarView instantiation** |
||||
|
||||
In `ContentView.swift`, add the `onDropTrack` parameter to the `PlaylistBarView(...)` call, after the `onEditConditions` closure (after line 259): |
||||
|
||||
```swift |
||||
onDropTrack: { trackId, targetPlaylist in |
||||
guard let track = library.tracks.first(where: { $0.id == trackId }) else { return } |
||||
try? playlist.addTrack(track, to: targetPlaylist) |
||||
} |
||||
``` |
||||
|
||||
- [ ] **Step 2: Build and verify** |
||||
|
||||
Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5` |
||||
|
||||
Expected: BUILD SUCCEEDED |
||||
|
||||
- [ ] **Step 3: Commit** |
||||
|
||||
```bash |
||||
git add Music/ContentView.swift |
||||
git commit -m "feat: wire track drag-to-playlist in ContentView" |
||||
``` |
||||
|
||||
--- |
||||
|
||||
### Task 4: Manual test and fix any issues |
||||
|
||||
- [ ] **Step 1: Launch the app** |
||||
|
||||
Run: `cd /Users/laurentmorvillier/code/Music && open -a Xcode Music.xcodeproj` and run from Xcode (Cmd+R), or build and run via command line. |
||||
|
||||
- [ ] **Step 2: Test the happy path** |
||||
|
||||
1. Ensure at least one regular playlist exists |
||||
2. Drag a track from the track table |
||||
3. Hover over a regular playlist chip — verify it highlights |
||||
4. Drop on the chip — verify the track appears in the playlist (click the playlist to check) |
||||
|
||||
- [ ] **Step 3: Test edge cases** |
||||
|
||||
1. Drag a track over a **smart** playlist chip — verify no highlight, drop rejected |
||||
2. Drag a track that's **already** in a playlist onto that playlist — verify silent no-op |
||||
3. Drag a track over the **Home** chip — verify no highlight |
||||
4. After a drag-to-playlist, try **reordering** tracks within a playlist — verify still works |
||||
5. **Drop a file from Finder** onto the app — verify library scan still works |
||||
|
||||
- [ ] **Step 4: Fix any issues found, commit** |
||||
@ -0,0 +1,85 @@ |
||||
# Track Drag-to-Playlist Design |
||||
|
||||
## Goal |
||||
|
||||
Drag a single track from the track table onto a playlist chip in the bottom bar to add that track to the playlist. |
||||
|
||||
## Scope |
||||
|
||||
- Single track only (no multi-select drag) |
||||
- Regular playlists only (smart playlists are read-only) |
||||
- Duplicate adds silently ignored (DB `UNIQUE(playlistId, trackId)` constraint) |
||||
|
||||
## Implementation |
||||
|
||||
### 1. Custom Pasteboard Type |
||||
|
||||
Define a custom `NSPasteboard.PasteboardType` for track IDs to avoid collisions with the existing `.string` type used for row-reorder and the `.fileURL` type used for Finder file drops. |
||||
|
||||
Location: top of `TrackTableView.swift` (alongside existing private constants). |
||||
|
||||
```swift |
||||
private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID") |
||||
``` |
||||
|
||||
### 2. Drag Source — TrackTableView Coordinator |
||||
|
||||
**File:** `TrackTableView.swift` |
||||
|
||||
**Change `pasteboardWriterForRow`:** Currently only writes when `onReorder != nil`. Change to always write the track ID under the custom type. When `onReorder` is also set, additionally write the row index under `.string` (preserving existing reorder behavior). |
||||
|
||||
``` |
||||
func pasteboardWriterForRow(row) -> NSPasteboardWriting? |
||||
let item = NSPasteboardItem() |
||||
// Always write track ID for cross-view drag |
||||
if let trackId = parent.tracks[row].id { |
||||
item.setString(String(trackId), forType: trackIdPasteboardType) |
||||
} |
||||
// Also write row index if reorder is enabled |
||||
if parent.onReorder != nil { |
||||
item.setString(String(row), forType: .string) |
||||
} |
||||
return item |
||||
``` |
||||
|
||||
**Register for drag types:** Add `trackIdPasteboardType` to `registerForDraggedTypes` alongside `.string`. |
||||
|
||||
**`validateDrop` / `acceptDrop`:** Update to read from `.string` type specifically (not just first pasteboard item string), so they continue to work for reorder and ignore the track-ID type. |
||||
|
||||
### 3. Drop Target — PlaylistBarView |
||||
|
||||
**File:** `PlaylistBarView.swift` |
||||
|
||||
**New callback prop:** |
||||
```swift |
||||
var onDropTrack: ((Int64, Playlist) -> Void)? |
||||
``` |
||||
|
||||
**Drop modifier on each regular playlist chip:** Add `.onDrop(of:isTargeted:perform:)` to each `PlaylistButton` for regular playlists only. The `isTargeted` binding drives a visual highlight (accent-colored border/background). |
||||
|
||||
Since SwiftUI's `.onDrop` works with UTTypes, we register the custom type as a UTType and read the track ID from the `NSItemProvider`. |
||||
|
||||
**Visual feedback:** When `isTargeted` is true, show a highlighted border/background on the chip (e.g. `tintColor.opacity(0.3)` background + `tintColor` border at 2pt). |
||||
|
||||
**PlaylistButton changes:** Add an `isDropTarget: Bool` parameter to `PlaylistButton` that controls the highlight styling. Default `false`. |
||||
|
||||
### 4. Wiring — ContentView |
||||
|
||||
**File:** `ContentView.swift` |
||||
|
||||
Pass the `onDropTrack` closure to `PlaylistBarView`. The closure calls `PlaylistViewModel.addTrack(_:to:)`, wrapping in try/catch to silently handle duplicates. |
||||
|
||||
## Files Changed |
||||
|
||||
| File | Change | |
||||
|------|--------| |
||||
| `TrackTableView.swift` | Custom pasteboard type constant; `pasteboardWriterForRow` writes track ID always + row index when reordering; register custom drag type; update `validateDrop`/`acceptDrop` to read `.string` explicitly | |
||||
| `PlaylistBarView.swift` | `onDropTrack` callback; `.onDrop` modifier on regular playlist chips; `isDropTarget` highlight state on `PlaylistButton` | |
||||
| `ContentView.swift` | Wire `onDropTrack` closure through to `PlaylistBarView` | |
||||
|
||||
## Behaviors Preserved |
||||
|
||||
- Right-click "Add to Playlist" context menu unchanged |
||||
- Drag reorder within a playlist unchanged |
||||
- File drops from Finder unchanged |
||||
- Smart playlists don't accept drops |
||||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,272 @@ |
||||
#!/usr/bin/env python3 |
||||
"""One-time backfill of real bitrate onto tracks stored with bitrate 0 or NULL. |
||||
|
||||
ScannerService writes `bitrate = Int(estimatedDataRate / 1000)` at scan time. |
||||
AVFoundation's estimatedDataRate returns 0 for some files (long/VBR MP3s), so a |
||||
literal 0 gets stored; other tracks were imported before bitrate existed and are |
||||
NULL. This script recomputes bitrate for those rows using ffprobe, falling back |
||||
to fileSize*8/duration (the same average the app's importer now uses) when |
||||
ffprobe is unavailable or can't determine a value. |
||||
|
||||
Dry-run by default. Pass --apply to write (a timestamped backup is made first). |
||||
|
||||
Usage: |
||||
python3 backfill_bitrate.py [--db <path>] [--apply] |
||||
python3 backfill_bitrate.py --self-test |
||||
|
||||
Stdlib only; uses ffprobe if present on PATH (optional). |
||||
""" |
||||
|
||||
import argparse |
||||
import os |
||||
import shutil |
||||
import sqlite3 |
||||
import subprocess |
||||
import sys |
||||
import unicodedata |
||||
from datetime import datetime |
||||
from urllib.parse import unquote |
||||
|
||||
# Default DB path for the sandboxed app (bundle id com.staxriver.mu). Computed from |
||||
# $HOME so it resolves to the right user on whichever Mac the script runs on. |
||||
DEFAULT_DB = os.path.expanduser( |
||||
"~/Library/Containers/com.staxriver.mu/Data/Library/" |
||||
"Application Support/Music/db.sqlite" |
||||
) |
||||
|
||||
|
||||
def norm_path(u): |
||||
"""Reduce a file:// URL (or bare path) to a comparable, on-disk POSIX path. |
||||
|
||||
The app stores `fileURL` as Foundation's url.absoluteString (a percent-encoded |
||||
file URL). Decode it, drop the file:// (or file://localhost) prefix, NFC- |
||||
normalize, and strip a trailing slash so it can be stat'd on APFS. |
||||
""" |
||||
s = u |
||||
if s.startswith("file://"): |
||||
s = s[len("file://"):] |
||||
if s.startswith("localhost/"): |
||||
s = s[len("localhost"):] # leaves the leading "/" |
||||
s = unquote(s) |
||||
s = unicodedata.normalize("NFC", s) |
||||
if len(s) > 1 and s.endswith("/"): |
||||
s = s[:-1] |
||||
return s |
||||
|
||||
|
||||
def parse_ffprobe_bitrate(stdout): |
||||
"""Parse ffprobe's bit_rate stdout (bits/sec) into integer kbps, or None. |
||||
|
||||
Returns None for empty output, 'N/A', or any non-integer text so the caller |
||||
falls back to the formula. |
||||
""" |
||||
s = stdout.strip() |
||||
if not s or s == "N/A": |
||||
return None |
||||
try: |
||||
return round(int(s) / 1000) |
||||
except ValueError: |
||||
return None |
||||
|
||||
|
||||
def kbps_from_ffprobe(path): |
||||
"""Return integer kbps from ffprobe's format bit_rate, or None if unavailable. |
||||
|
||||
None on: ffprobe not installed, ffprobe error, or N/A/empty/non-integer output. |
||||
""" |
||||
try: |
||||
out = subprocess.run( |
||||
["ffprobe", "-v", "error", "-show_entries", "format=bit_rate", |
||||
"-of", "default=nw=1:nk=1", path], |
||||
capture_output=True, text=True, timeout=30, |
||||
) |
||||
except (FileNotFoundError, subprocess.SubprocessError): |
||||
return None |
||||
return parse_ffprobe_bitrate(out.stdout) |
||||
|
||||
|
||||
def kbps_from_formula(file_size, duration): |
||||
"""Average kbps from size (bytes) and duration (seconds): size*8/duration/1000. |
||||
|
||||
Returns None when inputs can't yield a meaningful value (missing size, or |
||||
non-positive/missing duration). |
||||
""" |
||||
if file_size is None or file_size <= 0 or duration is None or duration <= 0: |
||||
return None |
||||
return round(file_size * 8 / duration / 1000) |
||||
|
||||
|
||||
def resolve_bitrate(path, duration): |
||||
"""Best available kbps for an on-disk file: ffprobe first, formula fallback. |
||||
|
||||
`duration` is the DB's stored seconds; file size is read from disk. Returns |
||||
None if neither method can produce a positive value. |
||||
""" |
||||
kbps = kbps_from_ffprobe(path) |
||||
if kbps is not None and kbps > 0: |
||||
return kbps |
||||
try: |
||||
size = os.path.getsize(path) |
||||
except OSError: |
||||
size = None |
||||
return kbps_from_formula(size, duration) |
||||
|
||||
|
||||
def ffprobe_available(): |
||||
"""Return True if ffprobe is on PATH.""" |
||||
return shutil.which("ffprobe") is not None |
||||
|
||||
|
||||
def self_test(): |
||||
"""Fast smoke check of the pure helpers (no DB, no ffprobe needed).""" |
||||
# ffprobe stdout parsing |
||||
assert parse_ffprobe_bitrate("256005\n") == 256 |
||||
assert parse_ffprobe_bitrate("N/A") is None |
||||
assert parse_ffprobe_bitrate("") is None |
||||
assert parse_ffprobe_bitrate("garbage") is None |
||||
|
||||
# formula: 230_358_479 bytes over 7198.54 s -> 256 kbps (matches ffprobe sample) |
||||
assert kbps_from_formula(230_358_479, 7198.5371428571425) == 256 |
||||
assert kbps_from_formula(None, 100) is None |
||||
assert kbps_from_formula(1000, 0) is None |
||||
assert kbps_from_formula(1000, None) is None |
||||
|
||||
# path normalization (NFD vs NFC accents, percent-encoding, localhost host) |
||||
nfc = norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3") |
||||
nfd = norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3") |
||||
assert nfc == nfd == "/Users/x/Música/Café.mp3", (nfc, nfd) |
||||
assert norm_path("file:///a/b%20c%23d.mp3") == "/a/b c#d.mp3" |
||||
|
||||
# resolve_bitrate composition: a missing file yields None regardless of whether |
||||
# ffprobe is installed (ffprobe errors on the path -> None; getsize raises |
||||
# OSError -> formula gets size=None -> None). |
||||
assert resolve_bitrate("/nonexistent/file.mp3", 100) is None |
||||
|
||||
print("self-test OK") |
||||
|
||||
|
||||
def fetch_rows(db_path): |
||||
"""Return candidate rows: (id, fileURL, duration, bitrate) where bitrate is 0/NULL.""" |
||||
con = sqlite3.connect(db_path) |
||||
try: |
||||
return con.execute( |
||||
"SELECT id, fileURL, duration, bitrate FROM tracks " |
||||
"WHERE bitrate = 0 OR bitrate IS NULL" |
||||
).fetchall() |
||||
finally: |
||||
con.close() |
||||
|
||||
|
||||
def build_updates(rows): |
||||
"""Resolve a new bitrate for each candidate row. |
||||
|
||||
Returns (updates, missing, undeterminable): |
||||
- updates: list of {id, file_url, old, new} where new is a positive kbps |
||||
- missing: (id, path) for rows whose file is not on disk (left untouched) |
||||
- undeterminable: (id, path) for on-disk files whose bitrate couldn't be found |
||||
""" |
||||
updates, missing, undeterminable = [], [], [] |
||||
for row_id, file_url, duration, old in rows: |
||||
path = norm_path(file_url) |
||||
if not os.path.exists(path): |
||||
missing.append((row_id, path)) |
||||
continue |
||||
new = resolve_bitrate(path, duration) |
||||
if new is None or new <= 0: |
||||
undeterminable.append((row_id, path)) |
||||
continue |
||||
updates.append({"id": row_id, "file_url": file_url, "old": old, "new": new}) |
||||
return updates, missing, undeterminable |
||||
|
||||
|
||||
def backup_db(db_path): |
||||
"""Copy db.sqlite (+ -wal, -shm) under backups/<timestamp>/ next to the DB.""" |
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") |
||||
backup_dir = os.path.join(os.path.dirname(db_path), "backups", stamp) |
||||
os.makedirs(backup_dir, exist_ok=True) |
||||
for suffix in ("", "-wal", "-shm"): |
||||
src = db_path + suffix |
||||
if os.path.exists(src): |
||||
shutil.copy2(src, os.path.join(backup_dir, os.path.basename(src))) |
||||
return backup_dir |
||||
|
||||
|
||||
def apply_updates(db_path, updates): |
||||
"""Write bitrate updates in a single transaction, then checkpoint the WAL.""" |
||||
con = sqlite3.connect(db_path) |
||||
try: |
||||
con.execute("BEGIN") |
||||
con.executemany("UPDATE tracks SET bitrate=:new WHERE id=:id", updates) |
||||
con.commit() |
||||
con.execute("PRAGMA wal_checkpoint(TRUNCATE)") |
||||
finally: |
||||
con.close() |
||||
|
||||
|
||||
def run(db_path, apply): |
||||
rows = fetch_rows(db_path) |
||||
updates, missing, undeterminable = build_updates(rows) |
||||
|
||||
print(f"Candidate rows (bitrate 0 or NULL): {len(rows)}") |
||||
print(f"Resolvable (will set): {len(updates)}") |
||||
print(f"Skipped — file missing on disk: {len(missing)}") |
||||
print(f"Skipped — could not determine: {len(undeterminable)}") |
||||
if not ffprobe_available(): |
||||
print("NOTE: ffprobe not on PATH — used the filesize/duration formula for all rows.") |
||||
print() |
||||
|
||||
for u in updates[:15]: |
||||
name = os.path.basename(norm_path(u["file_url"])) |
||||
old = "NULL" if u["old"] is None else u["old"] |
||||
print(f" • {name}") |
||||
print(f" bitrate {old} -> {u['new']} kbps") |
||||
if len(updates) > 15: |
||||
print(f" ... and {len(updates) - 15} more") |
||||
print() |
||||
|
||||
if missing[:5]: |
||||
print("Sample of skipped (file missing on disk, left untouched):") |
||||
for row_id, path in missing[:5]: |
||||
print(f" - [{row_id}] {os.path.basename(path)}") |
||||
print() |
||||
|
||||
if undeterminable[:5]: |
||||
print("Sample of skipped (could not determine bitrate, left untouched):") |
||||
for row_id, path in undeterminable[:5]: |
||||
print(f" - [{row_id}] {os.path.basename(path)}") |
||||
print() |
||||
|
||||
if not apply: |
||||
print("DRY RUN — nothing written. Re-run with --apply to commit these changes.") |
||||
return |
||||
|
||||
if not updates: |
||||
print("Nothing to apply.") |
||||
return |
||||
|
||||
backup_dir = backup_db(db_path) |
||||
print(f"Backup written to: {backup_dir}") |
||||
apply_updates(db_path, updates) |
||||
print(f"Applied {len(updates)} bitrate updates to {db_path}") |
||||
|
||||
|
||||
def main(argv=None): |
||||
p = argparse.ArgumentParser(description=__doc__) |
||||
p.add_argument("--db", default=DEFAULT_DB, help=f"App DB path (default: {DEFAULT_DB})") |
||||
p.add_argument("--apply", action="store_true", help="Write changes (default: dry run).") |
||||
p.add_argument("--self-test", action="store_true", help="Run the built-in smoke test.") |
||||
args = p.parse_args(argv) |
||||
|
||||
if args.self_test: |
||||
self_test() |
||||
return 0 |
||||
|
||||
if not os.path.exists(args.db): |
||||
p.error(f"DB not found: {args.db}") |
||||
|
||||
run(args.db, args.apply) |
||||
return 0 |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
sys.exit(main()) |
||||
@ -0,0 +1,253 @@ |
||||
#!/usr/bin/env python3 |
||||
"""One-time backfill of Date Added / play stats from Apple Music into the app DB. |
||||
|
||||
ScannerService stamps `dateAdded = Date()` at scan time, so the app DB holds scan |
||||
dates rather than the real "date added" from Apple Music. This script reads the |
||||
ground truth from a Music.app library export (File > Library > Export Library...) |
||||
and overwrites dateAdded, playCount, rating and lastPlayedAt on tracks it can match |
||||
by file path. |
||||
|
||||
Dry-run by default. Pass --apply to write (a timestamped backup is made first). |
||||
|
||||
Usage: |
||||
python3 backfill_itunes_dates.py --xml <Library.xml> [--db <path>] [--apply] |
||||
python3 backfill_itunes_dates.py --self-test |
||||
|
||||
Stdlib only; needs python3 (ships with Xcode Command Line Tools). |
||||
""" |
||||
|
||||
import argparse |
||||
import os |
||||
import plistlib |
||||
import shutil |
||||
import sqlite3 |
||||
import sys |
||||
import unicodedata |
||||
from datetime import datetime |
||||
from urllib.parse import unquote |
||||
|
||||
# Default DB path for the sandboxed app (bundle id com.staxriver.mu). Computed from |
||||
# $HOME so it resolves to the right user on whichever Mac the script runs on. |
||||
DEFAULT_DB = os.path.expanduser( |
||||
"~/Library/Containers/com.staxriver.mu/Data/Library/" |
||||
"Application Support/Music/db.sqlite" |
||||
) |
||||
|
||||
|
||||
def norm_path(u): |
||||
"""Reduce a file:// URL (or bare path) to a comparable POSIX path. |
||||
|
||||
Both the app's stored `fileURL` (Foundation's url.absoluteString) and Music.app's |
||||
`Location` are percent-encoded file URLs, but they can differ in host form |
||||
(file:/// vs file://localhost/) and Unicode normalization (APFS keeps filenames |
||||
in one form, the URL encoders may emit another). Normalizing both to a decoded, |
||||
NFC, trailing-slash-free path makes accented filenames compare equal. |
||||
""" |
||||
s = u |
||||
if s.startswith("file://"): |
||||
s = s[len("file://"):] |
||||
if s.startswith("localhost/"): |
||||
s = s[len("localhost"):] # leaves the leading "/" |
||||
s = unquote(s) |
||||
s = unicodedata.normalize("NFC", s) |
||||
if len(s) > 1 and s.endswith("/"): |
||||
s = s[:-1] |
||||
return s |
||||
|
||||
|
||||
def fmt_dt(dt): |
||||
"""Format a datetime as GRDB's .datetime string (UTC), or None. |
||||
|
||||
plistlib parses <date> values into naive datetimes already expressed in UTC, |
||||
which is exactly what GRDB stores (e.g. '2026-05-24 06:46:01.713'). We emit |
||||
millisecond precision (.000) to match the column's existing shape; GRDB reads |
||||
both with and without millis, so this round-trips. |
||||
""" |
||||
if dt is None: |
||||
return None |
||||
return dt.strftime("%Y-%m-%d %H:%M:%S") + ".000" |
||||
|
||||
|
||||
def parse_library(xml_path): |
||||
"""Parse a Music.app library export into {norm_path: fields}. |
||||
|
||||
Only tracks with a Location (i.e. real local files) are included; Apple Music |
||||
streaming entries have no Location and are skipped. |
||||
""" |
||||
with open(xml_path, "rb") as fp: |
||||
plist = plistlib.load(fp) |
||||
|
||||
music = {} |
||||
for track in plist.get("Tracks", {}).values(): |
||||
location = track.get("Location") |
||||
if not location: |
||||
continue |
||||
music[norm_path(location)] = { |
||||
"date_added": track.get("Date Added"), |
||||
"play_count": track.get("Play Count"), |
||||
"rating": track.get("Rating"), |
||||
"play_date_utc": track.get("Play Date UTC"), |
||||
} |
||||
return music |
||||
|
||||
|
||||
def build_updates(db_rows, music_map): |
||||
"""Compute UPDATE tuples for matched tracks (blunt overwrite, Music is truth). |
||||
|
||||
db_rows: iterable of (id, fileURL, current_dateAdded). |
||||
Returns (updates, unmatched) where: |
||||
- updates is a list of dicts: id, file_url, old_date, dateAdded, playCount, |
||||
rating, lastPlayedAt |
||||
- unmatched is a list of (id, fileURL) present in the DB but not the export. |
||||
""" |
||||
updates = [] |
||||
unmatched = [] |
||||
for row_id, file_url, current_date in db_rows: |
||||
m = music_map.get(norm_path(file_url)) |
||||
if m is None: |
||||
unmatched.append((row_id, file_url)) |
||||
continue |
||||
# dateAdded is NOT NULL: if the export somehow lacks it, keep what's there. |
||||
new_date = fmt_dt(m["date_added"]) if m["date_added"] else current_date |
||||
rating = min(5, int(m["rating"] or 0) // 20) # Music 0-100 -> 0-5 stars |
||||
updates.append({ |
||||
"id": row_id, |
||||
"file_url": file_url, |
||||
"old_date": current_date, |
||||
"dateAdded": new_date, |
||||
"playCount": int(m["play_count"] or 0), |
||||
"rating": rating, |
||||
"lastPlayedAt": fmt_dt(m["play_date_utc"]), |
||||
}) |
||||
return updates, unmatched |
||||
|
||||
|
||||
def backup_db(db_path): |
||||
"""Copy db.sqlite (+ -wal, -shm) next to it under backups/<timestamp>/.""" |
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") |
||||
backup_dir = os.path.join(os.path.dirname(db_path), "backups", stamp) |
||||
os.makedirs(backup_dir, exist_ok=True) |
||||
for suffix in ("", "-wal", "-shm"): |
||||
src = db_path + suffix |
||||
if os.path.exists(src): |
||||
shutil.copy2(src, os.path.join(backup_dir, os.path.basename(src))) |
||||
return backup_dir |
||||
|
||||
|
||||
def apply_updates(db_path, updates): |
||||
"""Write updates in a single transaction, then checkpoint the WAL.""" |
||||
con = sqlite3.connect(db_path) |
||||
try: |
||||
con.execute("BEGIN") |
||||
con.executemany( |
||||
"UPDATE tracks SET dateAdded=:dateAdded, playCount=:playCount, " |
||||
"rating=:rating, lastPlayedAt=:lastPlayedAt WHERE id=:id", |
||||
updates, |
||||
) |
||||
con.commit() |
||||
con.execute("PRAGMA wal_checkpoint(TRUNCATE)") |
||||
finally: |
||||
con.close() |
||||
|
||||
|
||||
def fetch_db_rows(db_path): |
||||
con = sqlite3.connect(db_path) |
||||
try: |
||||
return con.execute("SELECT id, fileURL, dateAdded FROM tracks").fetchall() |
||||
finally: |
||||
con.close() |
||||
|
||||
|
||||
def run(xml_path, db_path, apply): |
||||
music_map = parse_library(xml_path) |
||||
db_rows = fetch_db_rows(db_path) |
||||
updates, unmatched = build_updates(db_rows, music_map) |
||||
|
||||
matched_paths = {norm_path(u["file_url"]) for u in updates} |
||||
unmatched_xml = [p for p in music_map if p not in matched_paths] |
||||
|
||||
print(f"DB tracks: {len(db_rows)}") |
||||
print(f"Local files in XML: {len(music_map)}") |
||||
print(f"Matched (will set): {len(updates)}") |
||||
print(f"In DB, not in XML: {len(unmatched)}") |
||||
print(f"In XML, not in DB: {len(unmatched_xml)}") |
||||
print() |
||||
|
||||
for u in updates[:10]: |
||||
name = os.path.basename(norm_path(u["file_url"])) |
||||
print(f" • {name}") |
||||
print(f" dateAdded {u['old_date']} -> {u['dateAdded']}") |
||||
print(f" playCount={u['playCount']} rating={u['rating']} " |
||||
f"lastPlayedAt={u['lastPlayedAt']}") |
||||
if len(updates) > 10: |
||||
print(f" ... and {len(updates) - 10} more") |
||||
print() |
||||
|
||||
if unmatched[:5]: |
||||
print("Sample of DB tracks with no XML match (left untouched):") |
||||
for row_id, file_url in unmatched[:5]: |
||||
print(f" - [{row_id}] {os.path.basename(norm_path(file_url))}") |
||||
print() |
||||
|
||||
if not apply: |
||||
print("DRY RUN — nothing written. Re-run with --apply to commit these changes.") |
||||
return |
||||
|
||||
if not updates: |
||||
print("Nothing to apply.") |
||||
return |
||||
|
||||
backup_dir = backup_db(db_path) |
||||
print(f"Backup written to: {backup_dir}") |
||||
apply_updates(db_path, updates) |
||||
print(f"Applied {len(updates)} updates to {db_path}") |
||||
|
||||
|
||||
def self_test(): |
||||
"""Fast smoke check of the matching + formatting core.""" |
||||
nfc = norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3") # NFD source |
||||
nfd = norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3") # NFC source |
||||
assert nfc == nfd == "/Users/x/Música/Café.mp3", (nfc, nfd) |
||||
assert norm_path("file:///a/b%20c%23d.mp3") == "/a/b c#d.mp3" |
||||
|
||||
music = {"/a/song.mp3": { |
||||
"date_added": datetime(2021, 3, 14, 9, 26, 53), |
||||
"play_count": 7, "rating": 80, |
||||
"play_date_utc": datetime(2024, 1, 2, 3, 4, 5), |
||||
}} |
||||
rows = [(1, "file:///a/song.mp3", "2026-05-24 06:46:01.713"), |
||||
(2, "file:///a/missing.mp3", "2026-05-24 06:46:01.999")] |
||||
updates, unmatched = build_updates(rows, music) |
||||
assert len(updates) == 1 and len(unmatched) == 1 |
||||
u = updates[0] |
||||
assert u["dateAdded"] == "2021-03-14 09:26:53.000", u["dateAdded"] |
||||
assert u["playCount"] == 7 and u["rating"] == 4 |
||||
assert u["lastPlayedAt"] == "2024-01-02 03:04:05.000" |
||||
print("self-test OK") |
||||
|
||||
|
||||
def main(argv=None): |
||||
p = argparse.ArgumentParser(description=__doc__) |
||||
p.add_argument("--xml", help="Path to Music.app Library export (XML plist).") |
||||
p.add_argument("--db", default=DEFAULT_DB, help=f"App DB path (default: {DEFAULT_DB})") |
||||
p.add_argument("--apply", action="store_true", help="Write changes (default: dry run).") |
||||
p.add_argument("--self-test", action="store_true", help="Run the built-in smoke test.") |
||||
args = p.parse_args(argv) |
||||
|
||||
if args.self_test: |
||||
self_test() |
||||
return 0 |
||||
|
||||
if not args.xml: |
||||
p.error("--xml is required (export it via Music.app: File > Library > Export Library...)") |
||||
if not os.path.exists(args.xml): |
||||
p.error(f"XML not found: {args.xml}") |
||||
if not os.path.exists(args.db): |
||||
p.error(f"DB not found: {args.db}") |
||||
|
||||
run(args.xml, args.db, args.apply) |
||||
return 0 |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
sys.exit(main()) |
||||
@ -0,0 +1,178 @@ |
||||
#!/usr/bin/env python3 |
||||
"""Tests for backfill_itunes_dates. Run: python3 -m unittest test_backfill_itunes_dates""" |
||||
|
||||
import io |
||||
import os |
||||
import plistlib |
||||
import sqlite3 |
||||
import sys |
||||
import tempfile |
||||
import unittest |
||||
from contextlib import redirect_stdout |
||||
from datetime import datetime |
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
||||
import backfill_itunes_dates as bf # noqa: E402 |
||||
|
||||
# The three real file paths from the dev DB. The Kevin track exercises spaces (%20), |
||||
# a literal apostrophe, and parentheses; the others share a directory. |
||||
KEVIN = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/" |
||||
"Kevin%20Saunderson%20as%20E-Dancer/GlobalDJMix.com/" |
||||
"Radio%201's%20Essential%20Mix%20(2025-02-08).mp3") |
||||
T1130 = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/" |
||||
"Unknown%20Artist/Unknown%20Album/1130.mp3") |
||||
T1486 = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/" |
||||
"Unknown%20Artist/Unknown%20Album/1486.mp3") |
||||
|
||||
|
||||
class NormPathTests(unittest.TestCase): |
||||
# Step: an NFD-encoded source and an NFC-encoded source for the same accented |
||||
# filename must normalize to the identical path, regardless of file:// host form. |
||||
def test_nfc_nfd_and_host_form_converge(self): |
||||
nfd = bf.norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3") |
||||
nfc = bf.norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3") |
||||
self.assertEqual(nfd, "/Users/x/Música/Café.mp3") |
||||
self.assertEqual(nfd, nfc) |
||||
|
||||
# Step: percent-encoded space and hash decode to literal characters. |
||||
def test_special_chars_decoded(self): |
||||
self.assertEqual(bf.norm_path("file:///a/b%20c%23d.mp3"), "/a/b c#d.mp3") |
||||
|
||||
# Step: a trailing slash is stripped so dir-ish strings compare cleanly. |
||||
def test_trailing_slash_stripped(self): |
||||
self.assertEqual(bf.norm_path("file:///a/b/"), "/a/b") |
||||
|
||||
# Step: the real Kevin path with apostrophe + parens round-trips to a clean path. |
||||
def test_real_kevin_path(self): |
||||
self.assertTrue( |
||||
bf.norm_path(KEVIN).endswith("/Radio 1's Essential Mix (2025-02-08).mp3")) |
||||
|
||||
|
||||
class BuildUpdatesTests(unittest.TestCase): |
||||
# Step: a matched track gets dateAdded reformatted to GRDB shape, rating mapped |
||||
# 0-100 -> 0-5, playCount copied, and lastPlayedAt formatted; an unmatched DB |
||||
# track is reported separately and produces no update. |
||||
def test_mapping_and_unmatched(self): |
||||
music = {bf.norm_path("file:///a/song.mp3"): { |
||||
"date_added": datetime(2021, 3, 14, 9, 26, 53), |
||||
"play_count": 7, "rating": 80, |
||||
"play_date_utc": datetime(2024, 1, 2, 3, 4, 5), |
||||
}} |
||||
rows = [(1, "file:///a/song.mp3", "2026-05-24 06:46:01.713"), |
||||
(2, "file:///a/missing.mp3", "2026-05-24 06:46:01.999")] |
||||
updates, unmatched = bf.build_updates(rows, music) |
||||
self.assertEqual([u["id"] for u in updates], [1]) |
||||
self.assertEqual([r[0] for r in unmatched], [2]) |
||||
u = updates[0] |
||||
self.assertEqual(u["dateAdded"], "2021-03-14 09:26:53.000") |
||||
self.assertEqual(u["playCount"], 7) |
||||
self.assertEqual(u["rating"], 4) |
||||
self.assertEqual(u["lastPlayedAt"], "2024-01-02 03:04:05.000") |
||||
|
||||
# Step: missing optional fields default safely — playCount 0, rating 0, |
||||
# lastPlayedAt None — while dateAdded still applies. |
||||
def test_absent_optionals(self): |
||||
music = {bf.norm_path("file:///a/x.mp3"): { |
||||
"date_added": datetime(2020, 1, 1, 0, 0, 0), |
||||
"play_count": None, "rating": None, "play_date_utc": None, |
||||
}} |
||||
updates, _ = bf.build_updates([(1, "file:///a/x.mp3", "old")], music) |
||||
u = updates[0] |
||||
self.assertEqual(u["playCount"], 0) |
||||
self.assertEqual(u["rating"], 0) |
||||
self.assertIsNone(u["lastPlayedAt"]) |
||||
|
||||
# Step: if the export lacks Date Added, keep the existing value (column is NOT NULL). |
||||
def test_missing_date_keeps_existing(self): |
||||
music = {bf.norm_path("file:///a/x.mp3"): { |
||||
"date_added": None, "play_count": 1, "rating": 0, "play_date_utc": None}} |
||||
updates, _ = bf.build_updates([(1, "file:///a/x.mp3", "2026-05-24 06:46:01.713")], music) |
||||
self.assertEqual(updates[0]["dateAdded"], "2026-05-24 06:46:01.713") |
||||
|
||||
|
||||
class IntegrationTest(unittest.TestCase): |
||||
def setUp(self): |
||||
self.tmp = tempfile.mkdtemp() |
||||
self.db = os.path.join(self.tmp, "db.sqlite") |
||||
self.xml = os.path.join(self.tmp, "Library.xml") |
||||
|
||||
# Step: build a temp DB mirroring the real tracks columns the script touches, |
||||
# seeded with the three real paths carrying placeholder scan dates. |
||||
con = sqlite3.connect(self.db) |
||||
con.execute( |
||||
"CREATE TABLE tracks (" |
||||
" id INTEGER PRIMARY KEY," |
||||
" fileURL TEXT NOT NULL," |
||||
" dateAdded TEXT NOT NULL," |
||||
" playCount INTEGER NOT NULL DEFAULT 0," |
||||
" rating INTEGER NOT NULL DEFAULT 0," |
||||
" lastPlayedAt TEXT)") |
||||
con.executemany( |
||||
"INSERT INTO tracks (id, fileURL, dateAdded, playCount, rating) VALUES (?,?,?,0,0)", |
||||
[(1, KEVIN, "2026-05-24 06:46:01.713"), |
||||
(2, T1130, "2026-05-24 06:46:01.715"), |
||||
(3, T1486, "2026-05-24 06:46:01.718")]) |
||||
con.commit() |
||||
con.close() |
||||
|
||||
# Step: write a synthetic library export. Kevin is matched via the localhost |
||||
# host form (proving normalization), 1130 matched with no optional stats, 1486 |
||||
# deliberately omitted (stays unmatched), plus a streaming entry with no |
||||
# Location (must be skipped) and an XML-only local file (unmatched-in-DB). |
||||
kevin_localhost = KEVIN.replace("file:///", "file://localhost/") |
||||
plist = {"Tracks": { |
||||
"10": {"Location": kevin_localhost, |
||||
"Date Added": datetime(2025, 2, 9, 12, 0, 0), |
||||
"Play Count": 5, "Rating": 100, |
||||
"Play Date UTC": datetime(2025, 3, 1, 8, 0, 0)}, |
||||
"11": {"Location": T1130, |
||||
"Date Added": datetime(2024, 11, 30, 10, 0, 0)}, |
||||
"12": {"Date Added": datetime(2023, 1, 1, 0, 0, 0)}, # streaming, no Location |
||||
"13": {"Location": "file:///Users/x/only-in-xml.mp3", |
||||
"Date Added": datetime(2022, 6, 6, 6, 6, 6)}, |
||||
}} |
||||
with open(self.xml, "wb") as fp: |
||||
plistlib.dump(plist, fp) |
||||
|
||||
def tearDown(self): |
||||
import shutil |
||||
shutil.rmtree(self.tmp, ignore_errors=True) |
||||
|
||||
# Step: a full --apply run rewrites the two matched rows with Music.app's values |
||||
# (Kevin: 5 plays / 5 stars / real dates; 1130: date only), leaves the unmatched |
||||
# 1486 row untouched, and ignores the streaming + xml-only entries. |
||||
def test_apply_end_to_end(self): |
||||
with redirect_stdout(io.StringIO()): |
||||
bf.run(self.xml, self.db, apply=True) |
||||
|
||||
con = sqlite3.connect(self.db) |
||||
rows = {r[0]: r for r in con.execute( |
||||
"SELECT id, dateAdded, playCount, rating, lastPlayedAt FROM tracks")} |
||||
con.close() |
||||
|
||||
self.assertEqual(rows[1], (1, "2025-02-09 12:00:00.000", 5, 5, "2025-03-01 08:00:00.000")) |
||||
self.assertEqual(rows[2], (2, "2024-11-30 10:00:00.000", 0, 0, None)) |
||||
self.assertEqual(rows[3], (3, "2026-05-24 06:46:01.718", 0, 0, None)) # untouched |
||||
|
||||
# Step: a backup directory containing the db copy is created on apply. |
||||
def test_apply_makes_backup(self): |
||||
with redirect_stdout(io.StringIO()): |
||||
bf.run(self.xml, self.db, apply=True) |
||||
backups_root = os.path.join(self.tmp, "backups") |
||||
self.assertTrue(os.path.isdir(backups_root)) |
||||
stamps = os.listdir(backups_root) |
||||
self.assertEqual(len(stamps), 1) |
||||
self.assertIn("db.sqlite", os.listdir(os.path.join(backups_root, stamps[0]))) |
||||
|
||||
# Step: a dry run reports matches but writes nothing to the DB. |
||||
def test_dry_run_writes_nothing(self): |
||||
with redirect_stdout(io.StringIO()): |
||||
bf.run(self.xml, self.db, apply=False) |
||||
con = sqlite3.connect(self.db) |
||||
unchanged = con.execute("SELECT dateAdded FROM tracks WHERE id=1").fetchone()[0] |
||||
con.close() |
||||
self.assertEqual(unchanged, "2026-05-24 06:46:01.713") |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
unittest.main() |
||||
Loading…
Reference in new issue