Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 56 additions & 13 deletions App/Continuity/Views/LibrarySheetView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers
import Ingest
import Playback

Expand All @@ -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
Expand All @@ -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")
}
}
}
Expand All @@ -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()
Expand All @@ -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
}
}
4 changes: 2 additions & 2 deletions App/Continuity/Views/LibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
6 changes: 2 additions & 4 deletions App/Continuity/Views/PlaylistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 30 additions & 4 deletions Packages/ContinuityKit/Sources/Domain/Track.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
63 changes: 37 additions & 26 deletions Packages/ContinuityKit/Sources/Ingest/LibraryCleanup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Track>(
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<Track>())) ?? []
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<Track>()) 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
Expand All @@ -53,7 +41,7 @@ public enum LibraryCleanup {
) {
for file in files {
// Stem names are "<key>-vocals.*" / "<key>-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
Expand All @@ -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"))
}
}
Loading
Loading