Skip to content

Commit 836a8d9

Browse files
authored
Merge pull request #8 from kushalpandya/kp-improve-library-filtering
Improves Library filtering UX by optimizing track metadata parsing
2 parents 0277592 + 9b1bc29 commit 836a8d9

11 files changed

Lines changed: 764 additions & 194 deletions

File tree

Managers/Database/DatabaseManager.swift

Lines changed: 218 additions & 62 deletions
Large diffs are not rendered by default.

Managers/Database/MetadataExtractor.swift

Lines changed: 262 additions & 99 deletions
Large diffs are not rendered by default.

Managers/LibraryManager.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,10 @@ class LibraryManager: ObservableObject {
270270
func getTracksByAlbum(_ album: String) -> [Track] {
271271
return databaseManager.getTracksByAlbum(album)
272272
}
273+
274+
func getTracksByComposer(_ composer: String) -> [Track] {
275+
return databaseManager.getTracksByComposer(composer)
276+
}
273277

274278
func getTracksByGenre(_ genre: String) -> [Track] {
275279
return databaseManager.getTracksByGenre(genre)
@@ -292,6 +296,10 @@ class LibraryManager: ObservableObject {
292296
return databaseManager.getAllAlbums()
293297
}
294298

299+
func getAllComposers() -> [String] {
300+
return databaseManager.getAllComposers()
301+
}
302+
295303
func getAllGenres() -> [String] {
296304
return databaseManager.getAllGenres()
297305
}

Models/Core/Track.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import GRDB
33

44
class Track: Identifiable, ObservableObject, Equatable, FetchableRecord, PersistableRecord {
55
let id = UUID()
6-
var trackId: Int64? // Database ID
6+
var trackId: Int64?
77
let url: URL
88

99
@Published var title: String
1010
@Published var artist: String
1111
@Published var album: String
12+
@Published var composer: String
1213
@Published var genre: String
1314
@Published var year: String
1415
@Published var duration: Double
@@ -29,6 +30,7 @@ class Track: Identifiable, ObservableObject, Equatable, FetchableRecord, Persist
2930
self.title = url.deletingPathExtension().lastPathComponent
3031
self.artist = "Unknown Artist"
3132
self.album = "Unknown Album"
33+
self.composer = "Unknown Composer"
3234
self.genre = "Unknown Genre"
3335
self.year = "Unknown Year"
3436
self.duration = 0
@@ -47,6 +49,7 @@ class Track: Identifiable, ObservableObject, Equatable, FetchableRecord, Persist
4749
static let title = Column("title")
4850
static let artist = Column("artist")
4951
static let album = Column("album")
52+
static let composer = Column("composer")
5053
static let genre = Column("genre")
5154
static let year = Column("year")
5255
static let duration = Column("duration")
@@ -73,6 +76,11 @@ class Track: Identifiable, ObservableObject, Equatable, FetchableRecord, Persist
7376
artist = row[Columns.artist] ?? "Unknown Artist"
7477
album = row[Columns.album] ?? "Unknown Album"
7578
genre = row[Columns.genre] ?? "Unknown Genre"
79+
80+
// Normalize empty composer strings
81+
let composerValue = row[Columns.composer] ?? "Unknown Composer"
82+
composer = composerValue.isEmpty ? "Unknown Composer" : composerValue
83+
7684
year = row[Columns.year] ?? ""
7785
duration = row[Columns.duration] ?? 0
7886
format = row[Columns.format] ?? url.pathExtension
@@ -82,7 +90,6 @@ class Track: Identifiable, ObservableObject, Equatable, FetchableRecord, Persist
8290
lastPlayedDate = row[Columns.lastPlayedDate]
8391
isMetadataLoaded = true
8492
}
85-
8693
// MARK: - PersistableRecord
8794

8895
func encode(to container: inout PersistenceContainer) throws {
@@ -93,6 +100,7 @@ class Track: Identifiable, ObservableObject, Equatable, FetchableRecord, Persist
93100
container[Columns.title] = title
94101
container[Columns.artist] = artist
95102
container[Columns.album] = album
103+
container[Columns.composer] = composer
96104
container[Columns.genre] = genre
97105
container[Columns.year] = year
98106
container[Columns.duration] = duration
@@ -123,3 +131,12 @@ class Track: Identifiable, ObservableObject, Equatable, FetchableRecord, Persist
123131
return lhs.id == rhs.id
124132
}
125133
}
134+
135+
// MARK: - Hashable Conformance
136+
137+
extension Track: Hashable {
138+
func hash(into hasher: inout Hasher) {
139+
// Use the unique ID for hashing
140+
hasher.combine(id)
141+
}
142+
}

Models/Enums/LibraryFilterType.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,27 @@ import Foundation
33
enum LibraryFilterType: String, CaseIterable {
44
case artists = "Artists"
55
case albums = "Albums"
6-
case years = "Years"
6+
case composers = "Composers"
77
case genres = "Genres"
8+
case years = "Years"
89

910
var icon: String {
1011
switch self {
1112
case .artists: return "person.fill"
1213
case .albums: return "opticaldisc.fill"
13-
case .years: return "calendar"
14+
case .composers: return "person.fill"
1415
case .genres: return "music.note"
16+
case .years: return "calendar"
1517
}
1618
}
1719

1820
var emptyStateMessage: String {
1921
switch self {
2022
case .artists: return "No artists found in your library"
2123
case .albums: return "No albums found in your library"
22-
case .years: return "No release years found in your library"
24+
case .composers: return "No composers found in your library"
2325
case .genres: return "No genres found in your library"
26+
case .years: return "No release years found in your library"
2427
}
2528
}
2629
}

Petrichor.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
/* Begin PBXBuildFile section */
1010
550F4A402DB4786F005F794E /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = 550F4A3F2DB4786F005F794E /* .gitignore */; };
11+
5544932E2DE7911600D452AA /* ArtistParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5544932D2DE78EC000D452AA /* ArtistParser.swift */; };
1112
55593A422DE019E400640247 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 55593A412DE019E400640247 /* README.md */; };
1213
5590877F2DE0C078009E7719 /* PetrichorApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5590877E2DE0C078009E7719 /* PetrichorApp.swift */; };
1314
559087A02DE0C078009E7719 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5590877C2DE0C078009E7719 /* Assets.xcassets */; };
@@ -17,6 +18,7 @@
1718
/* Begin PBXFileReference section */
1819
550F4A0E2DB45B1A005F794E /* Petrichor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Petrichor.app; sourceTree = BUILT_PRODUCTS_DIR; };
1920
550F4A3F2DB4786F005F794E /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = "<group>"; };
21+
5544932D2DE78EC000D452AA /* ArtistParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistParser.swift; sourceTree = "<group>"; };
2022
55593A412DE019E400640247 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
2123
5590877C2DE0C078009E7719 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2224
5590877D2DE0C078009E7719 /* Petrichor.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Petrichor.entitlements; sourceTree = "<group>"; };
@@ -66,6 +68,7 @@
6668
559087AA2DE0C0A2009E7719 /* Managers */,
6769
559087B72DE0C0A5009E7719 /* Models */,
6870
559087D82DE0C0A6009E7719 /* Views */,
71+
5544932C2DE78EB300D452AA /* Utilities */,
6972
558BF80D2DE0C2040059A014 /* Configuration */,
7073
559087ED2DE0C0DC009E7719 /* Resources */,
7174
550F4A3F2DB4786F005F794E /* .gitignore */,
@@ -82,6 +85,14 @@
8285
name = Products;
8386
sourceTree = "<group>";
8487
};
88+
5544932C2DE78EB300D452AA /* Utilities */ = {
89+
isa = PBXGroup;
90+
children = (
91+
5544932D2DE78EC000D452AA /* ArtistParser.swift */,
92+
);
93+
path = Utilities;
94+
sourceTree = "<group>";
95+
};
8596
558BF80D2DE0C2040059A014 /* Configuration */ = {
8697
isa = PBXGroup;
8798
children = (
@@ -182,6 +193,7 @@
182193
isa = PBXSourcesBuildPhase;
183194
buildActionMask = 2147483647;
184195
files = (
196+
5544932E2DE7911600D452AA /* ArtistParser.swift in Sources */,
185197
5590877F2DE0C078009E7719 /* PetrichorApp.swift in Sources */,
186198
);
187199
runOnlyForDeploymentPostprocessing = 0;

Utilities/ArtistParser.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Foundation
2+
3+
struct ArtistParser {
4+
// Common separators used in artist fields
5+
private static let separators = [
6+
" feat. ", " feat ", " featuring ", " ft. ", " ft ",
7+
" & ", " and ", " x ", " X ", " vs. ", " vs ",
8+
", ", " with ", " / ", "", "", ";"
9+
]
10+
11+
/// Parses a multi-artist string into individual artist names
12+
static func parse(_ artistString: String) -> [String] {
13+
var artists: [String] = [artistString]
14+
15+
// Process each separator
16+
for separator in separators {
17+
var newArtists: [String] = []
18+
19+
for artist in artists {
20+
if artist.localizedCaseInsensitiveContains(separator) {
21+
// Split by this separator (case-insensitive)
22+
let components = artist.components(separatedBy: separator, options: .caseInsensitive)
23+
newArtists.append(contentsOf: components)
24+
} else {
25+
newArtists.append(artist)
26+
}
27+
}
28+
29+
artists = newArtists
30+
}
31+
32+
// Clean up and remove duplicates
33+
let cleanedArtists = artists
34+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
35+
.filter { !$0.isEmpty && $0 != "Unknown Artist" }
36+
37+
// Remove duplicates while preserving order
38+
var seen = Set<String>()
39+
var uniqueArtists: [String] = []
40+
41+
for artist in cleanedArtists {
42+
let lowercased = artist.lowercased()
43+
if !seen.contains(lowercased) {
44+
seen.insert(lowercased)
45+
uniqueArtists.append(artist)
46+
}
47+
}
48+
49+
// If no valid artists found, return the original or "Unknown Artist"
50+
if uniqueArtists.isEmpty {
51+
return [artistString.isEmpty ? "Unknown Artist" : artistString]
52+
}
53+
54+
return uniqueArtists
55+
}
56+
57+
/// Checks if a specific artist appears in a track's artist field
58+
static func trackContainsArtist(_ track: Track, artistName: String) -> Bool {
59+
let artists = parse(track.artist)
60+
return artists.contains { artist in
61+
artist.localizedCaseInsensitiveCompare(artistName) == .orderedSame
62+
}
63+
}
64+
}
65+
66+
// Extension to String for case-insensitive split
67+
extension String {
68+
func components(separatedBy separator: String, options: String.CompareOptions) -> [String] {
69+
var result: [String] = []
70+
var currentIndex = self.startIndex
71+
72+
while currentIndex < self.endIndex {
73+
if let range = self.range(of: separator, options: options, range: currentIndex..<self.endIndex) {
74+
result.append(String(self[currentIndex..<range.lowerBound]))
75+
currentIndex = range.upperBound
76+
} else {
77+
result.append(String(self[currentIndex..<self.endIndex]))
78+
break
79+
}
80+
}
81+
82+
return result
83+
}
84+
}

Views/Components/SidebarView.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,8 @@ struct LibrarySidebarItem: SidebarItem {
341341
self.id = filterItem.id // Use the stable ID from filterItem
342342
self.title = filterItem.name
343343
self.subtitle = nil
344-
self.icon = "person.fill"
344+
// Use the appropriate icon based on filter type
345+
self.icon = Self.getIcon(for: filterItem.filterType, isAllItem: false)
345346
self.count = filterItem.count
346347
self.filterType = filterItem.filterType
347348
self.filterName = filterItem.name
@@ -353,11 +354,27 @@ struct LibrarySidebarItem: SidebarItem {
353354
self.id = UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", filterType.hashValue))") ?? UUID()
354355
self.title = "All \(filterType.rawValue)"
355356
self.subtitle = nil
356-
self.icon = "person.2.fill"
357+
// Use a different icon for "All" items
358+
self.icon = Self.getIcon(for: filterType, isAllItem: true)
357359
self.count = count
358360
self.filterType = filterType
359361
self.filterName = ""
360362
}
363+
364+
private static func getIcon(for filterType: LibraryFilterType, isAllItem: Bool) -> String {
365+
switch filterType {
366+
case .artists:
367+
return isAllItem ? "person.2.fill" : "person.fill"
368+
case .albums:
369+
return isAllItem ? "opticaldisc.fill" : "opticaldisc"
370+
case .composers:
371+
return isAllItem ? "person.2.fill" : "person.fill"
372+
case .years:
373+
return isAllItem ? "calendar.circle.fill" : "calendar"
374+
case .genres:
375+
return isAllItem ? "music.note.list" : "music.note"
376+
}
377+
}
361378
}
362379

363380
// Folder Item
@@ -441,14 +458,14 @@ extension SidebarView where Item == LibrarySidebarItem {
441458
selectedItem: Binding<LibrarySidebarItem?>,
442459
onItemTap: @escaping (LibrarySidebarItem) -> Void
443460
) {
444-
// Create items including "All" item
461+
// Create items list
445462
var items: [LibrarySidebarItem] = []
446463

447-
// Add "All" item
464+
// Add "All" item first
448465
let allItem = LibrarySidebarItem(allItemFor: filterType, count: totalTracksCount)
449466
items.append(allItem)
450467

451-
// Add filter items
468+
// Add filter items (which should already be sorted with Unknown first)
452469
items.append(contentsOf: filterItems.map { LibrarySidebarItem(filterItem: $0) })
453470

454471
self.init(

0 commit comments

Comments
 (0)