From 218a897d4a9ea0f6f638fe37b529148802683e13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 01:07:02 +0000 Subject: [PATCH] Library toolbar rework + bulk local-file import into a Local Files playlist Removes the dead overflow ('...') toolbar item and replaces the plus button with two composed add buttons: plus+download opens the services importer (YouTube/Spotify links), plus+upload opens a Files picker that accepts audio files or whole folders. Picked folders are scanned recursively, filtering to audio types big enough to plausibly be songs, and everything imports into a shared "Local Files" playlist (deduped by title/artist/duration) with embedded metadata + artwork extraction and post-ready analysis. Ports the App Store branch's local-import foundation to main: Track.stemKey (video ID or UUID) so local tracks get stems/caches, Track.artworkPath + ArtworkStore for embedded art, and stem/cleanup paths rekeyed from youtubeVideoID to stemKey (existing YouTube stems keep their keys). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013TJoWkqg8bzkGdzxWhWjWP --- App/Continuity/Views/LibrarySheetView.swift | 69 ++++++-- App/Continuity/Views/LibraryView.swift | 4 +- App/Continuity/Views/PlaylistDetailView.swift | 6 +- .../ContinuityKit/Sources/Domain/Track.swift | 34 +++- .../Sources/Ingest/LibraryCleanup.swift | 63 ++++--- .../Ingest/PreparationQueue+LocalImport.swift | 167 ++++++++++++++++++ .../Ingest/PreparationQueue+Stems.swift | 11 +- .../Ingest/PreparationQueue+Sync.swift | 4 +- .../Sources/Ingest/PreparationQueue.swift | 2 +- 9 files changed, 303 insertions(+), 57 deletions(-) create mode 100644 Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift diff --git a/App/Continuity/Views/LibrarySheetView.swift b/App/Continuity/Views/LibrarySheetView.swift index 7bb4863..3f3f129 100644 --- a/App/Continuity/Views/LibrarySheetView.swift +++ b/App/Continuity/Views/LibrarySheetView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers import Ingest import Playback @@ -11,23 +12,17 @@ struct LibrarySheetView: View { @Environment(\.modelContext) private var modelContext @State private var showingAdd = false @State private var showingSearch = false + @State private var showingLocalImport = false + /// Non-nil while a picked folder/files are being scanned + copied in. + @State private var isImportingLocal = false var body: some View { NavigationStack { LibraryView() .navigationTitle("Continuity") .toolbar { - // Manual whole-library sync (source-backed playlists only). - ToolbarItem(placement: .secondaryAction) { - Button { - prepQueue.syncAll(in: modelContext) - } label: { - Image(systemName: "arrow.triangle.2.circlepath") - .symbolEffect(.rotate, isActive: !prepQueue.syncingPlaylistIDs.isEmpty) - } - .disabled(!prepQueue.syncingPlaylistIDs.isEmpty) - .accessibilityLabel("Sync library") - } + // Every action is a primaryAction so nothing collapses into a dead "…" + // overflow menu (secondaryAction items did, and looked broken). ToolbarItem(placement: .primaryAction) { Button { showingSearch = true @@ -36,13 +31,28 @@ struct LibrarySheetView: View { } .accessibilityLabel("Search music") } + // Add-by-download: import from other services (YouTube, Spotify links). ToolbarItem(placement: .primaryAction) { Button { showingAdd = true } label: { - Image(systemName: "plus") + AddBadgeIcon(base: "arrow.down") + } + .accessibilityLabel("Add from YouTube or Spotify") + } + // Add-by-upload: import songs from the user's own files. + ToolbarItem(placement: .primaryAction) { + Button { + showingLocalImport = true + } label: { + if isImportingLocal { + ProgressView() + } else { + AddBadgeIcon(base: "arrow.up") + } } - .accessibilityLabel("Add music") + .disabled(isImportingLocal) + .accessibilityLabel("Import local files") } } } @@ -53,6 +63,22 @@ struct LibrarySheetView: View { .fullScreenCover(isPresented: $showingSearch) { SearchView() } + // Local import: pick audio files OR a whole folder — folders are scanned recursively + // for music (audio type + song-sized) and imported in bulk into "Local Files". + // iOS sandboxing means the app can't read the Files "music folder" unprompted; a + // folder grant through this picker is the sanctioned way to scan it. + .fileImporter( + isPresented: $showingLocalImport, + allowedContentTypes: [.audio, .folder], + allowsMultipleSelection: true + ) { result in + guard case .success(let urls) = result, !urls.isEmpty else { return } + isImportingLocal = true + Task { + _ = await prepQueue.importLocalFiles(urls, in: modelContext) + isImportingLocal = false + } + } .safeAreaInset(edge: .bottom) { if player.currentTrack != nil { MiniPlayerView() @@ -78,3 +104,20 @@ struct LibrarySheetView: View { } } } + +/// A toolbar glyph composed of a base symbol (arrow.down / arrow.up) with a small plus badge — +/// "add by downloading" vs "add by uploading". SF Symbols has no built-in plus-badged arrows, +/// so the badge is drawn as an overlay. +private struct AddBadgeIcon: View { + let base: String + + var body: some View { + Image(systemName: base) + .overlay(alignment: .topTrailing) { + Image(systemName: "plus") + .font(.system(size: 8, weight: .heavy)) + .offset(x: 7, y: -4) + } + .padding(.trailing, 4) // room for the badge inside the tap target + } +} diff --git a/App/Continuity/Views/LibraryView.swift b/App/Continuity/Views/LibraryView.swift index 15f97d4..5101f32 100644 --- a/App/Continuity/Views/LibraryView.swift +++ b/App/Continuity/Views/LibraryView.swift @@ -87,11 +87,11 @@ struct LibraryView: View { /// surviving track shares. private func delete(_ playlist: Playlist) { let trackIDs = Set(playlist.tracks.map(\.id)) - let videoIDs = playlist.tracks.compactMap(\.youtubeVideoID) + let keys = playlist.tracks.map(\.stemKey) player.handleDeleted(trackIDs: trackIDs) modelContext.delete(playlist) // cascade deletes its tracks try? modelContext.save() - LibraryCleanup.removeOrphanedFiles(videoIDs: videoIDs, in: modelContext) + LibraryCleanup.removeOrphanedFiles(keys: keys, in: modelContext) } } diff --git a/App/Continuity/Views/PlaylistDetailView.swift b/App/Continuity/Views/PlaylistDetailView.swift index ebfcbd4..bb07157 100644 --- a/App/Continuity/Views/PlaylistDetailView.swift +++ b/App/Continuity/Views/PlaylistDetailView.swift @@ -58,14 +58,12 @@ struct PlaylistDetailView: View { /// Removes a track: the player drops it first (so no deck/queue reference dangles), then the /// model goes, then any cached files no other track shares. private func delete(_ track: Track) { - let videoID = track.youtubeVideoID + let key = track.stemKey player.handleDeleted(trackIDs: [track.id]) modelContext.delete(track) playlist.touch() // membership changed → resort the library try? modelContext.save() - if let videoID { - LibraryCleanup.removeOrphanedFiles(videoIDs: [videoID], in: modelContext) - } + LibraryCleanup.removeOrphanedFiles(keys: [key], in: modelContext) } private var header: some View { diff --git a/Packages/ContinuityKit/Sources/Domain/Track.swift b/Packages/ContinuityKit/Sources/Domain/Track.swift index 4d5ab90..d8ea107 100644 --- a/Packages/ContinuityKit/Sources/Domain/Track.swift +++ b/Packages/ContinuityKit/Sources/Domain/Track.swift @@ -8,6 +8,20 @@ public enum PrepState: String, Codable, Sendable { case pending, preparing, ready, failed } +/// On-disk store for artwork extracted from imported files' embedded metadata. Mirrors +/// `StemCache`: flat files in one Application Support directory, excluded from backup. +public enum ArtworkStore { + public static var directory: URL { + var dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Artwork", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? dir.setResourceValues(values) + return dir + } +} + @Model public final class Track { public var id: UUID @@ -66,12 +80,24 @@ public final class Track { /// A seeded demo track (no real source) — it plays synthesized tones, not real audio. Used to /// label the placeholder sample library so it isn't mistaken for real playback. - public var isDemo: Bool { youtubeVideoID == nil && searchQuery == nil } + public var isDemo: Bool { youtubeVideoID == nil && searchQuery == nil && localRelativePath == nil } + + /// Filename (relative to `ArtworkStore.directory`) of artwork extracted from an imported + /// file's embedded metadata; nil when the file carried none. + public var artworkPath: String? + + /// Stable key for the stem cache (and other per-source on-disk caches). YouTube-sourced + /// tracks keep their video ID (existing stems stay linked, and the same video shared + /// across playlists shares its caches); locally imported tracks use their own UUID. + public var stemKey: String { youtubeVideoID ?? id.uuidString } - /// Real cover art for YouTube-sourced tracks: the video's thumbnail, served from YouTube's - /// deterministic thumbnail CDN (no API call needed). nil for demo tracks → gradient artwork. + /// Real cover art: embedded artwork extracted at import, else the YouTube thumbnail for + /// YouTube-sourced tracks. nil → gradient placeholder artwork. public var artworkURL: URL? { - youtubeVideoID.flatMap { URL(string: "https://i.ytimg.com/vi/\($0)/hqdefault.jpg") } + if let artworkPath { + return ArtworkStore.directory.appendingPathComponent(artworkPath) + } + return youtubeVideoID.flatMap { URL(string: "https://i.ytimg.com/vi/\($0)/hqdefault.jpg") } } /// Inverse side of the Playlist ↔ Track relationship. diff --git a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift index ae0eb70..bf6ea13 100644 --- a/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift +++ b/Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift @@ -2,43 +2,31 @@ import Foundation import Domain import SwiftData -/// Removes cached files (downloaded audio + separated stems) for deleted tracks — but only when -/// no surviving track still references the same source video (the same video can appear in -/// several playlists; caches are keyed by video ID and shared). +/// Removes cached files (imported audio + separated stems + extracted artwork) for deleted +/// tracks — but only when no surviving track still shares the same stem key (a legacy +/// YouTube-sourced video can appear in several playlists; caches are keyed by `Track.stemKey` +/// and shared). /// /// Call AFTER the track models have been deleted and saved, so the reference check sees the /// post-deletion library. public enum LibraryCleanup { @MainActor - public static func removeOrphanedFiles(videoIDs: [String], in context: ModelContext) { - for videoID in Set(videoIDs) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.youtubeVideoID == videoID } - ) - let stillReferenced = ((try? context.fetchCount(descriptor)) ?? 0) > 0 - guard !stillReferenced else { continue } - - // Audio: the container extension varies (m4a/webm/…), so match by basename. - if let files = try? FileManager.default.contentsOfDirectory( - at: AudioCache.directory, includingPropertiesForKeys: nil - ) { - for file in files where file.deletingPathExtension().lastPathComponent == videoID { - try? FileManager.default.removeItem(at: file) - } - } - - StemCache.removeStems(key: videoID) + public static func removeOrphanedFiles(keys: [String], in context: ModelContext) { + let tracks = (try? context.fetch(FetchDescriptor())) ?? [] + let referenced = Set(tracks.map(\.stemKey)) + for key in Set(keys) where !referenced.contains(key) { + removeCachedFiles(key: key) } } - /// Launch-time sweep: removes any cached file whose video ID has no surviving track. Catches - /// downloads/stems that were still in flight when their tracks were deleted and landed on - /// disk after the delete-time cleanup had already run. + /// Launch-time sweep: removes any cached file whose stem key has no surviving track. + /// Catches files that were still being written when their tracks were deleted and landed + /// on disk after the delete-time cleanup had already run. @MainActor public static func sweepOrphanedFiles(in context: ModelContext) { guard let tracks = try? context.fetch(FetchDescriptor()) else { return } - let referenced = Set(tracks.compactMap(\.youtubeVideoID)) + let referenced = Set(tracks.map(\.stemKey)) if let files = try? FileManager.default.contentsOfDirectory( at: AudioCache.directory, includingPropertiesForKeys: nil @@ -53,7 +41,7 @@ public enum LibraryCleanup { ) { for file in files { // Stem names are "-vocals.*" / "-accompaniment.*". Suffix-only parsing - // (a video ID can contain "-vocals" as a substring); files that don't match the + // (a key can contain "-vocals" as a substring); files that don't match the // stem naming scheme at all are junk in this directory and stay sweepable. let base = file.deletingPathExtension().lastPathComponent let key = StemCache.key(fromStemBaseName: base) ?? base @@ -62,5 +50,28 @@ public enum LibraryCleanup { } } } + + if let files = try? FileManager.default.contentsOfDirectory( + at: ArtworkStore.directory, includingPropertiesForKeys: nil + ) { + for file in files where !referenced.contains(file.deletingPathExtension().lastPathComponent) { + try? FileManager.default.removeItem(at: file) + } + } + } + + /// Deletes every cached artifact for one stem key: audio (extension varies, match by + /// basename), stems, and extracted artwork. + private static func removeCachedFiles(key: String) { + if let files = try? FileManager.default.contentsOfDirectory( + at: AudioCache.directory, includingPropertiesForKeys: nil + ) { + for file in files where file.deletingPathExtension().lastPathComponent == key { + try? FileManager.default.removeItem(at: file) + } + } + StemCache.removeStems(key: key) + try? FileManager.default.removeItem( + at: ArtworkStore.directory.appendingPathComponent("\(key).jpg")) } } diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift new file mode 100644 index 0000000..091de59 --- /dev/null +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+LocalImport.swift @@ -0,0 +1,167 @@ +import AVFoundation +import Domain +import Foundation +import SwiftData +import ContinuityCore +import os + +extension PreparationQueue { + /// Audio containers iOS can decode — anything else in a scanned folder is not importable + /// music. + private static let musicExtensions: Set = [ + "mp3", "m4a", "aac", "wav", "aif", "aiff", "flac", "caf", "m4b", "mp4", + ] + /// Below this size a file is "obviously not music" (ringtone snippets, UI sounds, + /// notification tones) — roughly 15 seconds of 128 kbps audio. + private static let minimumMusicBytes = 250_000 + + /// Imports user-picked local audio: files are imported directly; folders are scanned + /// recursively for music (audio extension + big enough to be a song), so pointing the + /// picker at a music folder imports the whole thing in one shot. Everything lands in the + /// shared "Local Files" playlist, deduplicated against what's already there. Returns how + /// many tracks were imported; failures are logged and skipped (partial imports succeed). + public func importLocalFiles(_ urls: [URL], in context: ModelContext) async -> Int { + var imported = 0 + for url in urls { + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + if (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true { + // Security scope covers the folder's descendants, so files found by the scan + // are readable without their own scoped access. + for file in Self.scanForMusic(in: url) { + if await importOne(file, in: context) { imported += 1 } + } + } else if await importOne(url, in: context) { + imported += 1 + } + } + return imported + } + + /// Recursively lists the music files in a folder: audio extension, not hidden, and large + /// enough to plausibly be a song. Sorted by path so import order (→ `sortIndex`) is stable. + private static func scanForMusic(in folder: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: folder, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var files: [URL] = [] + for case let url as URL in enumerator { + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + musicExtensions.contains(url.pathExtension.lowercased()), + (values.fileSize ?? 0) >= minimumMusicBytes else { continue } + files.append(url) + } + return files.sorted { $0.path < $1.path } + } + + private func importOne(_ url: URL, in context: ModelContext) async -> Bool { + // Direct file picks carry their own security scope; files inside a scanned folder + // are covered by the folder's scope (startAccessing then returns false — harmless). + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + + // One fresh UUID keys everything for this track: the model row, the cached audio + // file's basename, and the stem-cache key (`Track.stemKey` == id for local imports) — + // so cleanup/sweep can match files to tracks by basename. + let trackID = UUID() + let ext = url.pathExtension.isEmpty ? "m4a" : url.pathExtension.lowercased() + let destination = AudioCache.fileURL(videoID: trackID.uuidString, container: ext) + do { + try FileManager.default.copyItem(at: url, to: destination) + } catch { + Logger.ingest.error("local import copy failed for \(url.lastPathComponent, privacy: .public): \(String(describing: error), privacy: .public)") + return false + } + + // Metadata load runs off-main (AVURLAsset touches the file). + let fallbackTitle = url.deletingPathExtension().lastPathComponent + let meta = await Task.detached(priority: .userInitiated) { () -> (title: String?, artist: String?, duration: Double, artwork: Data?) in + let asset = AVURLAsset(url: destination) + var title: String? + var artist: String? + var artwork: Data? + var duration: Double = 0 + if let seconds = try? await asset.load(.duration).seconds, seconds.isFinite { + duration = seconds + } + if let items = try? await asset.load(.commonMetadata) { + for item in items { + switch item.commonKey { + case .commonKeyTitle?: title = try? await item.load(.stringValue) + case .commonKeyArtist?: artist = try? await item.load(.stringValue) + case .commonKeyArtwork?: artwork = try? await item.load(.dataValue) + default: break + } + } + } + return (title, artist, duration, artwork) + }.value + + let playlist = Self.findOrCreateLocalFilesPlaylist(in: context) + let title = meta.title?.isEmpty == false ? meta.title! : fallbackTitle + let artist = meta.artist?.isEmpty == false ? meta.artist! : "Unknown Artist" + + // Re-scanning the same folder must not double-import: same title + artist + length + // (±1s) already in Local Files means we've seen this song. + if playlist.tracks.contains(where: { + $0.title == title && $0.artist == artist && abs($0.durationSeconds - meta.duration) < 1 + }) { + try? FileManager.default.removeItem(at: destination) + return false + } + + // Embedded artwork → Application Support/Artwork/.jpg (excluded from backup by + // the directory, mirroring StemCache). + var artworkPath: String? + if let data = meta.artwork { + let name = "\(trackID.uuidString).jpg" + let artworkURL = ArtworkStore.directory.appendingPathComponent(name) + if (try? data.write(to: artworkURL)) != nil { artworkPath = name } + } + + let track = Track( + id: trackID, + title: title, + artist: artist, + durationSeconds: meta.duration, + artworkSymbol: playlist.artworkSymbol, + // Vary the gradient per track so rows are visually distinct. + gradientSeed: playlist.gradientSeed * 100 + playlist.tracks.count, + sortIndex: playlist.tracks.count, + prepState: .ready, + localRelativePath: AudioCache.relativePath(for: destination) + ) + track.artworkPath = artworkPath + + playlist.tracks.append(track) + context.insert(track) + playlist.touch() // membership changed → resort the library + try? context.save() + + // BPM/key/loudness/silence analysis runs post-ready, limiter-gated. + backfillTrackDetails(track, in: context) + return true + } + + /// Returns the shared "Local Files" playlist for local imports, creating it if missing. + private static func findOrCreateLocalFilesPlaylist(in context: ModelContext) -> Playlist { + let title = "Local Files" + let descriptor = FetchDescriptor(predicate: #Predicate { $0.title == title }) + // Skip a demo playlist that happens to share the name — imports go to a real one. + if let existing = (try? context.fetch(descriptor))?.first(where: { !$0.isDemo }) { + return existing + } + let playlist = Playlist( + title: title, + subtitle: "Imported from Files", + artworkSymbol: "folder.fill", + gradientSeed: 53 + ) + context.insert(playlist) + return playlist + } +} diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift index b7abb40..9be684d 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Stems.swift @@ -14,17 +14,17 @@ extension PreparationQueue { // Diagnostic kill switch: launch argument `-debug.disableStemSeparation YES` rules the // whole separation pipeline in/out of a memory repro in one run, no code edits. if UserDefaults.standard.bool(forKey: "debug.disableStemSeparation") { return } - protectedStemKeys = Set(tracks.compactMap(\.youtubeVideoID)) + protectedStemKeys = Set(tracks.map(\.stemKey)) // First stem demand of the session = playback just started. Hold separation for a // minute so its peak never stacks on engine/UI/download ramp-up. if separationAllowedAt == nil { separationAllowedAt = Date().addingTimeInterval(60) } let holdActive = Date() < (separationAllowedAt ?? .distantPast) for track in tracks { guard !track.isDemo, track.prepState == .ready, - let key = track.youtubeVideoID, track.localRelativePath != nil else { continue } + track.localRelativePath != nil else { continue } reconcileStemLinks(track, in: context) if track.hasStems { - StemCache.markUsed(key: key) // actively played material stays cache-resident + StemCache.markUsed(key: track.stemKey) // actively played material stays cache-resident } else if holdActive { scheduleDeferredStemsRetry(for: tracks, in: context) } else { @@ -65,7 +65,7 @@ extension PreparationQueue { /// (e.g. a re-added video), clears links whose files were evicted so `hasStems` tells the /// truth and the track becomes eligible for re-separation. func reconcileStemLinks(_ track: Track, in context: ModelContext) { - guard let key = track.youtubeVideoID else { return } + let key = track.stemKey if let v = track.vocalsRelativePath, let a = track.accompanimentRelativePath, FileManager.default.fileExists(atPath: StemCache.url(forRelativePath: v).path), FileManager.default.fileExists(atPath: StemCache.url(forRelativePath: a).path) { @@ -88,7 +88,8 @@ extension PreparationQueue { /// transition for this track. Serialised via `stemLimiter`; after each separation the cache's /// byte budget is enforced (LRU eviction, protecting the play-queue neighborhood). private func separateStems(_ track: Track, in context: ModelContext) { - guard let key = track.youtubeVideoID, let relativePath = track.localRelativePath, + let key = track.stemKey + guard let relativePath = track.localRelativePath, !stemsInFlight.contains(key) else { return } stemsInFlight.insert(key) diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift index e976baf..5d37111 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue+Sync.swift @@ -181,8 +181,8 @@ extension PreparationQueue { private func removeTracks(_ tracks: [Track], in context: ModelContext) { guard !tracks.isEmpty else { return } onTracksDeleted?(Set(tracks.map(\.id))) - let videoIDs = tracks.compactMap(\.youtubeVideoID) + let keys = tracks.map(\.stemKey) for track in tracks { context.delete(track) } - LibraryCleanup.removeOrphanedFiles(videoIDs: videoIDs, in: context) + LibraryCleanup.removeOrphanedFiles(keys: keys, in: context) } } diff --git a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift index 45001d2..f110a86 100644 --- a/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift +++ b/Packages/ContinuityKit/Sources/Ingest/PreparationQueue.swift @@ -238,7 +238,7 @@ public final class PreparationQueue { /// `resumePreparation`. Every heavy step is gated by `ingestLimiter` so a large library's /// launch backfill can't fan out into dozens of simultaneous file opens / PCM decodes / /// oEmbed requests (which hitch launch and thrash memory). - private func backfillTrackDetails(_ track: Track, in context: ModelContext) { + func backfillTrackDetails(_ track: Track, in context: ModelContext) { let needsTitle = Self.hasPlaceholderMetadata(track) let needsDuration = track.durationSeconds <= 0 && track.localRelativePath != nil let needsSilenceScan = track.audibleEndSeconds == nil && track.localRelativePath != nil