Compare commits
2 commits
main
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
| c6451b440b | |||
| 3385b88270 |
18 changed files with 2071 additions and 143 deletions
|
|
@ -329,6 +329,34 @@ class SubsonicClient: ObservableObject {
|
|||
return body.internetRadioStations?.internetRadioStation ?? []
|
||||
}
|
||||
|
||||
// MARK: - Play Queue (cross-device sync)
|
||||
|
||||
/// Save the current play queue to the server for cross-device resume.
|
||||
func savePlayQueue(songIds: [String], current: String? = nil, position: Int64 = 0) async throws {
|
||||
var params: [URLQueryItem] = songIds.map { URLQueryItem(name: "id", value: $0) }
|
||||
if let c = current { params.append(URLQueryItem(name: "current", value: c)) }
|
||||
params.append(URLQueryItem(name: "position", value: "\(position)"))
|
||||
_ = try await requestBody(endpoint: "savePlayQueue", params: params)
|
||||
}
|
||||
|
||||
/// Get the saved play queue from the server (saved by any device).
|
||||
func getPlayQueue() async throws -> PlayQueue? {
|
||||
let body = try await requestBody(endpoint: "getPlayQueue")
|
||||
return body.playQueue
|
||||
}
|
||||
|
||||
// MARK: - Shares (public links)
|
||||
|
||||
/// Create a sharing link for a song, album, or playlist.
|
||||
/// Returns the created share, or nil if the server doesn't support sharing.
|
||||
func createShare(id: String, description: String? = nil, expires: Int64? = nil) async throws -> ShareItem? {
|
||||
var params = [URLQueryItem(name: "id", value: id)]
|
||||
if let d = description { params.append(URLQueryItem(name: "description", value: d)) }
|
||||
if let e = expires { params.append(URLQueryItem(name: "expires", value: "\(e)")) }
|
||||
let body = try await requestBody(endpoint: "createShare", params: params)
|
||||
return body.shares?.share?.first
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - API Errors
|
||||
|
|
|
|||
|
|
@ -152,11 +152,13 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
// Stop all visualizer timers when backgrounded — they serve no purpose without
|
||||
// a visible Canvas and burn CPU (causing XPC_EXIT_REASON_FAULT at ~169% CPU).
|
||||
NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.suspendVisTimers() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Restart the correct timer when returning to foreground
|
||||
NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.resumeVisTimers() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
|
@ -165,12 +167,14 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
// other apps taking the audio session. Without this, playback stops but
|
||||
// isPlaying stays true — timers keep firing and music never auto-resumes.
|
||||
NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in self?.handleAudioInterruption(notification) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
// ── Audio route change (headphones unplugged etc.) ────────────────────
|
||||
// Apple HIG: pause on oldDeviceUnavailable (headphones pulled out).
|
||||
NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] notification in self?.handleRouteChange(notification) }
|
||||
.store(in: &cancellables)
|
||||
#endif
|
||||
|
|
@ -238,6 +242,12 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
analysisTask = nil
|
||||
AudioPreFetcher.shared.cancelAll()
|
||||
removeTimeObserver()
|
||||
#if os(iOS)
|
||||
// Remove audio tap on background — no point running FFT when no one can see it
|
||||
if isRadioStream {
|
||||
AudioTapProcessor.shared.removeTap(from: playerItem)
|
||||
}
|
||||
#endif
|
||||
// SmartCrossfadeManager has its own 10Hz time observer that writes
|
||||
// @Published currentTime/duration — same SwiftUI churn as AudioPlayer's
|
||||
if isUsingCrossfade {
|
||||
|
|
@ -296,6 +306,11 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
if isUsingOfflineVis {
|
||||
startOfflineVisSync()
|
||||
} else if isRadioStream {
|
||||
#if os(iOS)
|
||||
// Reinstall the audio tap that was removed on background
|
||||
installAudioTapIfNeeded(on: playerItem)
|
||||
#endif
|
||||
} else if !isUsingCrossfade && !VisualizerSettings.shared.realAudioAnalysis {
|
||||
startLevelSimulation()
|
||||
}
|
||||
|
|
@ -405,6 +420,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
radioStreamURL = nil
|
||||
#if os(iOS)
|
||||
RadioStreamBuffer.shared.stopBuffering()
|
||||
AudioTapProcessor.shared.removeTap(from: playerItem)
|
||||
isPlayingFromBuffer = false
|
||||
#endif
|
||||
}
|
||||
|
|
@ -669,6 +685,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
let asset = AVURLAsset(url: snapURL)
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
playerItem = item // Keep self.playerItem in sync — taps and Shazam depend on this
|
||||
|
||||
// Auto-return to live when snapshot playback reaches the end
|
||||
snapshotEndObserver = NotificationCenter.default.addObserver(
|
||||
|
|
@ -679,6 +696,9 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
player?.replaceCurrentItem(with: item)
|
||||
#if os(iOS)
|
||||
installAudioTapIfNeeded(on: item)
|
||||
#endif
|
||||
|
||||
// KVO on status — more reliable than polling; fires immediately on failure too
|
||||
snapshotStatusObservation = item.observe(\.status, options: [.new, .initial]) { [weak self] observedItem, _ in
|
||||
|
|
@ -727,8 +747,24 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
let asset = AVURLAsset(url: liveURL)
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
playerItem = item // Keep self.playerItem in sync — taps and Shazam depend on this
|
||||
player?.replaceCurrentItem(with: item)
|
||||
player?.play()
|
||||
#if os(iOS)
|
||||
// Start simulation immediately — tap installs when stream reaches readyToPlay
|
||||
startRadioSimulation()
|
||||
let liveItem = item
|
||||
Task { @MainActor [weak self] in
|
||||
for await status in liveItem.publisher(for: \.status).values {
|
||||
guard let self, self.playerItem === liveItem else { break }
|
||||
if status == .readyToPlay {
|
||||
self.installAudioTapIfNeeded(on: liveItem)
|
||||
break
|
||||
}
|
||||
if status == .failed { break }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
alog("Radio: ▶ live")
|
||||
}
|
||||
|
||||
|
|
@ -802,6 +838,7 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
// errors that AVPlayer swallows silently otherwise
|
||||
#if os(iOS)
|
||||
let itemToObserve = playerItem!
|
||||
let isRadio = isRadioStream
|
||||
Task { @MainActor [weak self] in
|
||||
for await status in itemToObserve.publisher(for: \.status).values {
|
||||
guard let self = self, self.playerItem === itemToObserve else { break }
|
||||
|
|
@ -813,6 +850,12 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
category: "Audio", level: .error)
|
||||
case .readyToPlay:
|
||||
DebugLogger.shared.log("✓ Ready: \(url.lastPathComponent)", category: "Audio")
|
||||
// Install audio tap AFTER the stream has connected and the asset
|
||||
// has parsed its container. Before readyToPlay, loadTracks returns
|
||||
// empty for live streams → tap silently fails.
|
||||
if isRadio {
|
||||
self.installAudioTapIfNeeded(on: itemToObserve)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
@ -871,9 +914,9 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
|
||||
updateNowPlayingInfo()
|
||||
fetchAndSetArtwork(coverArtId: currentSong?.coverArt)
|
||||
// Radio streams use a smooth sinusoidal simulation — random phase
|
||||
// simulation reacts to currentTime jumps (buffer seeks) causing
|
||||
// the peakFollower to spike and "raise" the wave unexpectedly.
|
||||
// Radio streams: start simulation immediately for visual feedback while
|
||||
// the stream connects. The readyToPlay status observer (above) will install
|
||||
// the real FFT audio tap once the asset has loaded its tracks.
|
||||
#if os(iOS)
|
||||
if isRadioStream {
|
||||
startRadioSimulation()
|
||||
|
|
@ -1733,6 +1776,57 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Real-time FFT via Audio Tap (radio streams)
|
||||
|
||||
#if os(iOS)
|
||||
/// Install the shared audio tap on a player item for real-time FFT visualization.
|
||||
/// Async because `loadTracks(withMediaType:)` is the modern non-deprecated API.
|
||||
/// Falls back to sinusoidal simulation if tap installation fails.
|
||||
func installAudioTapIfNeeded(on item: AVPlayerItem?) {
|
||||
guard let item else {
|
||||
startRadioSimulation()
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
let success = await AudioTapProcessor.shared.installTap(on: item)
|
||||
if success {
|
||||
startRadioFFT()
|
||||
} else {
|
||||
alog("Audio tap failed — falling back to simulation")
|
||||
startRadioSimulation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Timer that reads real PCM samples from the audio tap ring buffer,
|
||||
/// runs vDSP FFT, and feeds 30 frequency bands to the visualizer.
|
||||
/// Replaces startRadioSimulation() when the tap is active.
|
||||
private func startRadioFFT() {
|
||||
stopLevelTimer()
|
||||
alog("Radio FFT: ▶ real-time analysis active")
|
||||
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
guard VisualizerSettings.shared.enabled else { return }
|
||||
guard self.isPlaying else {
|
||||
if self.internalLevels.contains(where: { $0 > 0.01 }) {
|
||||
for i in 0..<self.internalLevels.count { self.internalLevels[i] = 0 }
|
||||
self.setLevels(self.internalLevels)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let bands = AudioTapProcessor.shared.computeFFTBands(bandCount: self.internalLevels.count)
|
||||
|
||||
// Apply viscosity smoothing (same physics as offline vis path)
|
||||
let viscosity = Float(VisualizerSettings.shared.viscosity)
|
||||
for i in 0..<self.internalLevels.count {
|
||||
self.internalLevels[i] = self.internalLevels[i] * viscosity + bands[i] * (1 - viscosity)
|
||||
}
|
||||
self.setLevels(self.internalLevels)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Simulated Level Animation (for streams)
|
||||
|
||||
/// Radio-specific simulation: smooth sinusoidal wave that never spikes.
|
||||
|
|
@ -1955,6 +2049,11 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
private func stopAVPlayer() {
|
||||
#if os(iOS)
|
||||
// Clean up audio tap before nilling the playerItem — prevents stale
|
||||
// sourceFormat from confusing Shazam into skipping tap installation
|
||||
AudioTapProcessor.shared.removeTap(from: playerItem)
|
||||
#endif
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
if let observer = timeObserver {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ struct SubsonicResponseBody: Codable {
|
|||
let lyrics: LyricsResult?
|
||||
let internetRadioStations: InternetRadioContainer?
|
||||
let similarSongs2: SimilarSongsContainer?
|
||||
let playQueue: PlayQueue?
|
||||
let shares: SharesContainer?
|
||||
}
|
||||
|
||||
struct SubsonicError: Codable {
|
||||
|
|
@ -320,6 +322,35 @@ struct SimilarSongsContainer: Codable {
|
|||
let song: [Song]?
|
||||
}
|
||||
|
||||
// MARK: - Play Queue (cross-device resume)
|
||||
struct PlayQueueContainer: Codable {
|
||||
let playQueue: PlayQueue?
|
||||
}
|
||||
struct PlayQueue: Codable {
|
||||
let entry: [Song]?
|
||||
let current: String? // song ID of current track
|
||||
let position: Int64? // position in ms
|
||||
let changed: String? // ISO timestamp of last save
|
||||
let changedBy: String? // client that saved
|
||||
let username: String?
|
||||
}
|
||||
|
||||
// MARK: - Shares (public links)
|
||||
struct SharesContainer: Codable {
|
||||
let share: [ShareItem]?
|
||||
}
|
||||
struct ShareItem: Codable, Identifiable {
|
||||
let id: String
|
||||
let url: String?
|
||||
let description: String?
|
||||
let username: String?
|
||||
let created: String?
|
||||
let expires: String?
|
||||
let lastVisited: String?
|
||||
let visitCount: Int?
|
||||
let entry: [Song]?
|
||||
}
|
||||
|
||||
// MARK: - Companion Library Models
|
||||
// These map the Companion API /library/* responses.
|
||||
// They convert to standard Song/Album objects using navidrome_id for stream URLs.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ Endpoints (existing - unchanged):
|
|||
POST /reindex
|
||||
PATCH /edit-metadata
|
||||
PATCH /batch-edit-metadata
|
||||
POST /undo-batch-edit/{batch_id}
|
||||
POST /restore-tags?relative_path=...
|
||||
GET /batch-edit-history
|
||||
POST /upload-track
|
||||
POST /upload-tracks
|
||||
GET /smart-dj/profile
|
||||
|
|
@ -58,6 +61,7 @@ MUSIC_DIR = os.getenv("MUSIC_DIR", "/music")
|
|||
DB_PATH = os.getenv("DB_PATH", "/app/data/smart_dj.db")
|
||||
VIS_CACHE_DIR = os.getenv("VIS_CACHE_DIR", "/app/data/vis_cache")
|
||||
COVER_ART_DIR = os.getenv("COVER_ART_DIR", "/app/data/cover_art")
|
||||
TAG_BACKUP_DIR = os.getenv("TAG_BACKUP_DIR", "/app/data/tag_backups")
|
||||
ARTIST_PHOTO_DIR = os.getenv("ARTIST_PHOTO_DIR", "/app/data/artist_photos")
|
||||
NAVIDROME_URL = os.getenv("NAVIDROME_URL", "http://navidrome:4533/navidrome")
|
||||
SUBSONIC_USER = os.getenv("SUBSONIC_USER")
|
||||
|
|
@ -1180,6 +1184,294 @@ async def sync_navidrome_ids_task():
|
|||
# cause album splitting, wrong grouping, and other library issues.
|
||||
# Using a whitelist (keep only these) is safer than a blacklist (remove known bad ones)
|
||||
# because it handles any future Picard tags automatically.
|
||||
# ── Tag Backup System ────────────────────────────────────────────────────────
|
||||
# Snapshots all tags before any destructive write. Enables per-file and
|
||||
# batch-level undo. Backups are JSON files keyed by MD5 of the file path.
|
||||
# Batch edits also create a manifest linking all affected files for bulk undo.
|
||||
|
||||
os.makedirs(TAG_BACKUP_DIR, exist_ok=True)
|
||||
|
||||
def _backup_key(full_path: str) -> str:
|
||||
return hashlib.md5(full_path.encode()).hexdigest()
|
||||
|
||||
def _backup_key_rel(relative_path: str) -> str:
|
||||
"""Secondary key based on relative path — survives full_path changes from restructure."""
|
||||
return "rel_" + hashlib.md5(relative_path.encode()).hexdigest()
|
||||
|
||||
def backup_tags(full_path: str) -> Optional[str]:
|
||||
"""Snapshot all current tags to a JSON file before any write.
|
||||
|
||||
Creates TWO backup files with different keys:
|
||||
1. Keyed by full_path (fast lookup when path hasn't changed)
|
||||
2. Keyed by relative_path (fallback after restructure moves the file)
|
||||
|
||||
Both files contain identical data. This ensures undo works even after
|
||||
/bulk-fix restructures files to new directories.
|
||||
|
||||
Handles AIFF files specially (easy=True returns None for AIFF).
|
||||
Returns the primary backup file path, or None on failure.
|
||||
"""
|
||||
try:
|
||||
ext = Path(full_path).suffix.lower()
|
||||
|
||||
if ext in ('.aiff', '.aif'):
|
||||
tags = _read_aiff_tags_for_backup(full_path)
|
||||
if tags is None:
|
||||
return None
|
||||
else:
|
||||
audio = MutagenFile(full_path, easy=True)
|
||||
if audio is None:
|
||||
return None
|
||||
tags = {k: v for k, v in audio.items()}
|
||||
|
||||
relative = os.path.relpath(full_path, MUSIC_DIR)
|
||||
backup = {
|
||||
"path": full_path,
|
||||
"relative_path": relative,
|
||||
"filename": os.path.basename(full_path),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
data = json.dumps(backup, indent=2)
|
||||
|
||||
# Primary key: full absolute path
|
||||
primary_path = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(full_path)}.json")
|
||||
with open(primary_path, 'w') as f:
|
||||
f.write(data)
|
||||
|
||||
# Secondary key: relative path (survives restructure)
|
||||
secondary_path = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(relative)}.json")
|
||||
with open(secondary_path, 'w') as f:
|
||||
f.write(data)
|
||||
|
||||
return primary_path
|
||||
except Exception as e:
|
||||
print(f" backup_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def _read_aiff_tags_for_backup(full_path: str) -> Optional[dict]:
|
||||
"""Read AIFF tags via raw ID3 frames and return as easy-mode-style dict.
|
||||
Returns None if the file can't be read."""
|
||||
try:
|
||||
from mutagen.aiff import AIFF
|
||||
audio = AIFF(full_path)
|
||||
if audio.tags is None:
|
||||
return {}
|
||||
|
||||
# Map raw ID3 frames to easy-mode key names
|
||||
frame_to_easy = {
|
||||
'TIT2': 'title',
|
||||
'TPE1': 'artist',
|
||||
'TALB': 'album',
|
||||
'TPE2': 'albumartist',
|
||||
'TRCK': 'tracknumber',
|
||||
'TPOS': 'discnumber',
|
||||
'TDRC': 'date',
|
||||
'TYER': 'date',
|
||||
'TCON': 'genre',
|
||||
'TCOM': 'composer',
|
||||
}
|
||||
|
||||
tags = {}
|
||||
for frame_id, easy_key in frame_to_easy.items():
|
||||
frame = audio.tags.get(frame_id)
|
||||
if frame:
|
||||
# ID3 text frames store values as lists
|
||||
val = [str(t) for t in frame.text] if hasattr(frame, 'text') else [str(frame)]
|
||||
if val and val[0]:
|
||||
tags[easy_key] = val
|
||||
return tags
|
||||
except Exception as e:
|
||||
print(f" _read_aiff_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True)
|
||||
return None
|
||||
|
||||
|
||||
def _find_backup(full_path: str, original_path: str = None) -> Optional[str]:
|
||||
"""Find the backup JSON for a file, trying multiple key strategies.
|
||||
|
||||
Lookup order:
|
||||
1. Current full_path key (fast — no restructure happened)
|
||||
2. Current relative_path key (file moved but relative_path recalculated)
|
||||
3. Original full_path key (manifest stored the old path, backup keyed to it)
|
||||
4. Original relative_path key (old relative path from manifest)
|
||||
|
||||
Returns the backup file path, or None if not found.
|
||||
"""
|
||||
# 1. Current full_path
|
||||
bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(full_path)}.json")
|
||||
if os.path.exists(bp):
|
||||
return bp
|
||||
|
||||
# 2. Current relative_path
|
||||
try:
|
||||
rel = os.path.relpath(full_path, MUSIC_DIR)
|
||||
bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(rel)}.json")
|
||||
if os.path.exists(bp):
|
||||
return bp
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if original_path and original_path != full_path:
|
||||
# 3. Original full_path
|
||||
bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key(original_path)}.json")
|
||||
if os.path.exists(bp):
|
||||
return bp
|
||||
|
||||
# 4. Original relative_path
|
||||
try:
|
||||
old_rel = os.path.relpath(original_path, MUSIC_DIR)
|
||||
bp = os.path.join(TAG_BACKUP_DIR, f"{_backup_key_rel(old_rel)}.json")
|
||||
if os.path.exists(bp):
|
||||
return bp
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def restore_tags_from_backup(full_path: str, original_path: str = None) -> bool:
|
||||
"""Restore tags from the most recent backup for this file.
|
||||
|
||||
Uses _find_backup() to locate the backup JSON, trying multiple key strategies
|
||||
(current path, relative path, original path). This ensures undo works even
|
||||
after restructure has moved the file to a different directory.
|
||||
|
||||
IMPORTANT: Only overwrites text tags (title, artist, album, etc.) that
|
||||
easy=True exposes. Does NOT touch binary frames like APIC (album art),
|
||||
USLT (lyrics), or COMM (comments). Previous version used audio.delete()
|
||||
which nuked everything including embedded art — that was a data-loss bug.
|
||||
"""
|
||||
backup_path = _find_backup(full_path, original_path=original_path)
|
||||
if not backup_path:
|
||||
print(f" restore: no backup found for {os.path.basename(full_path)}"
|
||||
f" (original: {os.path.basename(original_path) if original_path else 'none'})", flush=True)
|
||||
return False
|
||||
try:
|
||||
with open(backup_path) as f:
|
||||
backup = json.load(f)
|
||||
|
||||
ext = Path(full_path).suffix.lower()
|
||||
|
||||
if ext in ('.aiff', '.aif'):
|
||||
return _restore_aiff_from_backup(full_path, backup)
|
||||
|
||||
audio = MutagenFile(full_path, easy=True)
|
||||
if audio is None:
|
||||
return False
|
||||
|
||||
backup_tags_dict = backup.get("tags", {})
|
||||
|
||||
# 1. Remove any easy-mode tags currently on file that AREN'T in the backup.
|
||||
# This undoes tags that were ADDED by the edit.
|
||||
# Only touches easy-namespace keys — APIC, USLT, COMM are untouched.
|
||||
current_keys = list(audio.keys())
|
||||
for k in current_keys:
|
||||
if k not in backup_tags_dict:
|
||||
try:
|
||||
del audio[k]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Set every tag from the backup (overwrites edited values, restores deleted ones).
|
||||
for k, v in backup_tags_dict.items():
|
||||
try:
|
||||
audio[k] = v
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
audio.save()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" restore_tags FAILED for {os.path.basename(full_path)}: {e}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
def _restore_aiff_from_backup(full_path: str, backup: dict) -> bool:
|
||||
"""Restore AIFF tags from backup using raw ID3 frames."""
|
||||
from mutagen.aiff import AIFF
|
||||
from mutagen.id3 import TIT2, TPE1, TALB, TPE2, TRCK, TPOS, TDRC, TCON
|
||||
|
||||
try:
|
||||
audio = AIFF(full_path)
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
tags = audio.tags
|
||||
|
||||
# Map easy-mode names back to ID3 frames
|
||||
frame_map = {
|
||||
'title': lambda v: TIT2(encoding=3, text=v),
|
||||
'artist': lambda v: TPE1(encoding=3, text=v),
|
||||
'album': lambda v: TALB(encoding=3, text=v),
|
||||
'albumartist': lambda v: TPE2(encoding=3, text=v),
|
||||
'tracknumber': lambda v: TRCK(encoding=3, text=v),
|
||||
'discnumber': lambda v: TPOS(encoding=3, text=v),
|
||||
'date': lambda v: TDRC(encoding=3, text=v),
|
||||
'genre': lambda v: TCON(encoding=3, text=v),
|
||||
}
|
||||
|
||||
for k, v in backup.get("tags", {}).items():
|
||||
k_lower = k.lower()
|
||||
if k_lower in frame_map:
|
||||
val = v if isinstance(v, list) else [v]
|
||||
tags.add(frame_map[k_lower](val))
|
||||
|
||||
audio.save()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" restore_aiff FAILED for {os.path.basename(full_path)}: {e}", flush=True)
|
||||
return False
|
||||
|
||||
def save_batch_manifest(batch_id: str, paths: list, tags_changed: dict = None,
|
||||
affected_albums: list = None, affected_artists: list = None,
|
||||
edit_type: str = "batch"):
|
||||
"""Save a manifest of a batch (or single) edit for undo + history UI.
|
||||
|
||||
Args:
|
||||
batch_id: unique identifier for this edit
|
||||
paths: list of absolute file paths that were modified
|
||||
tags_changed: dict of field→value that was applied (e.g. {"genre": "Rock"})
|
||||
affected_albums: list of album names touched (for UI display)
|
||||
affected_artists: list of artist names touched (for UI display)
|
||||
edit_type: "batch", "single", or "restructure"
|
||||
"""
|
||||
manifest = {
|
||||
"batch_id": batch_id,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"file_count": len(paths),
|
||||
"paths": paths,
|
||||
"tags_changed": tags_changed or {},
|
||||
"affected_albums": list(set(affected_albums or [])),
|
||||
"affected_artists": list(set(affected_artists or [])),
|
||||
"edit_type": edit_type,
|
||||
"is_reverted": False,
|
||||
}
|
||||
manifest_path = os.path.join(TAG_BACKUP_DIR, f"batch_{batch_id}.json")
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
return manifest_path
|
||||
|
||||
def validate_essential_tags(full_path: str, had_artist: bool, had_album: bool, had_title: bool) -> list:
|
||||
"""Check that essential tags weren't destroyed. Returns list of problems."""
|
||||
problems = []
|
||||
try:
|
||||
audio = MutagenFile(full_path, easy=True)
|
||||
if audio is None:
|
||||
return ["unsupported format"]
|
||||
now_artist = bool(audio.get('artist', [''])[0].strip())
|
||||
now_album = bool(audio.get('album', [''])[0].strip())
|
||||
now_title = bool(audio.get('title', [''])[0].strip())
|
||||
if had_artist and not now_artist:
|
||||
problems.append("artist lost")
|
||||
if had_album and not now_album:
|
||||
problems.append("album lost")
|
||||
if had_title and not now_title:
|
||||
problems.append("title lost")
|
||||
except:
|
||||
pass
|
||||
return problems
|
||||
|
||||
|
||||
NAVIDROME_TAGS = {
|
||||
'TITLE', 'ARTIST', 'ALBUM', 'ALBUMARTIST',
|
||||
'TRACKNUMBER', 'DISCNUMBER', 'DATE',
|
||||
|
|
@ -1188,6 +1480,29 @@ NAVIDROME_TAGS = {
|
|||
'REPLAYGAIN_ALBUM_GAIN', 'REPLAYGAIN_ALBUM_PEAK',
|
||||
}
|
||||
|
||||
# ID3v2 frame IDs that correspond to the Vorbis Comment names above.
|
||||
# enforce_tag_whitelist uses NAVIDROME_TAGS (Vorbis names) for FLAC,
|
||||
# but MP3 files use raw ID3 frame IDs — without this mapping, EVERY
|
||||
# MP3 tag gets deleted because "TPE1" != "ARTIST".
|
||||
ID3_FRAME_WHITELIST = {
|
||||
'TIT2', # TITLE
|
||||
'TPE1', # ARTIST
|
||||
'TALB', # ALBUM
|
||||
'TPE2', # ALBUMARTIST
|
||||
'TRCK', # TRACKNUMBER
|
||||
'TPOS', # DISCNUMBER
|
||||
'TDRC', # DATE (ID3v2.4)
|
||||
'TYER', # DATE (ID3v2.3 legacy)
|
||||
'TCON', # GENRE
|
||||
'TCOM', # COMPOSER
|
||||
'COMM', # COMMENT
|
||||
'TSRC', # ISRC
|
||||
'APIC', # Album art — always keep
|
||||
'USLT', # LYRICS (unsynced)
|
||||
'SYLT', # LYRICS (synced)
|
||||
'TXXX', # User-defined (checked by sub-key below)
|
||||
}
|
||||
|
||||
# Keep the blacklist for reference / legacy clean-tags endpoint
|
||||
PICARD_TAGS_TO_REMOVE = {
|
||||
'MUSICBRAINZ_TRACKID', 'MUSICBRAINZ_ALBUMID', 'MUSICBRAINZ_RELEASETRACKID',
|
||||
|
|
@ -1255,10 +1570,27 @@ def enforce_tag_whitelist(
|
|||
if audio is None:
|
||||
result["error"] = "Unsupported format"
|
||||
return result
|
||||
# For MP3/ID3 files, check raw frame IDs against ID3_FRAME_WHITELIST
|
||||
# (not NAVIDROME_TAGS, which uses Vorbis Comment names like "ARTIST"
|
||||
# that don't match ID3 frame IDs like "TPE1").
|
||||
allowed_rg = {
|
||||
'REPLAYGAIN_TRACK_GAIN', 'REPLAYGAIN_TRACK_PEAK',
|
||||
'REPLAYGAIN_ALBUM_GAIN', 'REPLAYGAIN_ALBUM_PEAK',
|
||||
}
|
||||
to_remove = []
|
||||
for k in list(audio.keys()):
|
||||
ku = k.upper().split(':')[0].strip()
|
||||
if ku not in allowed:
|
||||
# ID3 keys look like "TPE1", "TXXX:replaygain_track_gain",
|
||||
# "APIC:", "COMM::eng", "USLT::eng"
|
||||
frame_id = k.split(':')[0].strip().upper()
|
||||
if frame_id in ID3_FRAME_WHITELIST:
|
||||
# Frame type is allowed — but for TXXX, also check the
|
||||
# sub-key (description) against known ReplayGain names
|
||||
if frame_id == 'TXXX':
|
||||
desc = k.split(':', 1)[1].upper() if ':' in k else ''
|
||||
if desc not in allowed_rg:
|
||||
to_remove.append(k)
|
||||
# Otherwise keep it
|
||||
else:
|
||||
to_remove.append(k)
|
||||
to_remove = list(set(to_remove))
|
||||
result["removed"] = to_remove
|
||||
|
|
@ -1459,6 +1791,12 @@ def restructure_all() -> dict:
|
|||
if not os.path.isfile(full_path):
|
||||
skipped += 1
|
||||
continue
|
||||
# Safety net: snapshot tags BEFORE enforce_tag_whitelist touches them.
|
||||
# If whitelist enforcement damages tags (as it did for MP3s before the
|
||||
# ID3_FRAME_WHITELIST fix), the backup enables recovery.
|
||||
# Non-fatal if backup fails — log and continue (restructure is bulk).
|
||||
if not backup_tags(full_path):
|
||||
print(f" restructure: backup failed for {os.path.basename(full_path)}, proceeding cautiously", flush=True)
|
||||
# Enforce whitelist before restructuring — clean tags first so
|
||||
# build_target_path reads clean data and generates the correct path
|
||||
enforce_tag_whitelist(full_path, preserve_composer=True, preserve_lyrics=True)
|
||||
|
|
@ -1472,10 +1810,24 @@ def restructure_all() -> dict:
|
|||
|
||||
|
||||
def apply_tags(path: str, u, preserve_composer: bool = True, preserve_lyrics: bool = True):
|
||||
"""Write tags then enforce whitelist — only Navidrome tags survive."""
|
||||
"""Write tags then enforce whitelist — only Navidrome tags survive.
|
||||
Backs up all tags BEFORE writing so undo is always possible.
|
||||
REFUSES to write if backup fails — never modifies tags without a safety net."""
|
||||
# Snapshot current state for undo — MUST succeed before we touch anything
|
||||
if not backup_tags(path):
|
||||
raise RuntimeError(
|
||||
f"Cannot edit {os.path.basename(path)}: backup failed. "
|
||||
"Tags untouched. Check disk space and file permissions in TAG_BACKUP_DIR."
|
||||
)
|
||||
|
||||
audio = MutagenFile(path, easy=True)
|
||||
if audio is None:
|
||||
raise ValueError(f"Unsupported format: {path}")
|
||||
# Record what was present before write
|
||||
had_artist = bool(audio.get('artist', [''])[0].strip())
|
||||
had_album = bool(audio.get('album', [''])[0].strip())
|
||||
had_title = bool(audio.get('title', [''])[0].strip())
|
||||
|
||||
if u.title: audio['title'] = u.title
|
||||
if u.artist: audio['artist'] = u.artist
|
||||
if u.album: audio['album'] = u.album
|
||||
|
|
@ -1484,15 +1836,36 @@ def apply_tags(path: str, u, preserve_composer: bool = True, preserve_lyrics: bo
|
|||
if u.year: audio['date'] = str(u.year)
|
||||
if u.track_number: audio['tracknumber'] = str(u.track_number)
|
||||
audio.save()
|
||||
# Enforce whitelist after writing — nukes everything not in NAVIDROME_TAGS
|
||||
# Enforce whitelist after writing
|
||||
enforce_tag_whitelist(path, preserve_composer=preserve_composer, preserve_lyrics=preserve_lyrics)
|
||||
|
||||
# Validate — if essential tags were destroyed, auto-restore
|
||||
problems = validate_essential_tags(path, had_artist, had_album, had_title)
|
||||
if problems:
|
||||
print(f" ⚠ TAG DAMAGE DETECTED in {os.path.basename(path)}: {problems} — auto-restoring", flush=True)
|
||||
restore_tags_from_backup(path)
|
||||
raise RuntimeError(f"Tag write damaged {os.path.basename(path)}: {problems}. Restored from backup.")
|
||||
|
||||
|
||||
def apply_tags_dict(path: str, tags: dict, preserve_composer: bool = True, preserve_lyrics: bool = True):
|
||||
"""Write tags dict then enforce whitelist — only Navidrome tags survive."""
|
||||
"""Write tags dict then enforce whitelist — only Navidrome tags survive.
|
||||
Backs up all tags BEFORE writing so undo is always possible.
|
||||
REFUSES to write if backup fails — never modifies tags without a safety net."""
|
||||
# Snapshot current state for undo — MUST succeed before we touch anything
|
||||
if not backup_tags(path):
|
||||
raise RuntimeError(
|
||||
f"Cannot edit {os.path.basename(path)}: backup failed. "
|
||||
"Tags untouched. Check disk space and file permissions in TAG_BACKUP_DIR."
|
||||
)
|
||||
|
||||
audio = MutagenFile(path, easy=True)
|
||||
if audio is None:
|
||||
raise ValueError(f"Unsupported format: {path}")
|
||||
# Record what was present before write
|
||||
had_artist = bool(audio.get('artist', [''])[0].strip())
|
||||
had_album = bool(audio.get('album', [''])[0].strip())
|
||||
had_title = bool(audio.get('title', [''])[0].strip())
|
||||
|
||||
mapping = {'title': 'title', 'artist': 'artist', 'album': 'album',
|
||||
'album_artist': 'albumartist', 'genre': 'genre',
|
||||
'year': 'date', 'track_number': 'tracknumber'}
|
||||
|
|
@ -1504,6 +1877,13 @@ def apply_tags_dict(path: str, tags: dict, preserve_composer: bool = True, prese
|
|||
# Enforce whitelist after writing
|
||||
enforce_tag_whitelist(path, preserve_composer=preserve_composer, preserve_lyrics=preserve_lyrics)
|
||||
|
||||
# Validate — if essential tags were destroyed, auto-restore
|
||||
problems = validate_essential_tags(path, had_artist, had_album, had_title)
|
||||
if problems:
|
||||
print(f" ⚠ TAG DAMAGE DETECTED in {os.path.basename(path)}: {problems} — auto-restoring", flush=True)
|
||||
restore_tags_from_backup(path)
|
||||
raise RuntimeError(f"Tag write damaged {os.path.basename(path)}: {problems}. Restored from backup.")
|
||||
|
||||
|
||||
# ── Analysis ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -1700,19 +2080,55 @@ async def edit_metadata(update: MetadataUpdate):
|
|||
fp = resolve_path(update.relative_path)
|
||||
if not fp:
|
||||
raise HTTPException(404, f"File not found. raw='{update.relative_path}' MUSIC_DIR='{MUSIC_DIR}'")
|
||||
|
||||
# Generate a batch ID so single-track edits appear in edit history too
|
||||
batch_id = hashlib.md5(f"{time.time()}_{update.relative_path}".encode()).hexdigest()[:12]
|
||||
|
||||
try:
|
||||
# Collect context before the edit (for history UI)
|
||||
album_name = ""
|
||||
artist_name = ""
|
||||
try:
|
||||
pre = MutagenFile(fp, easy=True)
|
||||
if pre:
|
||||
album_name = pre.get('album', [''])[0]
|
||||
artist_name = pre.get('albumartist', pre.get('artist', ['']))[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Offload blocking Mutagen I/O to a thread — audio.save() on FLAC can
|
||||
# take 100-500ms and must not block the event loop (AUDIT-009)
|
||||
await asyncio.to_thread(apply_tags, fp, update)
|
||||
await asyncio.to_thread(update_song_in_db, fp)
|
||||
new_relative = os.path.relpath(fp, MUSIC_DIR)
|
||||
|
||||
# Build tags_changed from update fields
|
||||
tags_changed = {}
|
||||
if update.title: tags_changed["title"] = update.title
|
||||
if update.artist: tags_changed["artist"] = update.artist
|
||||
if update.album: tags_changed["album"] = update.album
|
||||
if update.album_artist: tags_changed["album_artist"] = update.album_artist
|
||||
if update.genre: tags_changed["genre"] = update.genre
|
||||
if update.year: tags_changed["year"] = str(update.year)
|
||||
if update.track_number: tags_changed["track_number"] = str(update.track_number)
|
||||
|
||||
# Save manifest so single edits appear in edit history
|
||||
save_batch_manifest(
|
||||
batch_id, [fp],
|
||||
tags_changed=tags_changed,
|
||||
affected_albums=[album_name] if album_name else [],
|
||||
affected_artists=[artist_name] if artist_name else [],
|
||||
edit_type="single",
|
||||
)
|
||||
|
||||
await trigger_scan()
|
||||
await push.broadcast("metadata_updated", {
|
||||
"path": new_relative,
|
||||
"title": update.title or "", "artist": update.artist or "",
|
||||
"album": update.album or ""
|
||||
"album": update.album or "",
|
||||
"batch_id": batch_id,
|
||||
})
|
||||
return {"status": "success", "file": new_relative, "resolved": fp}
|
||||
return {"status": "success", "file": new_relative, "resolved": fp, "batch_id": batch_id}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
|
@ -1721,7 +2137,9 @@ async def edit_metadata(update: MetadataUpdate):
|
|||
|
||||
@app.patch("/batch-edit-metadata")
|
||||
async def batch_edit_metadata(update: BatchMetadataUpdate):
|
||||
results = {"succeeded": [], "failed": []}
|
||||
# Generate a batch ID for undo support
|
||||
batch_id = hashlib.md5(f"{time.time()}_{len(update.relative_paths)}".encode()).hexdigest()[:12]
|
||||
results = {"succeeded": [], "failed": [], "batch_id": batch_id}
|
||||
tags = {}
|
||||
if update.title: tags["title"] = update.title
|
||||
if update.artist: tags["artist"] = update.artist
|
||||
|
|
@ -1730,18 +2148,43 @@ async def batch_edit_metadata(update: BatchMetadataUpdate):
|
|||
if update.genre: tags["genre"] = update.genre
|
||||
if update.year: tags["year"] = str(update.year)
|
||||
def _apply_batch():
|
||||
"""Blocking tag writes run in a thread so the event loop stays free."""
|
||||
"""Blocking tag writes run in a thread so the event loop stays free.
|
||||
Each file is backed up BEFORE any write — undo is always possible."""
|
||||
resolved_paths = []
|
||||
albums_seen = []
|
||||
artists_seen = []
|
||||
for rp in update.relative_paths:
|
||||
fp = resolve_path(rp)
|
||||
if not fp:
|
||||
results["failed"].append({"path": rp, "error": "File not found"})
|
||||
continue
|
||||
try:
|
||||
# Collect album/artist context BEFORE the edit (for history UI)
|
||||
try:
|
||||
pre = MutagenFile(fp, easy=True)
|
||||
if pre:
|
||||
a = pre.get('album', [''])[0]
|
||||
ar = pre.get('albumartist', pre.get('artist', ['']))[0]
|
||||
if a: albums_seen.append(a)
|
||||
if ar: artists_seen.append(ar)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
apply_tags_dict(fp, tags)
|
||||
update_song_in_db(fp)
|
||||
results["succeeded"].append(os.path.relpath(fp, MUSIC_DIR))
|
||||
resolved_paths.append(fp)
|
||||
except Exception as e:
|
||||
results["failed"].append({"path": rp, "error": str(e)})
|
||||
# Save batch manifest for bulk undo — includes what changed and what was affected
|
||||
if resolved_paths:
|
||||
save_batch_manifest(
|
||||
batch_id, resolved_paths,
|
||||
tags_changed=tags,
|
||||
affected_albums=albums_seen,
|
||||
affected_artists=artists_seen,
|
||||
edit_type="batch",
|
||||
)
|
||||
|
||||
await asyncio.to_thread(_apply_batch)
|
||||
await trigger_scan()
|
||||
|
|
@ -1750,12 +2193,143 @@ async def batch_edit_metadata(update: BatchMetadataUpdate):
|
|||
await push.broadcast("batch_metadata_updated",
|
||||
{"count": len(results["succeeded"]),
|
||||
"album": update.album or "",
|
||||
"batch_id": batch_id,
|
||||
"paths_changed": "true"})
|
||||
# Run conflict check in background after every batch edit
|
||||
_create_task(_run_conflict_check_and_broadcast())
|
||||
return results
|
||||
|
||||
|
||||
@app.post("/undo-batch-edit/{batch_id}")
|
||||
async def undo_batch_edit(batch_id: str):
|
||||
"""Restore all files in a batch edit to their pre-edit tags.
|
||||
The batch_id is returned by PATCH /batch-edit-metadata.
|
||||
|
||||
Handles files that moved after restructure: if the manifest path
|
||||
no longer exists, resolves the current location and uses the
|
||||
original path to find the backup.
|
||||
"""
|
||||
manifest_path = os.path.join(TAG_BACKUP_DIR, f"batch_{batch_id}.json")
|
||||
if not os.path.exists(manifest_path):
|
||||
raise HTTPException(404, f"Batch {batch_id} not found. Backups may have been cleaned up.")
|
||||
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Prevent double-undo: if already reverted, reject
|
||||
if manifest.get("is_reverted"):
|
||||
reverted_at = manifest.get("reverted_at", "unknown time")
|
||||
raise HTTPException(409, f"Batch {batch_id} was already reverted at {reverted_at}.")
|
||||
|
||||
results = {"restored": [], "failed": []}
|
||||
def _restore():
|
||||
for original_fp in manifest["paths"]:
|
||||
# Find the file — it may have moved since the edit
|
||||
if os.path.isfile(original_fp):
|
||||
current_fp = original_fp
|
||||
else:
|
||||
# File moved (restructure happened). Try resolve_path.
|
||||
old_rel = os.path.relpath(original_fp, MUSIC_DIR) if original_fp.startswith(MUSIC_DIR) else original_fp
|
||||
current_fp = resolve_path(old_rel)
|
||||
if not current_fp:
|
||||
# Last resort: search by filename anywhere in MUSIC_DIR
|
||||
fname = os.path.basename(original_fp)
|
||||
for root, dirs, files in os.walk(MUSIC_DIR):
|
||||
if fname in files:
|
||||
current_fp = os.path.join(root, fname)
|
||||
break
|
||||
if not current_fp:
|
||||
results["failed"].append({
|
||||
"path": os.path.relpath(original_fp, MUSIC_DIR),
|
||||
"error": "File not found (may have been deleted)"
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
ok = restore_tags_from_backup(current_fp, original_path=original_fp)
|
||||
if ok:
|
||||
results["restored"].append(os.path.relpath(current_fp, MUSIC_DIR))
|
||||
else:
|
||||
results["failed"].append({
|
||||
"path": os.path.relpath(current_fp, MUSIC_DIR),
|
||||
"error": "No backup found for this file"
|
||||
})
|
||||
except Exception as e:
|
||||
results["failed"].append({
|
||||
"path": os.path.relpath(original_fp, MUSIC_DIR),
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
await asyncio.to_thread(_restore)
|
||||
|
||||
# Mark the manifest as reverted so it can't be undone again
|
||||
manifest["is_reverted"] = True
|
||||
manifest["reverted_at"] = datetime.utcnow().isoformat()
|
||||
manifest["restore_results"] = {
|
||||
"restored": len(results["restored"]),
|
||||
"failed": len(results["failed"]),
|
||||
}
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
await trigger_scan()
|
||||
await asyncio.sleep(4)
|
||||
await push.broadcast("batch_undo_complete", {
|
||||
"batch_id": batch_id,
|
||||
"restored": len(results["restored"]),
|
||||
"failed": len(results["failed"]),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
@app.post("/restore-tags")
|
||||
async def restore_single_file_tags(relative_path: str = Query(...)):
|
||||
"""Restore a single file's tags from its backup."""
|
||||
fp = resolve_path(relative_path)
|
||||
if not fp:
|
||||
raise HTTPException(404, "File not found")
|
||||
ok = await asyncio.to_thread(restore_tags_from_backup, fp)
|
||||
if not ok:
|
||||
raise HTTPException(404, "No backup found for this file")
|
||||
await asyncio.to_thread(update_song_in_db, fp)
|
||||
await trigger_scan()
|
||||
return {"status": "restored", "path": relative_path}
|
||||
|
||||
|
||||
@app.get("/batch-edit-history")
|
||||
async def batch_edit_history():
|
||||
"""List recent edits (batch + single) that can be undone.
|
||||
Returns enough data for the Edit History UI to display:
|
||||
- what changed (tags_changed pills)
|
||||
- what was affected (album/artist names)
|
||||
- whether already reverted
|
||||
- edit type (batch/single)
|
||||
"""
|
||||
manifests = []
|
||||
if os.path.isdir(TAG_BACKUP_DIR):
|
||||
for f in sorted(os.listdir(TAG_BACKUP_DIR), reverse=True):
|
||||
if f.startswith("batch_") and f.endswith(".json"):
|
||||
try:
|
||||
with open(os.path.join(TAG_BACKUP_DIR, f)) as fh:
|
||||
m = json.load(fh)
|
||||
manifests.append({
|
||||
"batch_id": m.get("batch_id", ""),
|
||||
"timestamp": m.get("timestamp", ""),
|
||||
"file_count": m.get("file_count", 0),
|
||||
"tags_changed": m.get("tags_changed", {}),
|
||||
"affected_albums": m.get("affected_albums", []),
|
||||
"affected_artists": m.get("affected_artists", []),
|
||||
"edit_type": m.get("edit_type", "batch"),
|
||||
"is_reverted": m.get("is_reverted", False),
|
||||
"reverted_at": m.get("reverted_at"),
|
||||
})
|
||||
except:
|
||||
pass
|
||||
# Sort by timestamp descending (filenames aren't guaranteed to sort by time)
|
||||
manifests.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
return {"batches": manifests[:50]} # last 50
|
||||
|
||||
|
||||
@app.post("/upload-track")
|
||||
async def upload_track(
|
||||
file: UploadFile = File(...),
|
||||
|
|
|
|||
|
|
@ -196,6 +196,13 @@ struct RootView: View {
|
|||
category: "Audio", level: .info
|
||||
)
|
||||
}
|
||||
|
||||
// Play Queue Sync — start observing for saves, check server for cross-device resume
|
||||
let queueSync = PlayQueueSyncManager.shared
|
||||
queueSync.start()
|
||||
if AudioPlayer.shared.currentSong == nil {
|
||||
await queueSync.checkServerQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// Connect Companion push client if enabled
|
||||
|
|
|
|||
422
iOS/Data/AudioTapProcessor.swift
Normal file
422
iOS/Data/AudioTapProcessor.swift
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
import Foundation
|
||||
import AVFoundation
|
||||
import MediaToolbox
|
||||
import Accelerate
|
||||
|
||||
// MARK: - Lock-free Ring Buffer for audio samples
|
||||
|
||||
/// Single-producer (audio render thread), single-consumer (main thread) ring buffer.
|
||||
/// No locks, no allocations on the audio thread. ARM64 naturally-atomic Int writes
|
||||
/// ensure the single write index is safely visible across threads.
|
||||
final class PCMRingBuffer {
|
||||
let capacity: Int
|
||||
private let buffer: UnsafeMutablePointer<Float>
|
||||
// Atomic indices — render thread writes `writeIndex`, main thread writes `readIndex`
|
||||
private var _writeIndex: Int = 0
|
||||
|
||||
init(capacity: Int) {
|
||||
self.capacity = capacity
|
||||
buffer = .allocate(capacity: capacity)
|
||||
buffer.initialize(repeating: 0, count: capacity)
|
||||
}
|
||||
|
||||
deinit {
|
||||
buffer.deallocate()
|
||||
}
|
||||
|
||||
/// Write samples from the audio render thread. Lock-free.
|
||||
func write(_ samples: UnsafePointer<Float>, count: Int) {
|
||||
let wi = _writeIndex
|
||||
let space = capacity
|
||||
for i in 0..<count {
|
||||
buffer[(wi + i) % space] = samples[i]
|
||||
}
|
||||
_writeIndex = (wi + count) % space
|
||||
}
|
||||
|
||||
/// Read the most recent `count` samples from the main thread. Lock-free.
|
||||
/// Returns the number of samples actually copied.
|
||||
@discardableResult
|
||||
func readMostRecent(into dest: UnsafeMutablePointer<Float>, count: Int) -> Int {
|
||||
let wi = _writeIndex
|
||||
let start = (wi - count + capacity) % capacity
|
||||
for i in 0..<count {
|
||||
dest[i] = buffer[(start + i) % capacity]
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func reset() {
|
||||
_writeIndex = 0
|
||||
buffer.initialize(repeating: 0, count: capacity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Tap Processor
|
||||
|
||||
/// Installs an MTAudioProcessingTap on an AVPlayerItem and makes raw PCM samples
|
||||
/// available for FFT visualization and Shazam recognition.
|
||||
///
|
||||
/// Architecture:
|
||||
/// AVPlayerItem → MTAudioProcessingTap (C callback on render thread)
|
||||
/// → PCMRingBuffer (lock-free)
|
||||
/// → Timer at 30fps reads buffer, runs vDSP FFT → 30 bands → setLevels()
|
||||
/// → Optional: Shazam consumer subscribes via `shazamHandler`
|
||||
///
|
||||
/// Thread safety:
|
||||
/// - Tap callback: CoreAudio real-time render thread (no locks, no ObjC, no heap alloc)
|
||||
/// - FFT timer: main thread
|
||||
/// - Ring buffer: lock-free single-producer/single-consumer
|
||||
final class AudioTapProcessor {
|
||||
static let shared = AudioTapProcessor()
|
||||
|
||||
// Ring buffer: 8192 samples ≈ 186ms at 44.1kHz — plenty for 1024-sample FFT windows
|
||||
let ringBuffer = PCMRingBuffer(capacity: 8192)
|
||||
|
||||
// Shazam consumer — set by ShazamRecognizer, cleared when done
|
||||
var shazamHandler: ((UnsafeMutablePointer<AudioBufferList>, CMItemCount) -> Void)?
|
||||
|
||||
// Pre-allocated FFT resources — created once, reused every frame (30fps)
|
||||
private let fftSize = 1024
|
||||
private let fftLog2n: vDSP_Length
|
||||
private let fftSetup: FFTSetup
|
||||
private var hannWindow: [Float]
|
||||
private var fftTimeDomain: [Float]
|
||||
private var fftRealp: [Float]
|
||||
private var fftImagp: [Float]
|
||||
private var fftMagnitudes: [Float]
|
||||
|
||||
// Debug: save PCM to WAV file for verification — tap the share button in settings
|
||||
var debugDumpEnabled = false
|
||||
var debugDumpURL: URL?
|
||||
private var debugFileHandle: FileHandle?
|
||||
private var debugSamplesWritten: Int = 0
|
||||
private var debugMaxSamplesActual = 44100 * 5 // recalculated in startDebugDump from actual rate
|
||||
|
||||
/// Posted on main thread when debug capture completes. userInfo contains "url": URL.
|
||||
static let captureCompleteNotification = Notification.Name("AudioTapCaptureComplete")
|
||||
|
||||
// Source format detected by the tap's prepare callback
|
||||
var sourceFormat: AVAudioFormat?
|
||||
|
||||
private init() {
|
||||
let halfSize = fftSize / 2
|
||||
fftLog2n = vDSP_Length(log2(Float(fftSize)))
|
||||
fftSetup = vDSP_create_fftsetup(fftLog2n, FFTRadix(kFFTRadix2))!
|
||||
hannWindow = [Float](repeating: 0, count: fftSize)
|
||||
vDSP_hann_window(&hannWindow, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
|
||||
fftTimeDomain = [Float](repeating: 0, count: fftSize)
|
||||
fftRealp = [Float](repeating: 0, count: halfSize)
|
||||
fftImagp = [Float](repeating: 0, count: halfSize)
|
||||
fftMagnitudes = [Float](repeating: 0, count: halfSize)
|
||||
}
|
||||
|
||||
deinit {
|
||||
vDSP_destroy_fftsetup(fftSetup)
|
||||
}
|
||||
|
||||
// MARK: - Install / Remove Tap
|
||||
|
||||
/// Install the shared tap on a player item. Returns true if successful.
|
||||
/// Safe to call multiple times — removes any existing tap first.
|
||||
@MainActor
|
||||
func installTap(on playerItem: AVPlayerItem) async -> Bool {
|
||||
// Remove existing tap
|
||||
removeTap(from: playerItem)
|
||||
|
||||
// Load audio tracks — async API (non-deprecated)
|
||||
guard let audioTrack = try? await playerItem.asset
|
||||
.loadTracks(withMediaType: .audio).first else {
|
||||
print("[AudioTap] No audio track found on playerItem")
|
||||
return false
|
||||
}
|
||||
|
||||
// Create the MTAudioProcessingTap with C callbacks
|
||||
var callbacks = MTAudioProcessingTapCallbacks(
|
||||
version: kMTAudioProcessingTapCallbacksVersion_0,
|
||||
clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
|
||||
init: tapInit,
|
||||
finalize: nil,
|
||||
prepare: tapPrepare,
|
||||
unprepare: nil,
|
||||
process: tapProcess
|
||||
)
|
||||
|
||||
var tapOut: MTAudioProcessingTap?
|
||||
let status = MTAudioProcessingTapCreate(
|
||||
kCFAllocatorDefault, &callbacks,
|
||||
kMTAudioProcessingTapCreationFlag_PostEffects, &tapOut
|
||||
)
|
||||
guard status == noErr, let tap = tapOut else {
|
||||
print("[AudioTap] MTAudioProcessingTapCreate failed: \(status)")
|
||||
return false
|
||||
}
|
||||
|
||||
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
|
||||
inputParams.audioTapProcessor = tap
|
||||
|
||||
let mix = AVMutableAudioMix()
|
||||
mix.inputParameters = [inputParams]
|
||||
playerItem.audioMix = mix
|
||||
|
||||
print("[AudioTap] Tap installed successfully")
|
||||
|
||||
// Start debug dump if enabled
|
||||
if debugDumpEnabled {
|
||||
startDebugDump()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// Remove the tap from a player item.
|
||||
func removeTap(from playerItem: AVPlayerItem?) {
|
||||
playerItem?.audioMix = nil
|
||||
sourceFormat = nil
|
||||
ringBuffer.reset() // Prevent stale samples from previous stream bleeding into next FFT
|
||||
stopDebugDump()
|
||||
}
|
||||
|
||||
// MARK: - FFT Processing (called from main thread timer)
|
||||
|
||||
/// Perform FFT on the most recent samples and return frequency bands (0.0-1.0).
|
||||
/// Uses pre-allocated buffers — minimal heap allocation per call.
|
||||
/// Call this at 30fps from the visualizer timer.
|
||||
func computeFFTBands(bandCount: Int = 30) -> [Float] {
|
||||
let halfSize = fftSize / 2
|
||||
|
||||
// Read most recent 1024 samples from ring buffer into pre-allocated array
|
||||
_ = fftTimeDomain.withUnsafeMutableBufferPointer { buf in
|
||||
ringBuffer.readMostRecent(into: buf.baseAddress!, count: fftSize)
|
||||
}
|
||||
|
||||
// Apply Hann window (pre-computed) to reduce spectral leakage
|
||||
vDSP_vmul(fftTimeDomain, 1, hannWindow, 1, &fftTimeDomain, 1, vDSP_Length(fftSize))
|
||||
|
||||
// Zero the split complex buffers
|
||||
for i in 0..<halfSize { fftRealp[i] = 0; fftImagp[i] = 0 }
|
||||
|
||||
// Run FFT using withUnsafeMutableBufferPointer to get stable pointers
|
||||
// that outlive the DSPSplitComplex init call.
|
||||
fftRealp.withUnsafeMutableBufferPointer { realpBuf in
|
||||
fftImagp.withUnsafeMutableBufferPointer { imagpBuf in
|
||||
var splitComplex = DSPSplitComplex(
|
||||
realp: realpBuf.baseAddress!,
|
||||
imagp: imagpBuf.baseAddress!
|
||||
)
|
||||
|
||||
fftTimeDomain.withUnsafeBufferPointer { ptr in
|
||||
ptr.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: halfSize) {
|
||||
vDSP_ctoz($0, 2, &splitComplex, 1, vDSP_Length(halfSize))
|
||||
}
|
||||
}
|
||||
|
||||
vDSP_fft_zrip(fftSetup, &splitComplex, 1, fftLog2n, FFTDirection(kFFTDirection_Forward))
|
||||
vDSP_zvmags(&splitComplex, 1, &fftMagnitudes, 1, vDSP_Length(halfSize))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to dB scale — magnitude 0 → -inf, clamped to 0 in normalization step
|
||||
var one: Float = 1.0
|
||||
vDSP_vdbcon(fftMagnitudes, 1, &one, &fftMagnitudes, 1, vDSP_Length(halfSize), 1)
|
||||
|
||||
// Map to frequency bands with logarithmic spacing (more bass/mid resolution)
|
||||
var bands = [Float](repeating: 0, count: bandCount)
|
||||
let maxBin = min(halfSize, 300) // Cap at ~13kHz (300/512 * 22050)
|
||||
|
||||
for i in 0..<bandCount {
|
||||
let lowPct = Float(i) / Float(bandCount)
|
||||
let highPct = Float(i + 1) / Float(bandCount)
|
||||
let lowBin = Int(powf(lowPct, 2.0) * Float(maxBin))
|
||||
let highBin = max(lowBin + 1, Int(powf(highPct, 2.0) * Float(maxBin)))
|
||||
let clampedHigh = min(highBin, maxBin)
|
||||
|
||||
if lowBin < clampedHigh {
|
||||
var sum: Float = 0
|
||||
var count: Float = 0
|
||||
for bin in lowBin..<clampedHigh {
|
||||
sum += fftMagnitudes[bin]
|
||||
count += 1
|
||||
}
|
||||
bands[i] = sum / count
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize: map dB range to 0.0-1.0
|
||||
let minDB: Float = -50
|
||||
let maxDB: Float = 15
|
||||
let range = maxDB - minDB
|
||||
for i in 0..<bandCount {
|
||||
bands[i] = max(0, min(1, (bands[i] - minDB) / range))
|
||||
}
|
||||
|
||||
return bands
|
||||
}
|
||||
|
||||
// MARK: - Debug Dump (WAV file)
|
||||
|
||||
/// Start capturing audio tap output to a WAV file. Captures 5 seconds then auto-stops.
|
||||
func startDebugDump() {
|
||||
stopDebugDump()
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("audio_tap_capture_\(Int(Date().timeIntervalSince1970)).wav")
|
||||
debugDumpURL = url
|
||||
|
||||
// Use the actual stream sample rate, default 44100 if unknown
|
||||
let sampleRate = UInt32(sourceFormat?.sampleRate ?? 44100)
|
||||
debugMaxSamplesActual = Int(sampleRate) * 5 // 5 seconds at actual rate
|
||||
|
||||
// Write WAV header placeholder (44 bytes) — we'll patch the size fields when done
|
||||
var header = Data(count: 44)
|
||||
header.withUnsafeMutableBytes { ptr in
|
||||
let p = ptr.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
// "RIFF"
|
||||
p[0] = 0x52; p[1] = 0x49; p[2] = 0x46; p[3] = 0x46
|
||||
// File size placeholder (patch later)
|
||||
// "WAVE"
|
||||
p[8] = 0x57; p[9] = 0x41; p[10] = 0x56; p[11] = 0x45
|
||||
// "fmt "
|
||||
p[12] = 0x66; p[13] = 0x6D; p[14] = 0x74; p[15] = 0x20
|
||||
// Chunk size: 16
|
||||
p[16] = 16; p[17] = 0; p[18] = 0; p[19] = 0
|
||||
// Format: IEEE float (3)
|
||||
p[20] = 3; p[21] = 0
|
||||
// Channels: 1
|
||||
p[22] = 1; p[23] = 0
|
||||
// Sample rate (from actual stream format)
|
||||
let sr = sampleRate
|
||||
p[24] = UInt8(sr & 0xFF); p[25] = UInt8((sr >> 8) & 0xFF)
|
||||
p[26] = UInt8((sr >> 16) & 0xFF); p[27] = UInt8((sr >> 24) & 0xFF)
|
||||
// Byte rate: sampleRate * 1ch * 4 bytes
|
||||
let br = sr * 4
|
||||
p[28] = UInt8(br & 0xFF); p[29] = UInt8((br >> 8) & 0xFF)
|
||||
p[30] = UInt8((br >> 16) & 0xFF); p[31] = UInt8((br >> 24) & 0xFF)
|
||||
// Block align: 4
|
||||
p[32] = 4; p[33] = 0
|
||||
// Bits per sample: 32
|
||||
p[34] = 32; p[35] = 0
|
||||
// "data"
|
||||
p[36] = 0x64; p[37] = 0x61; p[38] = 0x74; p[39] = 0x61
|
||||
// Data size placeholder (patch later)
|
||||
}
|
||||
|
||||
FileManager.default.createFile(atPath: url.path, contents: header)
|
||||
debugFileHandle = FileHandle(forWritingAtPath: url.path)
|
||||
debugFileHandle?.seekToEndOfFile()
|
||||
debugSamplesWritten = 0
|
||||
debugDumpEnabled = true
|
||||
print("[AudioTap] Debug capture started: \(url.lastPathComponent) at \(sampleRate)Hz")
|
||||
}
|
||||
|
||||
/// Stop capturing and finalize the WAV header with correct sizes.
|
||||
func stopDebugDump() {
|
||||
guard let fh = debugFileHandle else { return }
|
||||
debugDumpEnabled = false
|
||||
|
||||
// Patch WAV header with correct sizes
|
||||
let dataSize = UInt32(debugSamplesWritten * MemoryLayout<Float>.size)
|
||||
let fileSize = dataSize + 36 // 44 - 8 = 36
|
||||
|
||||
fh.seek(toFileOffset: 4)
|
||||
var fs = fileSize; fh.write(Data(bytes: &fs, count: 4))
|
||||
fh.seek(toFileOffset: 40)
|
||||
var ds = dataSize; fh.write(Data(bytes: &ds, count: 4))
|
||||
fh.closeFile()
|
||||
debugFileHandle = nil
|
||||
|
||||
if debugSamplesWritten > 0 {
|
||||
let rate = sourceFormat?.sampleRate ?? 44100
|
||||
let duration = Double(debugSamplesWritten) / rate
|
||||
print("[AudioTap] Debug capture complete: \(String(format: "%.1f", duration))s, \(dataSize) bytes")
|
||||
}
|
||||
debugSamplesWritten = 0
|
||||
}
|
||||
|
||||
/// Called from the tap process callback to write samples to WAV file.
|
||||
func debugWriteSamples(_ samples: UnsafePointer<Float>, count: Int) {
|
||||
guard debugDumpEnabled, debugSamplesWritten < debugMaxSamplesActual else {
|
||||
if debugDumpEnabled && debugSamplesWritten >= debugMaxSamplesActual {
|
||||
// Auto-stop after 5 seconds
|
||||
debugDumpEnabled = false
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.stopDebugDump()
|
||||
if let url = self?.debugDumpURL {
|
||||
NotificationCenter.default.post(
|
||||
name: AudioTapProcessor.captureCompleteNotification,
|
||||
object: nil,
|
||||
userInfo: ["url": url]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
let data = Data(bytes: samples, count: count * MemoryLayout<Float>.size)
|
||||
debugFileHandle?.write(data)
|
||||
debugSamplesWritten += count
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - C Tap Callbacks (free functions, not methods)
|
||||
|
||||
private func tapInit(
|
||||
tap: MTAudioProcessingTap,
|
||||
clientInfo: UnsafeMutableRawPointer?,
|
||||
tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
|
||||
) {
|
||||
tapStorageOut.pointee = clientInfo
|
||||
}
|
||||
|
||||
private func tapPrepare(
|
||||
tap: MTAudioProcessingTap,
|
||||
maxFrames: CMItemCount,
|
||||
processingFormat: UnsafePointer<AudioStreamBasicDescription>
|
||||
) {
|
||||
let processor = Unmanaged<AudioTapProcessor>
|
||||
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
|
||||
.takeUnretainedValue()
|
||||
let format = AVAudioFormat(streamDescription: processingFormat)
|
||||
processor.sourceFormat = format
|
||||
print("[AudioTap] Prepared: \(processingFormat.pointee.mSampleRate)Hz, " +
|
||||
"\(processingFormat.pointee.mChannelsPerFrame)ch, " +
|
||||
"\(processingFormat.pointee.mBitsPerChannel)bit, " +
|
||||
"float=\(processingFormat.pointee.mFormatFlags & kAudioFormatFlagIsFloat != 0)")
|
||||
}
|
||||
|
||||
private func tapProcess(
|
||||
tap: MTAudioProcessingTap,
|
||||
numberFrames: CMItemCount,
|
||||
flags: MTAudioProcessingTapFlags,
|
||||
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
|
||||
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
||||
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
|
||||
) {
|
||||
// Fetch audio from the source — passes through to player unchanged
|
||||
let status = MTAudioProcessingTapGetSourceAudio(
|
||||
tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut
|
||||
)
|
||||
guard status == noErr else { return }
|
||||
|
||||
let processor = Unmanaged<AudioTapProcessor>
|
||||
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
|
||||
.takeUnretainedValue()
|
||||
|
||||
// Extract mono float samples from the first channel
|
||||
let abl = UnsafeMutableAudioBufferListPointer(bufferListInOut)
|
||||
guard let firstBuffer = abl.first,
|
||||
let data = firstBuffer.mData else { return }
|
||||
|
||||
let floatPtr = data.assumingMemoryBound(to: Float.self)
|
||||
let frameCount = Int(numberFramesOut.pointee)
|
||||
|
||||
// Write to ring buffer (lock-free, no allocation)
|
||||
processor.ringBuffer.write(floatPtr, count: frameCount)
|
||||
|
||||
// Forward to Shazam handler if active
|
||||
processor.shazamHandler?(bufferListInOut, numberFramesOut.pointee)
|
||||
|
||||
// Debug dump if enabled
|
||||
if processor.debugDumpEnabled {
|
||||
processor.debugWriteSamples(floatPtr, count: frameCount)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import Combine
|
|||
struct MainTabView: View {
|
||||
@EnvironmentObject var audioPlayer: AudioPlayer
|
||||
@ObservedObject private var debugLogger = DebugLogger.shared
|
||||
@ObservedObject private var queueSync = PlayQueueSyncManager.shared
|
||||
@State private var selectedTab = 0
|
||||
@State private var navigateToPlaylistId: String?
|
||||
@State private var navigateToAlbumId: String?
|
||||
|
|
@ -48,7 +49,8 @@ struct MainTabView: View {
|
|||
MyMusicView(
|
||||
navigateToPlaylistId: $navigateToPlaylistId,
|
||||
navigateToAlbumId: $navigateToAlbumId,
|
||||
navigateToArtistId: $navigateToArtistId
|
||||
navigateToArtistId: $navigateToArtistId,
|
||||
isDynamicIsland: $isDynamicIsland
|
||||
)
|
||||
.tabItem {
|
||||
Image(systemName: "music.note")
|
||||
|
|
@ -91,6 +93,51 @@ struct MainTabView: View {
|
|||
Color.clear.frame(height: 80)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
// Cross-device queue resume banner
|
||||
if queueSync.hasPendingRestore, let pq = queueSync.serverQueue {
|
||||
let songCount = pq.entry?.count ?? 0
|
||||
let device = pq.changedBy ?? "another device"
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(accentPink)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Resume from \(device)?")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Text("\(songCount) song\(songCount == 1 ? "" : "s") in queue")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { queueSync.restoreFromServer() }) {
|
||||
Text("Resume")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(accentPink)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
Button(action: { queueSync.dismissRestore() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.animation(.easeInOut, value: queueSync.hasPendingRestore)
|
||||
}
|
||||
}
|
||||
|
||||
// Debug panel (docked) — only when enabled and NOT in PiP mode
|
||||
if debugLogger.isEnabled && !debugPipMode {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ struct BatchAlbumEditorSheet: View {
|
|||
@State private var progress: Double = 0
|
||||
@State private var resultMessage: String?
|
||||
@State private var failedTracks: [String] = []
|
||||
@State private var lastBatchId: String?
|
||||
@State private var isUndoing = false
|
||||
|
||||
@ObservedObject private var settings = CompanionSettings.shared
|
||||
|
||||
|
|
@ -235,6 +237,38 @@ struct BatchAlbumEditorSheet: View {
|
|||
.font(.system(size: 11))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
// Undo button — visible when a batch_id was returned
|
||||
if let batchId = lastBatchId, failedTracks.isEmpty {
|
||||
Button(action: {
|
||||
Task {
|
||||
isUndoing = true
|
||||
do {
|
||||
let r = try await api.undoBatchEdit(batchId: batchId)
|
||||
let restored = r.restored?.count ?? 0
|
||||
resultMessage = "↩ Reverted \(restored) tracks to previous tags"
|
||||
lastBatchId = nil
|
||||
} catch {
|
||||
resultMessage = "Undo failed: \(error.localizedDescription)"
|
||||
}
|
||||
isUndoing = false
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
if isUndoing {
|
||||
ProgressView().tint(accentPink).scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.uturn.backward")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
Text("Undo")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(accentPink)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.disabled(isUndoing)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -363,6 +397,7 @@ struct BatchAlbumEditorSheet: View {
|
|||
await MainActor.run {
|
||||
progress = 1.0
|
||||
isSaving = false
|
||||
lastBatchId = result.batch_id
|
||||
let succeeded = result.succeeded?.count ?? 0
|
||||
let failed = result.failed ?? []
|
||||
if failed.isEmpty {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,116 @@ struct UploadMetadata {
|
|||
var preserveLyrics: Bool = false
|
||||
}
|
||||
|
||||
// MARK: - Batch Edit Result
|
||||
|
||||
struct BatchEditResult: Codable {
|
||||
let succeeded: [String]?
|
||||
let failed: [BatchEditFailure]?
|
||||
let batch_id: String?
|
||||
}
|
||||
struct BatchEditFailure: Codable {
|
||||
let path: String
|
||||
let error: String
|
||||
}
|
||||
|
||||
// MARK: - Edit History Models
|
||||
|
||||
struct EditHistoryResponse: Codable {
|
||||
let batches: [EditHistoryEntry]
|
||||
}
|
||||
|
||||
struct EditHistoryEntry: Codable, Identifiable {
|
||||
let batch_id: String
|
||||
let timestamp: String
|
||||
let file_count: Int
|
||||
let tags_changed: [String: String]?
|
||||
let affected_albums: [String]?
|
||||
let affected_artists: [String]?
|
||||
let edit_type: String?
|
||||
let is_reverted: Bool?
|
||||
let reverted_at: String?
|
||||
|
||||
var id: String { batch_id }
|
||||
|
||||
/// Parse the Python UTC timestamp (which lacks timezone suffix).
|
||||
private var parsedDate: Date? {
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = iso.date(from: timestamp) { return d }
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
if let d = iso.date(from: timestamp) { return d }
|
||||
if !timestamp.hasSuffix("Z") && !timestamp.contains("+") {
|
||||
if let d = iso.date(from: timestamp + "Z") { return d }
|
||||
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = iso.date(from: timestamp + "Z") { return d }
|
||||
}
|
||||
let df = DateFormatter()
|
||||
df.locale = Locale(identifier: "en_US_POSIX")
|
||||
df.timeZone = TimeZone(identifier: "UTC")
|
||||
for fmt in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS", "yyyy-MM-dd'T'HH:mm:ss"] {
|
||||
df.dateFormat = fmt
|
||||
if let d = df.date(from: timestamp) { return d }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var timeAgo: String {
|
||||
guard let date = parsedDate else { return timestamp }
|
||||
let interval = Date().timeIntervalSince(date)
|
||||
if interval < 60 { return "just now" }
|
||||
if interval < 3600 { return "\(Int(interval / 60))m ago" }
|
||||
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
|
||||
if interval < 604800 { return "\(Int(interval / 86400))d ago" }
|
||||
let df = DateFormatter()
|
||||
df.dateStyle = .short
|
||||
return df.string(from: date)
|
||||
}
|
||||
|
||||
var sectionDate: String {
|
||||
guard let date = parsedDate else { return "Unknown" }
|
||||
if Calendar.current.isDateInToday(date) { return "Today" }
|
||||
if Calendar.current.isDateInYesterday(date) { return "Yesterday" }
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "EEEE, MMM d"
|
||||
return df.string(from: date)
|
||||
}
|
||||
|
||||
var changeSummary: String {
|
||||
guard let changes = tags_changed, !changes.isEmpty else { return "Tags edited" }
|
||||
if changes.count == 1, let (key, val) = changes.first {
|
||||
return "\(key.capitalized) → \(val)"
|
||||
}
|
||||
return changes.keys.map { $0.capitalized }.joined(separator: ", ") + " edited"
|
||||
}
|
||||
|
||||
var editTitle: String {
|
||||
guard let changes = tags_changed, !changes.isEmpty else { return "Tag Edit" }
|
||||
if changes.count == 1, let key = changes.keys.first {
|
||||
return "\(key.capitalized) Edit"
|
||||
}
|
||||
return "Multi-field Edit"
|
||||
}
|
||||
|
||||
var contextString: String {
|
||||
let artists = affected_artists ?? []
|
||||
let albums = affected_albums ?? []
|
||||
if !artists.isEmpty {
|
||||
let display = artists.prefix(3).joined(separator: ", ")
|
||||
return artists.count > 3 ? "\(display)…" : display
|
||||
}
|
||||
if !albums.isEmpty {
|
||||
let display = albums.prefix(2).joined(separator: ", ")
|
||||
return albums.count > 2 ? "\(display)…" : display
|
||||
}
|
||||
return "\(file_count) file\(file_count == 1 ? "" : "s")"
|
||||
}
|
||||
}
|
||||
|
||||
struct UndoResult: Codable {
|
||||
let restored: [String]?
|
||||
let failed: [BatchEditFailure]?
|
||||
}
|
||||
|
||||
// MARK: - Companion API Service
|
||||
|
||||
actor CompanionAPIService {
|
||||
|
|
@ -316,15 +426,6 @@ actor CompanionAPIService {
|
|||
|
||||
// MARK: - Batch Edit Metadata (PATCH /batch-edit-metadata)
|
||||
|
||||
struct BatchEditResult: Codable {
|
||||
let succeeded: [String]?
|
||||
let failed: [BatchEditFailure]?
|
||||
}
|
||||
struct BatchEditFailure: Codable {
|
||||
let path: String
|
||||
let error: String
|
||||
}
|
||||
|
||||
/// Edit the same tags on multiple files in a single request + single Navidrome scan.
|
||||
func batchEditMetadata(_ request: BatchMetadataEditRequest) async throws -> BatchEditResult {
|
||||
let base = try baseURL()
|
||||
|
|
@ -348,6 +449,44 @@ actor CompanionAPIService {
|
|||
return try JSONDecoder().decode(BatchEditResult.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Edit History & Undo
|
||||
|
||||
/// Fetch list of recent batch/single edits with undo capability.
|
||||
func fetchEditHistory() async throws -> [EditHistoryEntry] {
|
||||
let base = try baseURL()
|
||||
let url = base.appendingPathComponent("batch-edit-history")
|
||||
let (data, response) = try await session.data(from: url)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
throw CompanionError.invalidResponse
|
||||
}
|
||||
let decoded = try JSONDecoder().decode(EditHistoryResponse.self, from: data)
|
||||
return decoded.batches
|
||||
}
|
||||
|
||||
/// Undo a batch edit by restoring all files to their pre-edit tags.
|
||||
/// Returns (restored count, failed count).
|
||||
func undoBatchEdit(batchId: String) async throws -> UndoResult {
|
||||
let base = try baseURL()
|
||||
let url = base.appendingPathComponent("undo-batch-edit/\(batchId)")
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse }
|
||||
|
||||
if http.statusCode == 409 {
|
||||
let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"]
|
||||
?? "Already reverted"
|
||||
throw CompanionError.serverErrorDetail(409, detail)
|
||||
}
|
||||
if !(200...299).contains(http.statusCode) {
|
||||
let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"]
|
||||
?? String(data: data, encoding: .utf8)
|
||||
?? "Unknown error"
|
||||
throw CompanionError.serverErrorDetail(http.statusCode, detail)
|
||||
}
|
||||
return try JSONDecoder().decode(UndoResult.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Upload Track (POST /upload-track)
|
||||
|
||||
func uploadTrack(fileURL: URL, metadata: UploadMetadata) async throws {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import SwiftUI
|
|||
// MARK: - Edit History View
|
||||
|
||||
struct EditHistoryView: View {
|
||||
@State private var entries: [CompanionAPIService.EditHistoryEntry] = []
|
||||
@State private var entries: [EditHistoryEntry] = []
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
@State private var revertingId: String?
|
||||
@State private var confirmRevert: CompanionAPIService.EditHistoryEntry?
|
||||
@State private var confirmRevert: EditHistoryEntry?
|
||||
@State private var undoResult: (restored: Int, failed: Int)?
|
||||
@State private var undoError: String?
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ struct EditHistoryView: View {
|
|||
return result
|
||||
}
|
||||
|
||||
private func entriesForSection(_ section: String) -> [CompanionAPIService.EditHistoryEntry] {
|
||||
private func entriesForSection(_ section: String) -> [EditHistoryEntry] {
|
||||
entries.filter { $0.sectionDate == section }
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ struct EditHistoryView: View {
|
|||
isLoading = false
|
||||
}
|
||||
|
||||
private func performRevert(_ entry: CompanionAPIService.EditHistoryEntry) async {
|
||||
private func performRevert(_ entry: EditHistoryEntry) async {
|
||||
revertingId = entry.batch_id
|
||||
do {
|
||||
let result = try await api.undoBatchEdit(batchId: entry.batch_id)
|
||||
|
|
@ -177,7 +177,7 @@ struct EditHistoryView: View {
|
|||
// MARK: - Edit History Card
|
||||
|
||||
private struct EditHistoryCard: View {
|
||||
let entry: CompanionAPIService.EditHistoryEntry
|
||||
let entry: EditHistoryEntry
|
||||
let isReverting: Bool
|
||||
let onRevert: () -> Void
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ struct MultiAlbumEditorSheet: View {
|
|||
@State private var progress: Double = 0
|
||||
@State private var resultMessage: String?
|
||||
@State private var failedTracks: [String] = []
|
||||
@State private var lastBatchId: String?
|
||||
@State private var isUndoing = false
|
||||
|
||||
@ObservedObject private var settings = CompanionSettings.shared
|
||||
private let api = CompanionAPIService.shared
|
||||
|
|
@ -313,6 +315,37 @@ struct MultiAlbumEditorSheet: View {
|
|||
.font(.system(size: 11))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
if let batchId = lastBatchId, failedTracks.isEmpty {
|
||||
Button(action: {
|
||||
Task {
|
||||
isUndoing = true
|
||||
do {
|
||||
let r = try await CompanionAPIService.shared.undoBatchEdit(batchId: batchId)
|
||||
let restored = r.restored?.count ?? 0
|
||||
resultMessage = "↩ Reverted \(restored) tracks to previous tags"
|
||||
lastBatchId = nil
|
||||
} catch {
|
||||
resultMessage = "Undo failed: \(error.localizedDescription)"
|
||||
}
|
||||
isUndoing = false
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
if isUndoing {
|
||||
ProgressView().tint(Color(red: 1, green: 0.176, blue: 0.333)).scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.uturn.backward")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
Text("Undo")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(Color(red: 1, green: 0.176, blue: 0.333))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.disabled(isUndoing)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -423,6 +456,7 @@ struct MultiAlbumEditorSheet: View {
|
|||
await MainActor.run {
|
||||
progress = 1.0
|
||||
isSaving = false
|
||||
lastBatchId = result.batch_id
|
||||
let succeeded = result.succeeded?.count ?? 0
|
||||
let failed = result.failed ?? []
|
||||
if failed.isEmpty {
|
||||
|
|
|
|||
|
|
@ -1078,6 +1078,26 @@ struct LibraryConflictsView: View {
|
|||
|
||||
var body: some View {
|
||||
List {
|
||||
// Edit History link — always visible at top
|
||||
Section {
|
||||
NavigationLink {
|
||||
EditHistoryView()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(accentPink)
|
||||
.frame(width: 28)
|
||||
Text("Edit History")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
Spacer()
|
||||
Text("View & revert")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab picker
|
||||
Section {
|
||||
Picker("", selection: $selectedTab) {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,11 @@ struct MyMusicView: View {
|
|||
@Binding var navigateToPlaylistId: String?
|
||||
@Binding var navigateToAlbumId: String?
|
||||
@Binding var navigateToArtistId: String?
|
||||
@Binding var isDynamicIsland: Bool
|
||||
|
||||
@State private var recentAlbums: [Album] = []
|
||||
@State private var recentlyPlayedAlbums: [Album] = []
|
||||
@State private var randomAlbums: [Album] = []
|
||||
@State private var allAlbums: [Album] = []
|
||||
@State private var allSongs: [Song] = []
|
||||
@State private var songsLoaded = false
|
||||
|
|
@ -62,6 +65,7 @@ struct MyMusicView: View {
|
|||
|
||||
enum SortMode: String, CaseIterable {
|
||||
case recentlyAdded = "Recently Added"
|
||||
case recentlyPlayed = "Recently Played"
|
||||
case favourites = "Favourites"
|
||||
case artists = "Artists"
|
||||
case albums = "Albums"
|
||||
|
|
@ -84,6 +88,9 @@ struct MyMusicView: View {
|
|||
if mode == .favourites {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 11))
|
||||
} else if mode == .recentlyPlayed {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
Text(mode == .favourites ? "Favourites" : mode.rawValue)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
|
|
@ -100,8 +107,8 @@ struct MyMusicView: View {
|
|||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// Search bar (for all tabs except Recently Added)
|
||||
if sortMode != .recentlyAdded {
|
||||
// Search bar (for all tabs except Recently Added and Recently Played)
|
||||
if sortMode != .recentlyAdded && sortMode != .recentlyPlayed {
|
||||
searchBar
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +117,7 @@ struct MyMusicView: View {
|
|||
VStack(alignment: .leading, spacing: 0) {
|
||||
switch sortMode {
|
||||
case .recentlyAdded: recentlyAddedTab
|
||||
case .recentlyPlayed: recentlyPlayedTab
|
||||
case .favourites: favouritesTab
|
||||
case .artists: artistsTab
|
||||
case .albums: albumsTab
|
||||
|
|
@ -155,7 +163,7 @@ struct MyMusicView: View {
|
|||
.foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink)
|
||||
}
|
||||
.disabled(selectedAlbumIds.isEmpty)
|
||||
} else {
|
||||
} else if !isDynamicIsland {
|
||||
Button(action: { showServerPicker = true }) {
|
||||
Image(systemName: "server.rack")
|
||||
.foregroundColor(accentPink)
|
||||
|
|
@ -278,10 +286,249 @@ struct MyMusicView: View {
|
|||
private var recentlyAddedTab: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
recentlyAddedSection
|
||||
discoverSection
|
||||
playlistsSection
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recently Played Tab
|
||||
|
||||
private var recentlyPlayedTab: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Recently Played")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
|
||||
if recentlyPlayedAlbums.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.gray)
|
||||
Text("No recently played albums")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.gray)
|
||||
Text("Albums will appear here as you listen.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color(white: 0.4))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
} else {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(recentlyPlayedAlbums) { album in
|
||||
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
||||
HStack(spacing: 12) {
|
||||
AsyncCoverArt(coverArtId: album.coverArt, size: 60)
|
||||
.frame(width: 56, height: 56)
|
||||
.cornerRadius(4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(album.name)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Text(album.artist ?? "")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { playAlbum(album) }) {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(accentPink)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: { playAlbum(album) }) {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
}
|
||||
Button(action: { playAlbumNext(album) }) {
|
||||
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if recentlyPlayedAlbums.isEmpty {
|
||||
await loadRecentlyPlayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRecentlyPlayed() async {
|
||||
do {
|
||||
let albums = try await serverManager.client.getAlbumList2(type: "recent", size: 30)
|
||||
await MainActor.run { recentlyPlayedAlbums = albums }
|
||||
} catch {
|
||||
print("[MyMusicView] Failed to load recently played: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Discover Section
|
||||
|
||||
private var discoverSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("Discover")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Button(action: { Task { await refreshRandom() } }) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(accentPink)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
|
||||
// Random Albums horizontal scroll
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 14) {
|
||||
// Shuffle All button
|
||||
Button(action: { Task { await playRandomMix() } }) {
|
||||
VStack(spacing: 8) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [accentPink.opacity(0.3), accentPink.opacity(0.1)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 140, height: 140)
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "shuffle")
|
||||
.font(.system(size: 28, weight: .medium))
|
||||
.foregroundColor(accentPink)
|
||||
Text("Shuffle All")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(accentPink)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("I'm Feeling Lucky")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Text("Random mix")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(width: 140)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Random albums
|
||||
ForEach(deduplicateAlbums(randomAlbums)) { album in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
||||
AsyncCoverArt(coverArtId: album.coverArt, size: 140)
|
||||
.frame(width: 140, height: 140)
|
||||
.cornerRadius(4)
|
||||
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
||||
}
|
||||
|
||||
Button(action: { playAlbum(album) }) {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.white)
|
||||
.shadow(radius: 4)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(album.name)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Text(album.artist ?? "")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 140)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
// Genre quick-play chips
|
||||
if !genres.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(genres.prefix(12)) { genre in
|
||||
Button(action: { Task { await playGenreMix(genre.value) } }) {
|
||||
Text(genre.value)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.08))
|
||||
.cornerRadius(14)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshRandom() async {
|
||||
do {
|
||||
let albums = try await serverManager.client.getAlbumList2(type: "random", size: 10)
|
||||
await MainActor.run { randomAlbums = albums }
|
||||
} catch {
|
||||
print("[MyMusicView] Failed to load random albums: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func playRandomMix() async {
|
||||
do {
|
||||
let songs = try await serverManager.client.getRandomSongs(size: 50)
|
||||
if !songs.isEmpty {
|
||||
AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0)
|
||||
}
|
||||
} catch {
|
||||
print("[MyMusicView] Failed to play random mix: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func playGenreMix(_ genre: String) async {
|
||||
do {
|
||||
let songs = try await serverManager.client.getRandomSongs(size: 50, genre: genre)
|
||||
if !songs.isEmpty {
|
||||
AudioPlayer.shared.play(song: songs[0], fromQueue: songs, at: 0)
|
||||
}
|
||||
} catch {
|
||||
print("[MyMusicView] Failed to play genre mix \(genre): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private var recentlyAddedSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
|
|
@ -1202,6 +1449,11 @@ struct MyMusicView: View {
|
|||
if favouriteSongs.isEmpty, let v = cache.load([Song].self, key: "starred_songs") { favouriteSongs = v }
|
||||
|
||||
isLoading = false
|
||||
|
||||
// Load random albums for Discover section (always fresh, not cached)
|
||||
if randomAlbums.isEmpty {
|
||||
Task { await refreshRandom() }
|
||||
}
|
||||
|
||||
// If the cache was empty (first launch or after a clear), kick off a sync
|
||||
// and wait for it to fill the cache, then reload.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ final class LyricsManager: ObservableObject {
|
|||
/// Whether the lyrics overlay is visible in Now Playing.
|
||||
@Published var isLyricsVisible = false
|
||||
|
||||
/// Global timing offset in seconds. Positive = lyrics appear later, negative = earlier.
|
||||
/// Applied in syncToTime and wordProgress — instant feedback without editing timestamps.
|
||||
@Published var globalOffset: Double = 0
|
||||
|
||||
// MARK: - Non-Published State (polled per-frame by views)
|
||||
|
||||
/// Current word index within the active line. Updated per-frame when visible.
|
||||
|
|
@ -81,6 +85,7 @@ final class LyricsManager: ObservableObject {
|
|||
currentLineIndex = 0
|
||||
currentWordIndex = 0
|
||||
lastSyncedTime = 0
|
||||
globalOffset = 0
|
||||
isLyricsVisible = false
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +97,10 @@ final class LyricsManager: ObservableObject {
|
|||
lastSyncedTime = time
|
||||
guard let lyrics = currentLyrics, !lyrics.lines.isEmpty else { return }
|
||||
|
||||
let newIndex = findLineIndex(at: time, in: lyrics.lines)
|
||||
// Apply global offset: positive = lyrics later, so we look up an earlier time
|
||||
let adjusted = time - globalOffset
|
||||
|
||||
let newIndex = findLineIndex(at: adjusted, in: lyrics.lines)
|
||||
if newIndex != currentLineIndex {
|
||||
currentLineIndex = newIndex
|
||||
}
|
||||
|
|
@ -101,7 +109,7 @@ final class LyricsManager: ObservableObject {
|
|||
if isLyricsVisible,
|
||||
newIndex < lyrics.lines.count,
|
||||
let words = lyrics.lines[newIndex].words {
|
||||
currentWordIndex = findWordIndex(at: time, in: words)
|
||||
currentWordIndex = findWordIndex(at: adjusted, in: words)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,21 +120,24 @@ final class LyricsManager: ObservableObject {
|
|||
return (0, 0, 0)
|
||||
}
|
||||
|
||||
let lineIdx = findLineIndex(at: time, in: lyrics.lines)
|
||||
// Apply global offset
|
||||
let adjusted = time - globalOffset
|
||||
|
||||
let lineIdx = findLineIndex(at: adjusted, in: lyrics.lines)
|
||||
let line = lyrics.lines[lineIdx]
|
||||
|
||||
guard let words = line.words, !words.isEmpty else {
|
||||
return (lineIdx, 0, 0)
|
||||
}
|
||||
|
||||
let wordIdx = findWordIndex(at: time, in: words)
|
||||
let wordIdx = findWordIndex(at: adjusted, in: words)
|
||||
let word = words[min(wordIdx, words.count - 1)]
|
||||
let wordDuration = word.endTime - word.startTime
|
||||
let fraction: Double
|
||||
if wordDuration > 0 {
|
||||
fraction = min(max((time - word.startTime) / wordDuration, 0), 1)
|
||||
fraction = min(max((adjusted - word.startTime) / wordDuration, 0), 1)
|
||||
} else {
|
||||
fraction = time >= word.startTime ? 1 : 0
|
||||
fraction = adjusted >= word.startTime ? 1 : 0
|
||||
}
|
||||
|
||||
return (lineIdx, wordIdx, fraction)
|
||||
|
|
|
|||
|
|
@ -15,22 +15,28 @@ import SwiftUI
|
|||
struct LyricsOverlayView: View {
|
||||
@ObservedObject var lyricsManager = LyricsManager.shared
|
||||
@ObservedObject var audioPlayer = AudioPlayer.shared
|
||||
@State private var showTimingAdjust = false
|
||||
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
var body: some View {
|
||||
if let lyrics = lyricsManager.currentLyrics, !lyrics.lines.isEmpty {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
|
||||
let now = audioPlayer.currentTime
|
||||
let progress = lyricsManager.wordProgress(at: now)
|
||||
ZStack(alignment: .bottom) {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !audioPlayer.isPlaying)) { timeline in
|
||||
let now = audioPlayer.currentTime
|
||||
let progress = lyricsManager.wordProgress(at: now)
|
||||
|
||||
lyricsContent(
|
||||
lyrics: lyrics,
|
||||
currentTime: now,
|
||||
lineIndex: progress.lineIndex,
|
||||
wordIndex: progress.wordIndex,
|
||||
wordFraction: progress.wordFraction
|
||||
)
|
||||
}
|
||||
|
||||
lyricsContent(
|
||||
lyrics: lyrics,
|
||||
currentTime: now,
|
||||
lineIndex: progress.lineIndex,
|
||||
wordIndex: progress.wordIndex,
|
||||
wordFraction: progress.wordFraction
|
||||
)
|
||||
// Bottom toolbar — search, timing, edit
|
||||
lyricsToolbar(hasSynced: lyrics.hasSynced)
|
||||
}
|
||||
} else if lyricsManager.isLoading {
|
||||
VStack {
|
||||
|
|
@ -55,7 +61,6 @@ struct LyricsOverlayView: View {
|
|||
.padding(.top, 8)
|
||||
|
||||
Button {
|
||||
// Open search sheet
|
||||
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
|
||||
} label: {
|
||||
Text("Search Lyrics")
|
||||
|
|
@ -73,6 +78,118 @@ struct LyricsOverlayView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Lyrics Toolbar
|
||||
|
||||
@ViewBuilder
|
||||
private func lyricsToolbar(hasSynced: Bool) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
// Timing adjustment (expandable)
|
||||
if showTimingAdjust && hasSynced {
|
||||
HStack(spacing: 12) {
|
||||
Button { lyricsManager.globalOffset -= 0.5 } label: {
|
||||
Text("-0.5s")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
Button { lyricsManager.globalOffset -= 0.1 } label: {
|
||||
Text("-0.1s")
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.06))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
Text(String(format: "%+.1fs", lyricsManager.globalOffset))
|
||||
.font(.system(size: 15, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(lyricsManager.globalOffset == 0 ? .white.opacity(0.5) : accentPink)
|
||||
.frame(width: 60)
|
||||
.onTapGesture { lyricsManager.globalOffset = 0 }
|
||||
|
||||
Button { lyricsManager.globalOffset += 0.1 } label: {
|
||||
Text("+0.1s")
|
||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.06))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
Button { lyricsManager.globalOffset += 0.5 } label: {
|
||||
Text("+0.5s")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 16)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// Main toolbar buttons
|
||||
HStack(spacing: 0) {
|
||||
// Re-search lyrics
|
||||
Button {
|
||||
NotificationCenter.default.post(name: .openLyricsSearch, object: nil)
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 16))
|
||||
Text("Search")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Timing offset toggle (only for synced lyrics)
|
||||
if hasSynced {
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3)) { showTimingAdjust.toggle() }
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 16))
|
||||
Text("Timing")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
}
|
||||
.foregroundColor(showTimingAdjust ? accentPink : .white.opacity(0.6))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// Open full editor
|
||||
Button {
|
||||
NotificationCenter.default.post(name: .openLyricsEditor, object: nil)
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: "pencil.line")
|
||||
.font(.system(size: 16))
|
||||
Text("Edit")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lyrics Content
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -258,4 +375,5 @@ struct FlowLayout: Layout {
|
|||
|
||||
extension Notification.Name {
|
||||
static let openLyricsSearch = Notification.Name("openLyricsSearch")
|
||||
static let openLyricsEditor = Notification.Name("openLyricsEditor")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -355,6 +355,9 @@ struct NowPlayingView: View {
|
|||
.onReceive(NotificationCenter.default.publisher(for: .openLyricsSearch)) { _ in
|
||||
showLyricsSearch = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .openLyricsEditor)) { _ in
|
||||
showLyricsEditor = true
|
||||
}
|
||||
.onAppear {
|
||||
dragOffset = 0
|
||||
isStarred = audioPlayer.currentSong?.starred != nil
|
||||
|
|
@ -1564,8 +1567,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
private var timeoutTask: Task<Void, Never>?
|
||||
private var hasDeliveredResult = false
|
||||
|
||||
// Tap state — MTAudioProcessingTap is a CF type, ARC manages it directly
|
||||
private var tap: MTAudioProcessingTap?
|
||||
// Tap state — uses shared AudioTapProcessor (no longer owns its own tap)
|
||||
private var converter: AVAudioConverter?
|
||||
private var sourceFormat: AVAudioFormat?
|
||||
private let targetFormat = AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)!
|
||||
|
|
@ -1592,14 +1594,40 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
|
||||
DebugLogger.shared.log("Shazam: starting recognition", category: "Audio")
|
||||
|
||||
// Prefer tap on AVPlayer — async track load, then install tap on main actor
|
||||
if let playerItem = AudioPlayer.shared.currentPlayerItem {
|
||||
// Use the shared AudioTapProcessor — piggyback on FFT tap if active,
|
||||
// or install a new one. Fixes the old bug where Shazam's own tap
|
||||
// would overwrite the FFT visualizer tap (or vice versa).
|
||||
if AudioPlayer.shared.currentPlayerItem != nil {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await self.installTap(on: playerItem) {
|
||||
|
||||
let tapProc = AudioTapProcessor.shared
|
||||
// Ensure the shared tap is installed (may already be active for FFT)
|
||||
if tapProc.sourceFormat == nil,
|
||||
let item = AudioPlayer.shared.currentPlayerItem {
|
||||
let _ = await tapProc.installTap(on: item)
|
||||
}
|
||||
|
||||
// tapPrepare callback fires async on the audio thread after installTap.
|
||||
// Wait briefly for sourceFormat to be populated — without this,
|
||||
// sourceFormat is often nil and Shazam falls back to mic unnecessarily.
|
||||
if tapProc.sourceFormat == nil {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
|
||||
if let srcFormat = tapProc.sourceFormat {
|
||||
self.sourceFormat = srcFormat
|
||||
self.converter = AVAudioConverter(from: srcFormat, to: self.targetFormat)
|
||||
|
||||
// Subscribe to the shared tap — receives buffers from render thread
|
||||
tapProc.shazamHandler = { [weak self] bufferList, frameCount in
|
||||
guard let self else { return }
|
||||
self.processTapBuffer(bufferList, frameCount: frameCount)
|
||||
}
|
||||
self.startTimeout(seconds: 15)
|
||||
DebugLogger.shared.log("Shazam: tap installed on AVPlayerItem", category: "Audio")
|
||||
DebugLogger.shared.log("Shazam: subscribed to shared audio tap (\(Int(srcFormat.sampleRate))Hz)", category: "Audio")
|
||||
} else {
|
||||
DebugLogger.shared.log("Shazam: shared tap has no format — mic fallback", category: "Audio")
|
||||
self.startMicFallback()
|
||||
}
|
||||
}
|
||||
|
|
@ -1608,64 +1636,17 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - MTAudioProcessingTap
|
||||
// MARK: - Shared Tap Consumer
|
||||
|
||||
/// Async because `loadTracks(withMediaType:)` is the non-deprecated API (iOS 16+).
|
||||
@MainActor
|
||||
private func installTap(on playerItem: AVPlayerItem) async -> Bool {
|
||||
// loadTracks is the non-deprecated replacement for tracks(withMediaType:)
|
||||
guard let audioTrack = try? await playerItem.asset
|
||||
.loadTracks(withMediaType: .audio).first else {
|
||||
DebugLogger.shared.log("Shazam: no audio track on playerItem", category: "Audio")
|
||||
return false
|
||||
}
|
||||
|
||||
var callbacks = MTAudioProcessingTapCallbacks(
|
||||
version: kMTAudioProcessingTapCallbacksVersion_0,
|
||||
clientInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()),
|
||||
init: { tap, clientInfo, tapStorageOut in
|
||||
tapStorageOut.pointee = clientInfo
|
||||
},
|
||||
finalize: nil,
|
||||
prepare: { tap, _, processingFormat in
|
||||
let storage = Unmanaged<ShazamRecognizer>
|
||||
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
|
||||
.takeUnretainedValue()
|
||||
let format = AVAudioFormat(streamDescription: processingFormat)
|
||||
storage.analysisQueue.async {
|
||||
storage.sourceFormat = format
|
||||
if let src = format {
|
||||
storage.converter = AVAudioConverter(from: src, to: storage.targetFormat)
|
||||
}
|
||||
}
|
||||
},
|
||||
unprepare: nil,
|
||||
process: shazamTapProcess
|
||||
)
|
||||
|
||||
// MTAudioProcessingTap is a CF type — Swift bridges it as MTAudioProcessingTap?
|
||||
var tapOut: MTAudioProcessingTap?
|
||||
let status = MTAudioProcessingTapCreate(
|
||||
kCFAllocatorDefault, &callbacks,
|
||||
kMTAudioProcessingTapCreationFlag_PostEffects, &tapOut
|
||||
)
|
||||
guard status == noErr, let tapValue = tapOut else {
|
||||
DebugLogger.shared.log("Shazam: MTAudioProcessingTapCreate failed \(status)", category: "Audio")
|
||||
return false
|
||||
}
|
||||
|
||||
tap = tapValue // ARC owns it
|
||||
|
||||
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
|
||||
inputParams.audioTapProcessor = tapValue
|
||||
|
||||
let mix = AVMutableAudioMix()
|
||||
mix.inputParameters = [inputParams]
|
||||
playerItem.audioMix = mix
|
||||
return true
|
||||
private func removeTap() {
|
||||
// Unsubscribe from the shared tap — do NOT remove the audioMix
|
||||
// because the FFT visualizer may still be using it.
|
||||
AudioTapProcessor.shared.shazamHandler = nil
|
||||
converter = nil
|
||||
sourceFormat = nil
|
||||
}
|
||||
|
||||
/// Called from the C tap callback — dispatches to analysis queue to avoid blocking render thread.
|
||||
/// Called from the shared tap callback — dispatches to analysis queue to avoid blocking render thread.
|
||||
func processTapBuffer(_ bufferList: UnsafeMutablePointer<AudioBufferList>,
|
||||
frameCount: CMItemCount) {
|
||||
guard let session, let conv = converter,
|
||||
|
|
@ -1733,16 +1714,6 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private func removeTap() {
|
||||
// Removing the audioMix disconnects the tap cleanly
|
||||
if let item = AudioPlayer.shared.currentPlayerItem {
|
||||
item.audioMix = nil
|
||||
}
|
||||
tap = nil
|
||||
converter = nil
|
||||
sourceFormat = nil
|
||||
}
|
||||
|
||||
// MARK: - Microphone fallback (local engine / engine path)
|
||||
|
||||
private func startMicFallback() {
|
||||
|
|
@ -1832,6 +1803,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
private func stopAll() {
|
||||
timeoutTask?.cancel(); timeoutTask = nil
|
||||
removeTap()
|
||||
let usedMic = audioEngine != nil
|
||||
if let engine = audioEngine {
|
||||
if engine.inputNode.numberOfInputs > 0 {
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
|
|
@ -1842,7 +1814,7 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
session = nil
|
||||
|
||||
// Restore audio session only if we used the mic fallback
|
||||
if audioEngine != nil {
|
||||
if usedMic {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
}
|
||||
|
|
@ -1854,29 +1826,6 @@ class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - MTAudioProcessingTap C callback (must be a free function)
|
||||
/// Must be a C-style free function — cannot be a method or closure.
|
||||
private func shazamTapProcess(
|
||||
tap: MTAudioProcessingTap,
|
||||
numberFrames: CMItemCount,
|
||||
flags: MTAudioProcessingTapFlags,
|
||||
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
|
||||
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
||||
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
|
||||
) {
|
||||
// Fetch audio from source — passes samples through to the player unchanged
|
||||
let status = MTAudioProcessingTapGetSourceAudio(
|
||||
tap, numberFrames, bufferListInOut, flagsOut, nil, numberFramesOut)
|
||||
guard status == noErr else { return }
|
||||
|
||||
// Forward to the recognizer on the analysis queue — never block this render thread
|
||||
let recognizer = Unmanaged<ShazamRecognizer>
|
||||
.fromOpaque(MTAudioProcessingTapGetStorage(tap))
|
||||
.takeUnretainedValue()
|
||||
recognizer.processTapBuffer(bufferListInOut, frameCount: numberFramesOut.pointee)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Shazam Result Sheet
|
||||
|
||||
|
|
@ -1972,6 +1921,10 @@ struct ShazamResultSheet: View {
|
|||
struct SongInfoSheet: View {
|
||||
let song: Song
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var shareURL: String?
|
||||
@State private var isCreatingShare = false
|
||||
@State private var shareError: String?
|
||||
@State private var showCopiedToast = false
|
||||
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
|
|
@ -2011,6 +1964,73 @@ struct SongInfoSheet: View {
|
|||
let isDownloaded = OfflineManager.shared.isSongDownloaded(song.id)
|
||||
infoRow("Downloaded", isDownloaded ? "Yes" : "No")
|
||||
}
|
||||
|
||||
// Share Link
|
||||
Section("SHARE") {
|
||||
if let url = shareURL {
|
||||
HStack {
|
||||
Text(url)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(accentPink)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = url
|
||||
withAnimation { showCopiedToast = true }
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
withAnimation { showCopiedToast = false }
|
||||
}
|
||||
}) {
|
||||
Image(systemName: showCopiedToast ? "checkmark" : "doc.on.clipboard")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(showCopiedToast ? .green : accentPink)
|
||||
}
|
||||
}
|
||||
|
||||
// System share sheet
|
||||
if let shareLink = URL(string: url) {
|
||||
ShareLink(item: shareLink) {
|
||||
HStack {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 14))
|
||||
Text("Share via…")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(accentPink)
|
||||
}
|
||||
}
|
||||
} else if isCreatingShare {
|
||||
HStack {
|
||||
ProgressView().tint(accentPink).scaleEffect(0.8)
|
||||
Text("Creating share link…")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.gray)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
} else if let error = shareError {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(error)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.red)
|
||||
Button("Try Again") {
|
||||
Task { await createShareLink() }
|
||||
}
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(accentPink)
|
||||
}
|
||||
} else {
|
||||
Button(action: { Task { await createShareLink() } }) {
|
||||
HStack {
|
||||
Image(systemName: "link.badge.plus")
|
||||
.font(.system(size: 14))
|
||||
Text("Create Share Link")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(accentPink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Get Info")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
@ -2022,6 +2042,28 @@ struct SongInfoSheet: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func createShareLink() async {
|
||||
isCreatingShare = true
|
||||
shareError = nil
|
||||
do {
|
||||
// Expires in 7 days (ms since epoch)
|
||||
let expires = Int64((Date().timeIntervalSince1970 + 7 * 86400) * 1000)
|
||||
let share = try await ServerManager.shared.client.createShare(
|
||||
id: song.id,
|
||||
description: "\(song.title) — \(song.artist ?? "Unknown")",
|
||||
expires: expires
|
||||
)
|
||||
if let url = share?.url {
|
||||
shareURL = url
|
||||
} else {
|
||||
shareError = "Server returned no share URL. Sharing may be disabled."
|
||||
}
|
||||
} catch {
|
||||
shareError = error.localizedDescription
|
||||
}
|
||||
isCreatingShare = false
|
||||
}
|
||||
|
||||
private func infoRow(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label).foregroundColor(.gray)
|
||||
|
|
|
|||
|
|
@ -912,6 +912,9 @@ struct CompactVisualizerView: View {
|
|||
struct VisualizerSettingsView: View {
|
||||
@ObservedObject var settings = VisualizerSettings.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isCapturingTap = false
|
||||
@State private var showShareSheet = false
|
||||
@State private var capturedFileURL: URL?
|
||||
|
||||
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
|
|
@ -1042,6 +1045,46 @@ struct VisualizerSettingsView: View {
|
|||
}
|
||||
|
||||
Section { Button("Reset to Defaults", role: .destructive) { resetDefaults() } }
|
||||
|
||||
// ── Debug: Audio Tap Capture ─────────────────────────────────
|
||||
Section {
|
||||
if isCapturingTap {
|
||||
HStack {
|
||||
ProgressView().tint(pink)
|
||||
Text("Capturing 5s of audio tap…")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.gray)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
} else {
|
||||
Button(action: startTapCapture) {
|
||||
HStack {
|
||||
Image(systemName: "waveform.badge.mic")
|
||||
.foregroundColor(.orange)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Capture Audio Tap").foregroundColor(.white)
|
||||
Text("Records 5s of raw tap output as WAV")
|
||||
.font(.caption2).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!AudioPlayer.shared.isRadioStream)
|
||||
}
|
||||
|
||||
if let fmt = AudioTapProcessor.shared.sourceFormat {
|
||||
HStack {
|
||||
Text("Tap format")
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Text("\(Int(fmt.sampleRate))Hz \(fmt.channelCount)ch")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
} header: { Text("DEBUG: AUDIO TAP") } footer: {
|
||||
Text("Captures raw PCM from MTAudioProcessingTap as a playable WAV file. Only active during radio playback. Use to verify the tap is receiving real audio data.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Visualizer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
@ -1050,8 +1093,34 @@ struct VisualizerSettingsView: View {
|
|||
Button("Done") { dismiss() }.foregroundColor(pink)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let url = capturedFileURL {
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Bug 12 fix: reset stuck spinner if capture was interrupted by background
|
||||
if isCapturingTap && !AudioTapProcessor.shared.debugDumpEnabled {
|
||||
isCapturingTap = false
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: AudioTapProcessor.captureCompleteNotification)) { note in
|
||||
// Bug 11 fix: notification-based completion — works even if view was dismissed and re-opened
|
||||
isCapturingTap = false
|
||||
if let url = note.userInfo?["url"] as? URL {
|
||||
capturedFileURL = url
|
||||
showShareSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tap Capture
|
||||
|
||||
private func startTapCapture() {
|
||||
isCapturingTap = true
|
||||
AudioTapProcessor.shared.startDebugDump()
|
||||
}
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ settings:
|
|||
base:
|
||||
SWIFT_VERSION: "5.9"
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
CURRENT_PROJECT_VERSION: "9"
|
||||
DEAD_CODE_STRIPPING: true
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: true
|
||||
DEVELOPMENT_TEAM: E9C9AGS9K6
|
||||
|
|
|
|||
Loading…
Reference in a new issue