-
Notifications
You must be signed in to change notification settings - Fork 1
Technical Overview
This page provides a comprehensive breakdown of the foo_mac_scrobble codebase, detailing each module's responsibilities and how components interact based on the actual implementation.
foo_mac_scrobble/
├── Core Logic (C++)
│ ├── lastfm_api.* → Last.fm API client
│ ├── session_manager.* → Session persistence
│ ├── scrobble_queue.* → Offline queue management
│ ├── play_callback.cpp → Playback event hooks
│ ├── initquit.cpp → Lifecycle & worker thread
│ ├── config.* → Configuration management
│ ├── preferences.cpp → Preferences registration
│ └── main.cpp → Component entry point
│
├── macOS UI (Objective-C++)
│ ├── Mac/fooLastfmMacPreferences.* → Preferences panel
│ └── Mac/*.xib → Interface Builder files
│
├── Utilities
│ ├── safe_log_utils.h → Sensitive data masking
│ ├── stdafx.h → Precompiled headers
│ └── foobar2000-mac-class-suffix.h → macOS compatibility
│
└── foobar2000 SDK
├── SDK/ → Core foobar2000 headers
└── pfc/ → Utility library
Purpose: Last.fm Web Services API client
Lines of Code: 88 (header) + 637 (implementation) = 725 lines
Key Classes:
class LastfmApi {
public:
// Data structure for track information
struct TrackInfo {
std::string artist;
std::string track;
std::string album;
std::string album_artist;
int duration;
int track_number;
time_t timestamp;
};
// Authentication
std::string get_auth_url();
bool authenticate(const std::string& token);
bool validate_session();
bool is_authenticated();
// Session management
void set_credentials(const char* api_key, const char* api_secret);
void set_session_key(const char* session_key);
std::string get_session_key();
bool has_saved_session();
// Scrobbling
bool update_now_playing(const TrackInfo& track);
bool scrobble_track(const TrackInfo& track);
private:
std::string generate_signature(const std::map<std::string, std::string>& params);
std::string make_request(const std::string& method,
const std::map<std::string, std::string>& params);
std::string generate_token();
};Responsibilities:
- Generate authentication URLs with proper tokens
- Sign all API requests with MD5 signatures
- Submit individual scrobbles (not batched)
- Update "Now Playing" status on Last.fm
- Parse XML responses from Last.fm
- Handle API errors and rate limiting
- Validate session keys
Dependencies:
- libcurl (for HTTP requests)
- OpenSSL MD5 (for signature generation)
-
pfc/string_base.h(for string handling)
API Endpoints Used:
-
auth.getMobileSession— Exchange token for session key -
track.scrobble— Submit listening history (single track) -
track.updateNowPlaying— Update current track -
auth.getSession— Validate existing session
Implementation Details:
- Uses mobile authentication flow (auth.getMobileSession)
- Implements proper Last.fm API signature generation
- Handles network errors with appropriate error codes
- Thread-safe API calls (can be called from worker thread)
Purpose: Persist and manage Last.fm session credentials
Lines of Code: 42 (header) + 153 (implementation) = 195 lines
Key Classes:
class SessionManager {
public:
bool load_session(std::string& session_key, std::string& username);
void save_session(const std::string& session_key,
const std::string& username);
void clear_session();
private:
std::string get_session_file_path();
};Storage Format:
{
"session_key": "d580d57f32848420e4155f4r4d5e6d8e",
"username": "lastfm_username"
}File Location:
~/Library/foobar2000-v2/lastfm_session.json
Responsibilities:
- Load session data on component startup
- Save session after successful authentication
- Provide session validation checks
- Clear session on logout or re-authentication
- Handle file I/O errors gracefully
Dependencies:
-
nlohmann/json(JSON parsing/serialization) -
pfc/pathUtils.h(for file paths)
Migration Support:
- Supports loading legacy sessions from foobar2000 config
- Migrates old config-based sessions to JSON file format
- Validates session keys before use
Purpose: Manage offline queue with disk persistence
Lines of Code: 77 (header) + 341 (implementation) = 418 lines
Key Classes:
class ScrobbleQueue {
public:
void add_track(const LastfmApi::TrackInfo& track);
void process_queue();
void clear_queue();
void load_queue();
void save_queue();
bool is_network_available();
private:
std::vector<LastfmApi::TrackInfo> queue_;
std::mutex queue_mutex_;
std::string get_queue_file_path();
};Storage Format:
[
{
"artist": "Queen",
"track": "Bohemian Rhapsody",
"album": "A Night at the Opera",
"album_artist": "Queen",
"duration": 354,
"track_number": 11,
"timestamp": 1700000000
}
]File Location:
~/Library/foobar2000-v2/lastfm_scrobble_queue.json
Responsibilities:
- Queue tracks that meet scrobble threshold
- Persist queue to disk immediately on changes
- Load queue from disk on startup
- Provide thread-safe access to queue
- Process queue periodically (called by worker thread)
- Check network availability before processing
- Remove successfully submitted tracks
- Handle partial failures (retry individual tracks)
Thread Safety:
- All public methods are mutex-protected
- Supports concurrent access from UI and worker threads
- Uses std::lock_guard for RAII-style locking
Network Detection:
- Uses SystemConfiguration framework (macOS)
- Checks network reachability before attempting scrobbles
- Queues tracks when offline, processes when online
Purpose: Hook into foobar2000 playback events
Lines of Code: 195 lines
Key Class:
class scrobble_callback : public play_callback_static {
public:
// Inherited from play_callback_static
void on_playback_new_track(metadb_handle_ptr track) override;
void on_playback_time(double time) override;
void on_playback_stop(play_control::t_stop_reason reason) override;
void on_playback_pause(bool state) override;
void on_playback_seek(double time) override;
unsigned get_flags() override {
return flag_on_playback_new_track |
flag_on_playback_time |
flag_on_playback_stop |
flag_on_playback_pause |
flag_on_playback_seek;
}
private:
metadb_handle_ptr current_track_;
double track_length_;
bool has_scrobbled_;
bool has_updated_now_playing_;
double scrobble_time_;
};Event Handling:
| Event | Action |
|---|---|
on_playback_new_track() |
Capture track metadata, reset state, update "Now Playing" |
on_playback_time() |
Check if threshold reached, queue for scrobbling |
on_playback_stop() |
Finalize current track if needed |
on_playback_pause() |
No action (scrobbling continues) |
on_playback_seek() |
Track seeking behavior |
Scrobble Logic:
void on_playback_time(double time) {
if (has_scrobbled_) return;
// Calculate percentage played
double percent = (time / track_length_) * 100.0;
int threshold = cfg_scrobble_percent.get();
// Check if threshold reached AND minimum 30 seconds
if (percent >= threshold || time >= 30.0) {
// Extract track metadata
LastfmApi::TrackInfo track_info;
track_info.artist = get_metadata(current_track_, "artist");
track_info.track = get_metadata(current_track_, "title");
track_info.album = get_metadata(current_track_, "album");
track_info.duration = static_cast<int>(track_length_);
track_info.timestamp = std::time(nullptr);
// Add to queue
if (g_scrobble_queue) {
g_scrobble_queue->add_track(track_info);
}
has_scrobbled_ = true;
}
}Now Playing Updates:
void on_playback_new_track(metadb_handle_ptr track) {
current_track_ = track;
track_length_ = track->get_length();
has_scrobbled_ = false;
has_updated_now_playing_ = false;
// Update "Now Playing" immediately
if (cfg_enabled.get() && g_lastfm_api && g_lastfm_api->is_authenticated()) {
LastfmApi::TrackInfo track_info = extract_track_info(track);
g_lastfm_api->update_now_playing(track_info);
has_updated_now_playing_ = true;
}
}Responsibilities:
- Monitor all playback state changes
- Extract track metadata (artist, title, album, duration, etc.)
- Calculate playback percentage
- Update "Now Playing" when track starts
- Queue eligible tracks for scrobbling
- Prevent duplicate scrobbles per session
- Handle seek/skip behavior appropriately
Purpose: Component lifecycle management
Lines of Code: 248 lines
Key Classes:
class queue_processor_thread {
public:
void start();
void stop();
private:
void worker_loop();
std::atomic<bool> m_running;
std::thread m_thread;
};
class initquit_lastfm : public initquit {
public:
void on_init() override;
void on_quit() override;
};Initialization Sequence:
- Create global instances (LastfmApi, SessionManager, ScrobbleQueue)
- Load API credentials from configuration
- Log credential status (with safe logging)
- Load session from file or legacy config
- Validate session key if present
- Migrate legacy config sessions to JSON file
- Initialize scrobble queue from disk
- Start background worker thread
- Log initialization summary
Background Worker:
void worker_loop() {
while (m_running) {
// Sleep for 30 seconds (checking m_running every second)
for (int i = 0; i < 30 && m_running; ++i) {
std::this_thread::sleep_for(std::chrono::seconds(1));
}
if (!m_running) break;
// Process queue without blocking UI
if (g_scrobble_queue) {
g_scrobble_queue->process_queue();
}
}
}Shutdown Sequence:
- Signal worker thread to stop
- Join worker thread (wait for completion)
- Save any pending queue changes to disk
- Delete global instances (ScrobbleQueue, SessionManager, LastfmApi)
- Log shutdown complete
Global Instances:
namespace foo_lastfm {
LastfmApi* g_lastfm_api = nullptr;
SessionManager* g_session_manager = nullptr;
ScrobbleQueue* g_scrobble_queue = nullptr;
}Responsibilities:
- Initialize all component subsystems
- Load and validate session credentials
- Start background processing thread
- Clean shutdown of all resources
- Ensure no memory leaks or resource leaks
Purpose: Configuration variable declarations
Lines of Code: 53 (header) + 82 (implementation) = 135 lines
Key Variables:
// Configuration GUIDs
static const GUID guid_cfg_api_key = { /* ... */ };
static const GUID guid_cfg_api_secret = { /* ... */ };
static const GUID guid_cfg_session_key = { /* ... */ };
static const GUID guid_cfg_username = { /* ... */ };
static const GUID guid_cfg_enabled = { /* ... */ };
static const GUID guid_cfg_scrobble_percent = { /* ... */ };
static const GUID guid_cfg_debug_enabled = { /* ... */ };
// Configuration objects
extern cfg_string cfg_api_key;
extern cfg_string cfg_api_secret;
extern cfg_string cfg_session_key; // Legacy support
extern cfg_string cfg_username;
extern cfg_bool cfg_enabled;
extern cfg_int cfg_scrobble_percent;
extern cfg_bool cfg_debug_enabled;Default Values:
-
cfg_api_key: Empty (user must provide) -
cfg_api_secret: Empty (user must provide) -
cfg_session_key: Empty (migrated to JSON file) -
cfg_username: Empty (loaded from session) -
cfg_enabled:true(scrobbling enabled by default) -
cfg_scrobble_percent:50(50% threshold) -
cfg_debug_enabled:false(debug logging disabled)
Responsibilities:
- Define unique GUIDs for each preference
- Provide typed access to configuration values
- Automatically persist changes to disk
- Thread-safe read/write access via SDK
Purpose: Register preferences page with foobar2000
Lines of Code: 207 lines
Key Class:
class preferences_page_impl : public preferences_page_v3 {
public:
const char* get_name() override {
return "Last.fm Scrobbler";
}
GUID get_guid() override {
return guid_preferences_page_lastfm;
}
GUID get_parent_guid() override {
return preferences_page::guid_tools;
}
preferences_page_instance::ptr instantiate(
HWND parent,
preferences_page_callback::ptr callback) override;
};Responsibilities:
- Register preferences page under "Tools" menu
- Create native macOS preferences view controller
- Bridge between foobar2000 SDK and Cocoa UI
- Handle preferences page lifecycle
Purpose: Component entry point and metadata
Lines of Code: 31 lines
DECLARE_COMPONENT_VERSION(
"Last.fm Scrobbler",
"1.0",
"Last.fm scrobbling support for foobar2000 on macOS\n"
"Built with native Cocoa UI and async networking\n\n"
"© 2025 Oleksandr Velychko\n"
"https://github.com/avelytchko/foo_mac_scrobble"
);
VALIDATE_COMPONENT_FILENAME("foo_mac_scrobble.component");Responsibilities:
- Declare component metadata (name, version, description)
- Validate component filename
- Register component with foobar2000
Purpose: Native Cocoa preferences panel
Lines of Code: 22 (header) + 634 (implementation) = 656 lines
Key Class:
@interface fooLastfmMacPreferences : NSViewController
// UI Elements
@property(nonatomic, strong) NSTextField* apiKeyField;
@property(nonatomic, strong) NSTextField* apiSecretField;
@property(nonatomic, strong) NSSlider* thresholdSlider;
@property(nonatomic, strong) NSTextField* thresholdLabel;
@property(nonatomic, strong) NSButton* enabledCheckbox;
@property(nonatomic, strong) NSButton* debugCheckbox;
@property(nonatomic, strong) NSTextField* statusLabel;
@property(nonatomic, strong) NSButton* authButton;
// Actions
- (void)authenticateButtonClicked;
- (void)thresholdSliderChanged;
- (void)enabledCheckboxChanged;
- (void)debugCheckboxChanged;
- (void)apiCredentialsChanged;
// Data binding
- (void)loadPreferences;
- (void)savePreferences;
- (void)updateAuthenticationStatus;
@endAuthentication Flow Implementation:
- (void)authenticateButtonClicked {
// 1. Generate auth URL
std::string auth_url = g_lastfm_api->get_auth_url();
// 2. Open browser
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:@(auth_url.c_str())]];
// 3. Prompt user to paste URL with token
NSAlert* alert = [[NSAlert alloc] init];
[alert setMessageText:@"Complete Authentication"];
[alert setInformativeText:@"After approving on Last.fm, copy the full URL and paste it here."];
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
NSTextField* urlField = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 400, 24)];
[alert setAccessoryView:urlField];
NSInteger result = [alert runModal];
if (result == NSAlertFirstButtonReturn) {
NSString* url = [urlField stringValue];
// 4. Extract token from URL
std::string token = extract_token_from_url([url UTF8String]);
// 5. Authenticate with token
if (g_lastfm_api->authenticate(token)) {
[self updateAuthenticationStatus];
[self showSuccessMessage:@"Authenticated successfully!"];
} else {
[self showErrorMessage:@"Authentication failed"];
}
}
}Data Flow:
User Input → NSControl → Action Method → C++ cfg_* variable → Disk
↓
Component Core
Responsibilities:
- Display current configuration values
- Handle user input and validation
- Trigger authentication flow
- Show authentication status
- Update preferences in real-time
- Provide visual feedback for actions
Purpose: Mask sensitive data in logs
Lines of Code: 20 lines
inline std::string redact_secret(const std::string& value) {
if (value.empty())
return "<empty>";
if (value.length() <= 6)
return "******";
return value.substr(0, 2) + "****" + value.substr(value.length() - 2);
}Usage:
// Instead of logging full API key:
// FB2K_console_formatter() << "API Key: " << api_key;
// Use redaction:
FB2K_console_formatter() << "API Key: " << redact_secret(api_key);
// Output: "API Key: ab****cd"Prevents:
- Accidental exposure of API keys in console logs
- Session key leaks in debug output
- Security vulnerabilities from verbose logging
Purpose: Precompiled header for common includes
Lines of Code: 8 lines
#pragma once
#include "SDK/foobar2000-lite.h"
#include "libPPUI/PP-console.h"Speeds up compilation by precompiling commonly used headers.
Purpose: macOS compatibility defines
Lines of Code: 2 lines
#define FB2K_CLASS_SUFFIX _macEnsures proper class name suffixing for macOS-specific code.
| Component | Purpose |
|---|---|
SDK/foobar2000-lite.h |
Main SDK header, core types |
SDK/play_callback.h |
Playback event interface |
SDK/initquit.h |
Component lifecycle |
SDK/preferences_page.h |
Preferences UI registration |
SDK/metadb.h |
Track metadata access |
libPPUI/PP-console.h |
Console logging (FB2K_console_formatter) |
pfc/string_base.h |
String utilities (pfc::string8) |
pfc/pathUtils.h |
File path handling |
Component Registration:
// Declare component factory
static service_factory_single_t<initquit_lastfm> g_initquit_lastfm;
static service_factory_single_t<scrobble_callback> g_scrobble_callback;
static service_factory_single_t<preferences_page_impl> g_preferences_page;Console Logging:
// Info level
FB2K_console_formatter() << "Last.fm: Message";
// Using cfg_debug_enabled for conditional logging
if (cfg_debug_enabled.get()) {
FB2K_console_formatter() << "Last.fm: Debug info";
}User plays track in foobar2000
│
▼
play_callback::on_playback_new_track()
│
├─→ Update "Now Playing" (immediate)
│ └─→ LastfmApi::update_now_playing()
│ └─→ HTTP POST to Last.fm
│
▼
play_callback::on_playback_time()
│
├─→ Check threshold (50% or 30 seconds)
│
▼ (threshold met)
│
ScrobbleQueue::add_track()
│
├─→ Save to disk (JSON)
│
▼
queue_processor_thread (every 30s)
│
├─→ Check network availability
│
▼ (network available)
│
ScrobbleQueue::process_queue()
│
├─→ Get queued tracks
│
▼
LastfmApi::scrobble_track() (for each track)
│
├─→ Generate signature
├─→ HTTP POST to Last.fm
│
▼ (success)
│
Remove track from queue
│
└─→ Save updated queue to disk
- Handles all UI interactions
- Receives playback callbacks
- Updates preferences UI
- Adds tracks to queue (thread-safe via mutex)
- Runs in background independently
- Processes queue every 30 seconds
- Makes HTTP requests (non-blocking for UI)
- Handles network errors and retries
- Updates queue file on disk
class ScrobbleQueue {
private:
std::mutex queue_mutex_;
public:
void add_track(const LastfmApi::TrackInfo& track) {
std::lock_guard<std::mutex> lock(queue_mutex_);
// Safe queue access
}
};foo_mac_scrobble.xcodeproj
├── Targets
│ └── foo_mac_scrobble (Component Bundle)
├── Build Settings
│ ├── Header Search Paths → SDK includes
│ ├── Framework Search Paths → System frameworks
│ ├── Other Linker Flags → -lcurl
│ └── Product Bundle Identifier → com.foo.mac.scrobble
└── Build Phases
├── Sources (C++/Objective-C++)
├── Frameworks (Cocoa, SystemConfiguration)
└── Copy Bundle Resources (.xib files)
- Language: C++17, Objective-C++ with ARC
- SDK: macOS 12.0+
- Architectures: arm64, x86_64 (Universal Binary)
- Optimization: -O2 (Release), -O0 (Debug)
Cocoa.framework → macOS UI
SystemConfiguration.framework → Network reachability
libcurl (system) → HTTP client
// Info: Normal operations
FB2K_console_formatter() << "Last.fm: Queue initialized, 5 tracks loaded";
// Warning: Recoverable issues (only if debug enabled)
if (cfg_debug_enabled.get()) {
FB2K_console_formatter() << "Last.fm: Network timeout, will retry";
}
// Error: Critical problems
FB2K_console_formatter() << "Last.fm: Invalid session key, re-authentication required";| Category | Handling Strategy |
|---|---|
| Network errors | Queue tracks, retry in 30s |
| Authentication errors | Clear session, prompt re-auth |
| API errors | Log and skip track |
| File I/O errors | Log but continue operation |
| Invalid metadata | Skip track, log warning |
- If session is invalid → Disable scrobbling, show error
- If network is offline → Queue all tracks
- If disk write fails → Keep queue in memory
- If API rate limited → Retry with exponential backoff
| Category | Files | Lines of Code |
|---|---|---|
| Core C++ Logic | 8 | ~1,894 |
| C++ Headers | 8 | ~372 |
| Objective-C++ UI | 1 | ~634 |
| Total Component | 17 | ~2,900 |
- Memory: ~2 MB resident, ~500 KB private
- CPU: < 0.1% average, spikes during API calls
- Disk: ~10 KB (queue + session files)
- Network: ~500 bytes per scrobble + "Now Playing" update
- Queue add: < 1 ms
- API request: 100-500 ms (network dependent)
- Disk write: < 10 ms
- UI interaction: < 50 ms
In Memory:
- API credentials stored in plain text (cfg_string)
- Session keys stored in plain text
- Protected by macOS user account permissions
In Logs:
- Uses
redact_secret()to mask sensitive values - Debug logging must be explicitly enabled
- Full credentials never logged
On Disk:
- Session file readable only by user (chmod 600)
- Config stored in foobar2000's protected directory
- No encryption of stored credentials
- HTTPS used for all Last.fm API calls
- SSL certificate validation via libcurl defaults
- Proper API signature prevents request tampering
- Session keys expire only when revoked on Last.fm
- Love/unlove tracks from foobar2000
- Scrobble editing/correction
- Statistics and listening reports
- Multiple Last.fm account support
- Batch scrobbling (currently one-by-one)
- Add comprehensive unit tests
- Implement exponential backoff for retries
- Enhanced network connectivity detection
- Refactor preferences UI for better testability
- Consider migration to NSURLSession from libcurl
- How It Works — Architecture and flow
- Building from Source — Compilation guide
- Configuration Files — File formats and locations
- Contributing — Development guidelines
Questions about the codebase? Open an issue on GitHub.