Skip to content

Commit

Permalink
LastFM scrobble caching / retry
Browse files Browse the repository at this point in the history
  • Loading branch information
kartik-venugopal committed Dec 5, 2023
1 parent 9182b4a commit ecdb891
Show file tree
Hide file tree
Showing 10 changed files with 254 additions and 15 deletions.
43 changes: 43 additions & 0 deletions Aural.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,10 @@
3EE7CB9825E21FAE0075F1DD /* PlaylistFontSchemeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE7CB9725E21FAE0075F1DD /* PlaylistFontSchemeViewController.swift */; };
3EE7CB9C25E22D5D0075F1DD /* EffectsFontScheme.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3EE7CB9B25E22D5D0075F1DD /* EffectsFontScheme.xib */; };
3EE7CB9F25E22D6C0075F1DD /* EffectsFontSchemeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE7CB9E25E22D6B0075F1DD /* EffectsFontSchemeViewController.swift */; };
3EE896632B1FD1360087ADC2 /* LastFMScrobbleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE896622B1FD1360087ADC2 /* LastFMScrobbleCache.swift */; };
3EE896672B1FDA820087ADC2 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 3EE896662B1FDA820087ADC2 /* Collections */; };
3EE896692B1FDA820087ADC2 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 3EE896682B1FDA820087ADC2 /* DequeModule */; };
3EE8966B2B1FDA820087ADC2 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 3EE8966A2B1FDA820087ADC2 /* OrderedCollections */; };
3EE9BBE9268F90BE0073321A /* TimeStretchUnitPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EE9BBE8268F90BE0073321A /* TimeStretchUnitPersistenceTests.swift */; };
3EEC040B26874041004478CE /* SequencerInfoDelegateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EEC040A26874041004478CE /* SequencerInfoDelegateProtocol.swift */; };
3EEC040D26876E4D004478CE /* ShuffleMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EEC040C26876E4D004478CE /* ShuffleMode.swift */; };
Expand Down Expand Up @@ -2378,6 +2382,7 @@
3EE7CB9725E21FAE0075F1DD /* PlaylistFontSchemeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFontSchemeViewController.swift; sourceTree = "<group>"; };
3EE7CB9B25E22D5D0075F1DD /* EffectsFontScheme.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = EffectsFontScheme.xib; sourceTree = "<group>"; };
3EE7CB9E25E22D6B0075F1DD /* EffectsFontSchemeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EffectsFontSchemeViewController.swift; sourceTree = "<group>"; };
3EE896622B1FD1360087ADC2 /* LastFMScrobbleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastFMScrobbleCache.swift; sourceTree = "<group>"; };
3EE9BBE8268F90BE0073321A /* TimeStretchUnitPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeStretchUnitPersistenceTests.swift; sourceTree = "<group>"; };
3EEC040A26874041004478CE /* SequencerInfoDelegateProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequencerInfoDelegateProtocol.swift; sourceTree = "<group>"; };
3EEC040C26876E4D004478CE /* ShuffleMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShuffleMode.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2438,10 +2443,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3EE896672B1FDA820087ADC2 /* Collections in Frameworks */,
3EEF68AB2AC0E7DF00327699 /* libavutil.xcframework in Frameworks */,
3EEF68A92AC0E7DE00327699 /* libavformat.xcframework in Frameworks */,
3EE896692B1FDA820087ADC2 /* DequeModule in Frameworks */,
3EEF68B02AC0E7E600327699 /* libopenmpt.0.dylib in Frameworks */,
3EEF68AD2AC0E7E000327699 /* libswresample.xcframework in Frameworks */,
3EE8966B2B1FDA820087ADC2 /* OrderedCollections in Frameworks */,
3EEF68A72AC0E7DD00327699 /* libavcodec.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down Expand Up @@ -2560,6 +2568,7 @@
children = (
3E18817F2B1D51BA0056711A /* LastFM_WSClientProtocol.swift */,
3E18817D2B1D51970056711A /* LastFM_WSClient.swift */,
3EE896622B1FD1360087ADC2 /* LastFMScrobbleCache.swift */,
);
path = LastFM;
sourceTree = "<group>";
Expand Down Expand Up @@ -5121,6 +5130,9 @@
);
name = Aural;
packageProductDependencies = (
3EE896662B1FDA820087ADC2 /* Collections */,
3EE896682B1FDA820087ADC2 /* DequeModule */,
3EE8966A2B1FDA820087ADC2 /* OrderedCollections */,
);
productName = Aural;
productReference = 3E6C0EBF25CEB3ED00BF0D07 /* Aural.app */;
Expand Down Expand Up @@ -5195,6 +5207,7 @@
);
mainGroup = 3E6C0EB625CEB3ED00BF0D07;
packageReferences = (
3EE896652B1FDA820087ADC2 /* XCRemoteSwiftPackageReference "swift-collections" */,
);
productRefGroup = 3E6C0EC025CEB3ED00BF0D07 /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -5531,6 +5544,7 @@
3E09D0EA26805C19008ECB8C /* FavoritePersistentState.swift in Sources */,
3E6C118325CEBD6D00BF0D07 /* FavoritesDelegate.swift in Sources */,
3E6C119625CEBD6D00BF0D07 /* FavoritesDelegateProtocol.swift in Sources */,
3EE896632B1FD1360087ADC2 /* LastFMScrobbleCache.swift in Sources */,
3E8C7932269C4278005E78D1 /* FavoritesListNotifications.swift in Sources */,
3E6C126325CEBE0600BF0D07 /* FavoritesManagerViewController.swift in Sources */,
3E6C12B325CEBE5800BF0D07 /* FavoritesMenuController.swift in Sources */,
Expand Down Expand Up @@ -6920,6 +6934,35 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */

/* Begin XCRemoteSwiftPackageReference section */
3EE896652B1FDA820087ADC2 /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.5;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
3EE896662B1FDA820087ADC2 /* Collections */ = {
isa = XCSwiftPackageProductDependency;
package = 3EE896652B1FDA820087ADC2 /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = Collections;
};
3EE896682B1FDA820087ADC2 /* DequeModule */ = {
isa = XCSwiftPackageProductDependency;
package = 3EE896652B1FDA820087ADC2 /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = DequeModule;
};
3EE8966A2B1FDA820087ADC2 /* OrderedCollections */ = {
isa = XCSwiftPackageProductDependency;
package = 3EE896652B1FDA820087ADC2 /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = OrderedCollections;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 3E6C0EB725CEB3ED00BF0D07 /* Project object */;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307",
"version" : "1.0.5"
}
}
],
"version" : 2
}
Binary file not shown.
2 changes: 2 additions & 0 deletions Source/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {

// Initiate the recurring task that periodically saves persistent app state.
objectGraph.beginPeriodicPersistence()

objectGraph.lastFMClient.retryFailedScrobbleAttempts()
}

/// Opens the application with a single file (audio file or playlist)
Expand Down
139 changes: 139 additions & 0 deletions Source/LastFM/LastFMScrobbleCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//
// LastFMScrobbleCache.swift
// Aural
//
// Copyright © 2021 Kartik Venugopal. All rights reserved.
//
// This software is licensed under the MIT software license.
// See the file "LICENSE" in the project root directory for license terms.
//

import Foundation
import OrderedCollections

fileprivate let numberOfSecondsInTwoWeeks: Int = 14 * 86400
fileprivate let maxScrobbleAttempts: Int = 5

class LastFMScrobbleCache: PersistentModelObject {

private var queue: OrderedDictionary<String, LastFMScrobble> = .init()

var allEntries: [LastFMScrobble] {
Array(queue.values)
}

init(persistentState: LastFMScrobbleCachePersistentState?) {

let scrobbles = persistentState?.scrobbles?.compactMap {LastFMScrobble(persistentState: $0)}.filter {$0.isCurrent} ?? []

for scrobble in scrobbles {
queue[scrobble.id] = scrobble
}
}

func markFailedScrobbleAttempt(artist: String, title: String, album: String?, timestamp: Int) {

let attemptId = "\(artist)|\(title)|\(album ?? "")"

if let existingScrobble = queue[attemptId] {

existingScrobble.markFailedAttempt()

if existingScrobble.reachedMaxAttempts {
queue.removeValue(forKey: attemptId)
}

} else {

let newScrobble = LastFMScrobble(artist: artist, title: title, timestamp: timestamp, album: album)
queue[attemptId] = newScrobble
}
}

func invalidateEntry(artist: String, title: String, album: String?) {

let attemptId = "\(artist)|\(title)|\(album ?? "")"
queue.removeValue(forKey: attemptId)
}

var persistentState: LastFMScrobbleCachePersistentState {
LastFMScrobbleCachePersistentState(scrobbles: queue.values.map {LastFMScrobblePersistentState(scrobble: $0)})
}
}

class LastFMScrobble {

let artist: String
let title: String
let timestamp: Int

let album: String?
var numScrobbleAttempts: Int

var hasExpired: Bool {
(Date.nowEpochTime - timestamp) >= numberOfSecondsInTwoWeeks
}

var reachedMaxAttempts: Bool {
numScrobbleAttempts >= maxScrobbleAttempts
}

var isCurrent: Bool {
!(hasExpired || reachedMaxAttempts)
}

lazy var id: String = "\(artist)|\(title)|\(album ?? "")"

init(artist: String, title: String, timestamp: Int, album: String?) {

self.artist = artist
self.title = title
self.timestamp = timestamp
self.album = album
self.numScrobbleAttempts = 1
}

init?(persistentState: LastFMScrobblePersistentState) {

guard let artist = persistentState.artist, let title = persistentState.title,
let timestamp = persistentState.timestamp, let numScrobbleAttempts = persistentState.numScrobbleAttempts else {

return nil
}

self.artist = artist
self.title = title
self.timestamp = timestamp

self.album = persistentState.album
self.numScrobbleAttempts = numScrobbleAttempts
}

func markFailedAttempt() {
numScrobbleAttempts.increment()
}
}

struct LastFMScrobbleCachePersistentState: Codable {

let scrobbles: [LastFMScrobblePersistentState]?
}

struct LastFMScrobblePersistentState: Codable {

let artist: String?
let title: String?
let timestamp: Int?

let album: String?
var numScrobbleAttempts: Int?

init(scrobble: LastFMScrobble) {

self.artist = scrobble.artist
self.title = scrobble.title
self.timestamp = scrobble.timestamp
self.album = scrobble.album
self.numScrobbleAttempts = scrobble.numScrobbleAttempts
}
}
49 changes: 39 additions & 10 deletions Source/LastFM/LastFM_WSClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,34 @@ class LastFM_WSClient: LastFM_WSClientProtocol {
private static let jsonDecoder: JSONDecoder = JSONDecoder()

private let httpClient: HTTPClient = .shared
private let cache: LastFMScrobbleCache

private lazy var messenger: Messenger = .init(for: self)
private lazy var fileReader: FileReader = objectGraph.fileReader

static let shared: LastFM_WSClient = .init()
private let retryOpQueue: OperationQueue = .init(opCount: 1, qos: .background)

private init() {
init(cache: LastFMScrobbleCache) {

self.cache = cache

messenger.subscribe(to: .favoritesList_trackAdded, handler: favoriteAdded(favorite:))
messenger.subscribe(to: .favoritesList_tracksRemoved, handler: favoritesRemoved(favorites:))
}

func retryFailedScrobbleAttempts() {

let prefs = objectGraph.preferences.metadataPreferences.lastFM
guard prefs.enableScrobbling, let sessionKey = prefs.sessionKey else {return}

for entry in cache.allEntries {

retryOpQueue.addOperation {
self.scrobbleTrack(artist: entry.artist, title: entry.title, album: entry.album, timestamp: entry.timestamp, usingSessionKey: sessionKey)
}
}
}

// MARK: Get Token ------------------------------------------------------------

func getToken() throws -> LastFMToken {
Expand Down Expand Up @@ -84,17 +100,24 @@ class LastFM_WSClient: LastFM_WSClientProtocol {
return
}

scrobbleTrack(artist: artist, title: title, album: track.album, timestamp: timestamp, usingSessionKey: sessionKey)
}

func scrobbleTrack(artist: String, title: String, album: String?, timestamp: Int, usingSessionKey sessionKey: String) {

var failed: Bool = false

do {

var signature = "api_key\(Self.apiKey)artist\(artist)methodtrack.scrobblesk\(sessionKey)timestamp\(timestamp)track\(title)\(Self.sharedSecret)"

if let album = track.album {
if let album = album {
signature = "album\(album)" + signature
}

var urlString = "\(Self.webServicesBaseURL)?method=track.scrobble&sk=\(sessionKey.encodedAsURLQueryParameter())&api_key=\(Self.apiKey)&artist=\(artist.encodedAsURLQueryParameter())&timestamp=\(timestamp)&track=\(title.encodedAsURLQueryParameter())&api_sig=\(signature.utf8EncodedString().MD5Hex())&format=json"

if let album = track.album {
if let album = album {
urlString += "&album=\(album.encodedAsURLQueryParameter())"
}

Expand All @@ -103,13 +126,21 @@ class LastFM_WSClient: LastFM_WSClientProtocol {
}

try httpClient.performPOST(toURL: url, withHeaders: [:], withBody: nil)
NSLog("Last.fm: Successfully Scrobbled: Artist='\(artist)', Title='\(title)' !")

} catch let httpError as HTTPError {
NSLog("Failed to scrobble track '\(track.displayName)' on Last.fm. HTTP Error: \(httpError.code)")

NSLog("Failed to scrobble track '\(artist) - \(title)' on Last.fm. HTTP Error: \(httpError.code)")
failed = true

} catch {
NSLog("Failed to scrobble track '\(track.displayName)' on Last.fm. Error: \(error.localizedDescription)")
NSLog("Failed to scrobble track '\(artist) - \(title)' on Last.fm. Error: \(error.localizedDescription)")
failed = true
}

if failed {
cache.markFailedScrobbleAttempt(artist: artist, title: title, album: album, timestamp: timestamp)
} else {
cache.invalidateEntry(artist: artist, title: title, album: album)
}
}

Expand Down Expand Up @@ -140,7 +171,6 @@ class LastFM_WSClient: LastFM_WSClientProtocol {
}

_ = try httpClient.performPOST(toURL: url, withHeaders: [:], withBody: nil)
NSLog("Last.fm: Successfully Loved: Artist='\(artist)', Title='\(title)' !")

} catch let httpError as HTTPError {
NSLog("Failed to love track '\(artist) - \(title)' on Last.fm. HTTP Error: \(httpError.code)")
Expand Down Expand Up @@ -177,7 +207,6 @@ class LastFM_WSClient: LastFM_WSClientProtocol {
}

_ = try httpClient.performPOST(toURL: url, withHeaders: [:], withBody: nil)
NSLog("Last.fm: Successfully Unloved: Artist='\(artist)', Title='\(title)' !")

} catch let httpError as HTTPError {
NSLog("Failed to unlove track '\(artist) - \(title)' on Last.fm. HTTP Error: \(httpError.code)")
Expand Down Expand Up @@ -242,7 +271,7 @@ class LastFM_WSClient: LastFM_WSClientProtocol {
guard let artist = metadata.artist, let title = metadata.title else {

NSLog("Cannot love track '\(favorite.file.lastPathComponent)' on Last.fm because it does not have both title and artist metadata.")
return
continue
}

self.doUnloveTrack(artist: artist, title: title, usingSessionKey: sessionKey)
Expand Down
4 changes: 4 additions & 0 deletions Source/LastFM/LastFM_WSClientProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ protocol LastFM_WSClientProtocol {

func scrobbleTrack(track: Track, timestamp: Int, usingSessionKey sessionKey: String)

func scrobbleTrack(artist: String, title: String, album: String?, timestamp: Int, usingSessionKey sessionKey: String)

func retryFailedScrobbleAttempts()

func loveTrack(track: Track, usingSessionKey sessionKey: String)

func unloveTrack(track: Track, usingSessionKey sessionKey: String)
Expand Down
Loading

0 comments on commit ecdb891

Please sign in to comment.