Skip to content

Technical Overview

avelytchko edited this page Nov 17, 2025 · 1 revision

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.


Module Architecture

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

Core Modules

lastfm_api.h / lastfm_api.cpp

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)

session_manager.h / session_manager.cpp

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

scrobble_queue.h / scrobble_queue.cpp

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

play_callback.cpp

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

initquit.cpp

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:

  1. Create global instances (LastfmApi, SessionManager, ScrobbleQueue)
  2. Load API credentials from configuration
  3. Log credential status (with safe logging)
  4. Load session from file or legacy config
  5. Validate session key if present
  6. Migrate legacy config sessions to JSON file
  7. Initialize scrobble queue from disk
  8. Start background worker thread
  9. 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:

  1. Signal worker thread to stop
  2. Join worker thread (wait for completion)
  3. Save any pending queue changes to disk
  4. Delete global instances (ScrobbleQueue, SessionManager, LastfmApi)
  5. 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

config.h / config.cpp

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

preferences.cpp

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

main.cpp

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

macOS UI Layer

Mac/fooLastfmMacPreferences.h / .mm

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;

@end

Authentication 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

Utilities

safe_log_utils.h

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

stdafx.h

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.


foobar2000-mac-class-suffix.h

Purpose: macOS compatibility defines

Lines of Code: 2 lines

#define FB2K_CLASS_SUFFIX _mac

Ensures proper class name suffixing for macOS-specific code.


foobar2000 SDK Integration

Core SDK Components Used

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

SDK Patterns

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";
}

Data Flow Diagram

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

Thread Architecture

Main Thread (foobar2000 UI)

  • Handles all UI interactions
  • Receives playback callbacks
  • Updates preferences UI
  • Adds tracks to queue (thread-safe via mutex)

Worker Thread (queue_processor_thread)

  • 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

Thread Safety Mechanisms

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
    }
};

Build System

Xcode Project Structure

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)

Compiler Settings

  • Language: C++17, Objective-C++ with ARC
  • SDK: macOS 12.0+
  • Architectures: arm64, x86_64 (Universal Binary)
  • Optimization: -O2 (Release), -O0 (Debug)

Linked Frameworks

Cocoa.framework                   → macOS UI
SystemConfiguration.framework     → Network reachability
libcurl (system)                  → HTTP client

Error Handling Strategy

Logging Levels

// 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";

Error Categories

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

Graceful Degradation

  • 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

Performance Metrics

Source Code Statistics

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

Resource Usage

  • 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

Latency

  • Queue add: < 1 ms
  • API request: 100-500 ms (network dependent)
  • Disk write: < 10 ms
  • UI interaction: < 50 ms

Security Considerations

Sensitive Data Protection

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

Network Security

  • 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

Future Enhancements

Planned Features

  • Love/unlove tracks from foobar2000
  • Scrobble editing/correction
  • Statistics and listening reports
  • Multiple Last.fm account support
  • Batch scrobbling (currently one-by-one)

Technical Debt

  • 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

Related Documentation


Questions about the codebase? Open an issue on GitHub.

Clone this wiki locally