diff --git a/Persephone.xcodeproj/project.pbxproj b/Persephone.xcodeproj/project.pbxproj index a9895ec..c6bcc75 100644 --- a/Persephone.xcodeproj/project.pbxproj +++ b/Persephone.xcodeproj/project.pbxproj @@ -298,23 +298,23 @@ E407861A2110CE6E006887B1 /* Persephone */ = { isa = PBXGroup; children = ( - E450AD9E2229B9BC0091BED3 /* PersephoneBridgingHeader.h */, - E450AD8922262B420091BED3 /* Operations */, - E4A83BF2222207BE0098FED6 /* Services */, - E4A83BEC2221F5DD0098FED6 /* Preferences */, + E407861B2110CE6E006887B1 /* AppDelegate.swift */, + E407861F2110CE70006887B1 /* Assets.xcassets */, + E4D1B597220BA3A20026F233 /* Controllers */, + E4F6B45E221E117600ACF42A /* DataSources */, + E408D3B7220DE8CC0006D9BE /* Extensions */, + E41B22C721FB966C00D544F6 /* include */, + E40786242110CE70006887B1 /* Info.plist */, E47E2FE32220AA0700F747E6 /* Layouts */, E4F6B461221E124700ACF42A /* Models */, - E4F6B45E221E117600ACF42A /* DataSources */, - E407861F2110CE70006887B1 /* Assets.xcassets */, - E408D3B7220DE8CC0006D9BE /* Extensions */, - E4D1B598220BA3C90026F233 /* Resources */, - E4D1B597220BA3A20026F233 /* Controllers */, - E408D3C3220E138B0006D9BE /* Views */, - E41B22C721FB966C00D544F6 /* include */, - E407861B2110CE6E006887B1 /* AppDelegate.swift */, - E40786242110CE70006887B1 /* Info.plist */, - E40786252110CE70006887B1 /* Persephone.entitlements */, E4A642DB220912FA00067D21 /* MPDClient */, + E450AD8922262B420091BED3 /* Operations */, + E40786252110CE70006887B1 /* Persephone.entitlements */, + E450AD9E2229B9BC0091BED3 /* PersephoneBridgingHeader.h */, + E4A83BEC2221F5DD0098FED6 /* Preferences */, + E4D1B598220BA3C90026F233 /* Resources */, + E4A83BF2222207BE0098FED6 /* Services */, + E408D3C3220E138B0006D9BE /* Views */, ); path = Persephone; sourceTree = ""; @@ -342,10 +342,10 @@ children = ( E4928E0A2218D62A001D4BEA /* CGColor.swift */, E408D3B5220DD8970006D9BE /* Notification.swift */, - E408D3B8220DE98F0006D9BE /* NSUserInterfaceItemIdentifier.swift */, E40FE71A221B904300A4223F /* NSEvent.swift */, E435E3E1221CD4E200184CFC /* NSFont.swift */, E435E3E3221CD75D00184CFC /* NSImage.swift */, + E408D3B8220DE98F0006D9BE /* NSUserInterfaceItemIdentifier.swift */, E450AD9C2229B9050091BED3 /* String.swift */, ); path = Extensions; @@ -354,15 +354,15 @@ E408D3BC220E03D20006D9BE /* Extensions */ = { isa = PBXGroup; children = ( + E41E530A223C033700173814 /* MPDClient+Album.swift */, + E41E5308223C020400173814 /* MPDClient+Command.swift */, E41E52FC223BF87300173814 /* MPDClient+Connection.swift */, E42410B52241B956005ED6DF /* MPDClient+Database.swift */, - E41E52FE223BF95E00173814 /* MPDClient+Transport.swift */, - E41E5300223BF99300173814 /* MPDClient+Queue.swift */, - E41E5302223BF9C300173814 /* MPDClient+Idle.swift */, E41E5304223BFB0700173814 /* MPDClient+Error.swift */, + E41E5302223BF9C300173814 /* MPDClient+Idle.swift */, + E41E5300223BF99300173814 /* MPDClient+Queue.swift */, E41E5306223C019100173814 /* MPDClient+Status.swift */, - E41E5308223C020400173814 /* MPDClient+Command.swift */, - E41E530A223C033700173814 /* MPDClient+Album.swift */, + E41E52FE223BF95E00173814 /* MPDClient+Transport.swift */, E408D3BD220E03EE0006D9BE /* RawRepresentable.swift */, ); path = Extensions; @@ -450,8 +450,8 @@ isa = PBXGroup; children = ( E41E530D223EF4CF00173814 /* AlbumArtService+Caching.swift */, - E41E530F223EF6CE00173814 /* AlbumArtService+Remote.swift */, E41E5311223EF74A00173814 /* AlbumArtService+Filesystem.swift */, + E41E530F223EF6CE00173814 /* AlbumArtService+Remote.swift */, ); path = Extensions; sourceTree = ""; @@ -475,10 +475,10 @@ E4A642DB220912FA00067D21 /* MPDClient */ = { isa = PBXGroup; children = ( - E41B22C521FB932700D544F6 /* MPDClient.swift */, E408D3BC220E03D20006D9BE /* Extensions */, - E4D1B595220BA27C0026F233 /* Protocols */, E4D1B594220BA2490026F233 /* Models */, + E41B22C521FB932700D544F6 /* MPDClient.swift */, + E4D1B595220BA27C0026F233 /* Protocols */, ); path = MPDClient; sourceTree = ""; @@ -494,8 +494,8 @@ E4A83BED2221F5E60098FED6 /* Controllers */ = { isa = PBXGroup; children = ( - E41EA46B221636AF0068EF46 /* GeneralPrefsViewController.swift */, E4A83BEE2221F8CF0098FED6 /* AlbumArtPrefsController.swift */, + E41EA46B221636AF0068EF46 /* GeneralPrefsViewController.swift */, E4A83BF02221FAA00098FED6 /* PreferencesViewController.swift */, E4C8B53B22342005009A20F3 /* PreferencesWindowController.swift */, ); @@ -505,8 +505,8 @@ E4A83BF2222207BE0098FED6 /* Services */ = { isa = PBXGroup; children = ( - E41E530C223EF4BA00173814 /* Extensions */, E4A83BF3222207D50098FED6 /* AlbumArtService.swift */, + E41E530C223EF4BA00173814 /* Extensions */, ); path = Services; sourceTree = ""; @@ -514,12 +514,12 @@ E4D1B594220BA2490026F233 /* Models */ = { isa = PBXGroup; children = ( + E4EB237A220F7CF1008C70C0 /* MPDAlbum.swift */, + E45962C52241A78500FC1A1E /* MPDCommand.swift */, + E4C8B53D22349002009A20F3 /* MPDIdle.swift */, + E4EB2378220F10B8008C70C0 /* MPDPair.swift */, E4E8CC9922075D370024217A /* MPDSong.swift */, E4A642D922090CBE00067D21 /* MPDStatus.swift */, - E4EB2378220F10B8008C70C0 /* MPDPair.swift */, - E4EB237A220F7CF1008C70C0 /* MPDAlbum.swift */, - E4C8B53D22349002009A20F3 /* MPDIdle.swift */, - E45962C52241A78500FC1A1E /* MPDCommand.swift */, ); path = Models; sourceTree = ""; @@ -535,12 +535,12 @@ E4D1B597220BA3A20026F233 /* Controllers */ = { isa = PBXGroup; children = ( - E47E2FD4222071FD00F747E6 /* AlbumViewItem.swift */, E408D3C1220E134F0006D9BE /* AlbumViewController.swift */, + E47E2FD4222071FD00F747E6 /* AlbumViewItem.swift */, + E47E2FD022205C4600F747E6 /* MainSplitViewController.swift */, E4E8CC932206097F0024217A /* NotificationsController.swift */, E4E8CC912204F4B80024217A /* QueueViewController.swift */, E465049921E94DF500A70F4C /* WindowController.swift */, - E47E2FD022205C4600F747E6 /* MainSplitViewController.swift */, ); path = Controllers; sourceTree = ""; @@ -557,8 +557,8 @@ E4F6B45E221E117600ACF42A /* DataSources */ = { isa = PBXGroup; children = ( - E4F6B45F221E119B00ACF42A /* QueueDataSource.swift */, E4F6B466221E233200ACF42A /* AlbumDataSource.swift */, + E4F6B45F221E119B00ACF42A /* QueueDataSource.swift */, ); path = DataSources; sourceTree = ""; diff --git a/Persephone/Controllers/QueueViewController.swift b/Persephone/Controllers/QueueViewController.swift index 30a6d52..a2acb7e 100644 --- a/Persephone/Controllers/QueueViewController.swift +++ b/Persephone/Controllers/QueueViewController.swift @@ -7,6 +7,7 @@ // import Cocoa +import PromiseKit class QueueViewController: NSViewController, NSOutlineViewDelegate { @@ -66,8 +67,18 @@ class QueueViewController: NSViewController, } func updateAlbumArt() { - if let playingSong = dataSource.queue.first(where: { $0.isPlaying }) { + if let playingQueueItem = dataSource.queue.first(where: { $0.isPlaying }) { + let albumArtService = AlbumArtService(song: playingQueueItem.song) + albumArtService.fetchBigAlbumArt() + .done() { + guard let image = $0 else { return } + + self.queueAlbumArtImage.image = image.toFitBox( + size: NSSize(width: 500, height: 500) + ) + } + .cauterize() } else { queueAlbumArtImage.image = NSImage.defaultCoverArt } diff --git a/Persephone/DataSources/AlbumDataSource.swift b/Persephone/DataSources/AlbumDataSource.swift index 9748750..b7f6a59 100644 --- a/Persephone/DataSources/AlbumDataSource.swift +++ b/Persephone/DataSources/AlbumDataSource.swift @@ -7,6 +7,7 @@ // import Cocoa +import PromiseKit class AlbumDataSource: NSObject, NSCollectionViewDataSource { var albums: [Album] = [] @@ -26,13 +27,15 @@ class AlbumDataSource: NSObject, NSCollectionViewDataSource { AppDelegate.mpdClient.getAlbumFirstSong(for: albums[indexPath.item].mpdAlbum) { guard let song = $0 else { return } - AlbumArtService(song: Song(mpdSong: song)).fetchAlbumArt { image in - self.albums[indexPath.item].coverArt = image + AlbumArtService(song: Song(mpdSong: song)) + .fetchAlbumArt() + .done { image in + self.albums[indexPath.item].coverArt = image - DispatchQueue.main.async { - collectionView.reloadItems(at: [indexPath]) + DispatchQueue.main.async { + collectionView.reloadItems(at: [indexPath]) + } } - } } } diff --git a/Persephone/Resources/Base.lproj/Main.storyboard b/Persephone/Resources/Base.lproj/Main.storyboard index 0add0f3..a1d558b 100644 --- a/Persephone/Resources/Base.lproj/Main.storyboard +++ b/Persephone/Resources/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -599,7 +599,7 @@ - + diff --git a/Persephone/Services/AlbumArtService.swift b/Persephone/Services/AlbumArtService.swift index a7f614b..b96e6f6 100644 --- a/Persephone/Services/AlbumArtService.swift +++ b/Persephone/Services/AlbumArtService.swift @@ -18,38 +18,54 @@ class AlbumArtService { let cachedArtworkQuality: CGFloat = 0.5 var session = URLSession(configuration: .default) - let cacheQueue = DispatchQueue(label: "albumArtCacheQueue") + let artworkQueue = DispatchQueue( + label: "albumArtQueue", + qos: .background, + attributes: .concurrent + ) init(song: Song) { self.song = song self.album = song.album } - func fetchAlbumArt(callback: @escaping (_ image: NSImage?) -> Void) { - cacheQueue.async { - firstly { - self.getCachedArtwork() - }.then { artwork -> Promise in - artwork.map { Promise.value($0 as NSImage?) } ?? self.cacheIfNecessary(self.getArtworkFromFilesystem()) - }.then { artwork -> Promise in - artwork.map { Promise.value($0 as NSImage?) } ?? self.cacheIfNecessary(self.getArtworkFromMusicBrainz().map(Optional.some)) - }.tap { result in - switch result { - case .fulfilled(nil), .rejected(MusicBrainzError.noArtworkAvailable): - self.cacheArtwork(data: Data()) - default: - break - } - }.recover { error in - .value(nil) - }.done(callback) + func fetchBigAlbumArt() -> Promise { + return firstly { + self.getArtworkFromFilesystem() + }.then { (artwork: NSImage?) -> Promise in + artwork.map(Promise.value) ?? self.getRemoteArtwork().map(Optional.some) } } - func cacheIfNecessary(_ promise: Promise) -> Promise { - return promise.get { image in - if let data = image?.jpegData(compressionQuality: self.cachedArtworkQuality) { - self.cacheArtwork(data: data) + func fetchAlbumArt() -> Guarantee { + return firstly { + self.getCachedArtwork() + }.then { (artwork: NSImage?) -> Promise in + artwork.map(Promise.value) ?? self.getArtworkFromFilesystem() + }.then { (artwork: NSImage?) -> Promise in + artwork.map(Promise.value) ?? self.getRemoteArtwork().map(Optional.some) + }.compactMap(on: artworkQueue) { + return self.sizeAndCacheImage($0).map(Optional.some) + }.recover { error in + self.cacheArtwork(data: Data()) + return .value(nil) + } + } + + func sizeAndCacheImage(_ image: NSImage?) -> NSImage? { + switch image { + case nil: + self.cacheArtwork(data: Data()) + return image + case let image: + if self.isArtworkCached() { + return image + } else { + let sizedImage = image?.toFitBox( + size: NSSize(width: self.cachedArtworkSize, height: self.cachedArtworkSize) + ) + self.cacheArtwork(data: sizedImage?.jpegData(compressionQuality: self.cachedArtworkQuality)) + return sizedImage } } } diff --git a/Persephone/Services/Extensions/AlbumArtService+Caching.swift b/Persephone/Services/Extensions/AlbumArtService+Caching.swift index 52dbf22..eb64e50 100644 --- a/Persephone/Services/Extensions/AlbumArtService+Caching.swift +++ b/Persephone/Services/Extensions/AlbumArtService+Caching.swift @@ -14,24 +14,38 @@ extension AlbumArtService { func getCachedArtwork() -> Promise { return Promise { seal in - let cacheFilePath = AlbumArtService.cacheDir.appendingPathComponent(album.hash).path - let data = FileManager.default.contents(atPath: cacheFilePath) - let image = data.flatMap(NSImage.init(data:)) + artworkQueue.async { + if self.isArtworkCached() { + let cacheFilePath = AlbumArtService.cacheDir.appendingPathComponent(self.album.hash).path + let data = FileManager.default.contents(atPath: cacheFilePath) + let image = NSImage(data: data ?? Data()) ?? NSImage.defaultCoverArt - seal.fulfill(image) + seal.fulfill(image) + } else { + seal.fulfill(nil) + } + } } } func cacheArtwork(data: Data?) { - guard let bundleIdentifier = Bundle.main.bundleIdentifier, - let cacheDir = try? FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - .appendingPathComponent(bundleIdentifier) - else { return } + artworkQueue.async { + guard let bundleIdentifier = Bundle.main.bundleIdentifier, + let cacheDir = try? FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent(bundleIdentifier) + else { return } - let cacheFilePath = cacheDir.appendingPathComponent(album.hash).path + let cacheFilePath = cacheDir.appendingPathComponent(self.album.hash).path - if !FileManager.default.fileExists(atPath: cacheFilePath) { - FileManager.default.createFile(atPath: cacheFilePath, contents: data, attributes: nil) + if !self.isArtworkCached() { + FileManager.default.createFile(atPath: cacheFilePath, contents: data, attributes: nil) + } } } + + func isArtworkCached() -> Bool { + let cacheFilePath = AlbumArtService.cacheDir.appendingPathComponent(album.hash).path + + return FileManager.default.fileExists(atPath: cacheFilePath) + } } diff --git a/Persephone/Services/Extensions/AlbumArtService+Filesystem.swift b/Persephone/Services/Extensions/AlbumArtService+Filesystem.swift index 1d6faff..39b2be5 100644 --- a/Persephone/Services/Extensions/AlbumArtService+Filesystem.swift +++ b/Persephone/Services/Extensions/AlbumArtService+Filesystem.swift @@ -21,18 +21,21 @@ extension AlbumArtService { let songPath = self.songPath() return Promise { seal in - let image = coverArtFilenames - .lazy - .map { "\(musicDir)/\(songPath)/\($0)" } - .compactMap(self.tryImage) - .first + artworkQueue.async { + let image = coverArtFilenames + .lazy + .map { "\(musicDir)/\(songPath)/\($0)" } + .compactMap(self.tryImage) + .first - seal.fulfill(image) + seal.fulfill(image) + } } } func songPath() -> String { - return song.mpdSong + return song + .mpdSong .uriString .split(separator: "/") .dropLast() diff --git a/Persephone/Services/Extensions/AlbumArtService+Remote.swift b/Persephone/Services/Extensions/AlbumArtService+Remote.swift index 21cbb84..16f0379 100644 --- a/Persephone/Services/Extensions/AlbumArtService+Remote.swift +++ b/Persephone/Services/Extensions/AlbumArtService+Remote.swift @@ -18,11 +18,13 @@ extension AlbumArtService { func getRemoteArtwork() -> Promise { return Promise { seal in - let albumArtWorkItem = DispatchWorkItem { - self.getArtworkFromMusicBrainz().pipe(to: seal.resolve) - } + artworkQueue.async { + let albumArtWorkItem = DispatchWorkItem { + self.getArtworkFromMusicBrainz().pipe(to: seal.resolve) + } - AlbumArtQueue.shared.addToQueue(workItem: albumArtWorkItem) + AlbumArtQueue.shared.addToQueue(workItem: albumArtWorkItem) + } } } @@ -30,25 +32,26 @@ extension AlbumArtService { var search = URLComponents(string: "https://musicbrainz.org/ws/2/release/")! search.query = "query=artist:\(album.artist) AND release:\(album.title) AND country:US&limit=1&fmt=json" - return URLSession.shared.dataTask(.promise, with: search.url!).validate() - .compactMap { - JSON($0.data) - }.compactMap { - $0["releases"][0]["id"].string - }.compactMap { - URLComponents(string: "https://coverartarchive.org/release/\($0)/front-500")?.url - }.then { (url: URL?) -> Promise<(data: Data, response: URLResponse)> in - return URLSession.shared.dataTask(.promise, with: url!).validate() - }.compactMap { - NSImage(data: $0.data)?.toFitBox( - size: NSSize(width: self.cachedArtworkSize, height: self.cachedArtworkSize) - ) - }.recover { error -> Promise in - if case PMKHTTPError.badStatusCode(404, _, _) = error { - throw MusicBrainzError.noArtworkAvailable - } else { - throw error - } + return firstly { + URLSession.shared.dataTask(.promise, with: search.url!).validate() + }.compactMap { + JSON($0.data) + }.compactMap { + $0["releases"][0]["id"].string + }.compactMap { + URLComponents(string: "https://coverartarchive.org/release/\($0)/front-500")?.url + }.then { (url: URL?) -> Promise<(data: Data, response: URLResponse)> in + return URLSession.shared.dataTask(.promise, with: url!).validate() + }.compactMap { + NSImage(data: $0.data)?.toFitBox( + size: NSSize(width: self.cachedArtworkSize, height: self.cachedArtworkSize) + ) + }.recover { error -> Promise in + if case PMKHTTPError.badStatusCode(404, _, _) = error { + throw MusicBrainzError.noArtworkAvailable + } else { + throw error } + } } }