diff --git a/.env.example b/.env.example index 86ad7fb..fc421cf 100644 --- a/.env.example +++ b/.env.example @@ -22,16 +22,21 @@ CACHE_BACKUP_PATH=./backups STATS_DB_PATH=./stats.db # TTML API Configuration -# Single account (legacy, still supported): -#TTML_BEARER_TOKEN=your_bearer_token_here +# Bearer tokens are now auto-scraped from the upstream provider - only MUTs needed +# Single account: #TTML_MEDIA_USER_TOKEN=your_media_user_token_here # Multi-account support (comma-separated, preferred): -# Each bearer token must have a corresponding media user token in the same position -TTML_BEARER_TOKENS=token1,token2,token3 -TTML_MEDIA_USER_TOKENS=media1,media2,media3 +# Media User Tokens for multiple accounts - used for rate limit distribution +TTML_MEDIA_USER_TOKENS=mut1,mut2,mut3 + +# Token source for auto-scraping bearer tokens (web frontend URL) +TTML_TOKEN_SOURCE_URL= + +# Storefront is used for both browse path (/{storefront}/browse) and API requests +# (optional, defaults to storefront location for user, falls back to 'in') +# TTML_STOREFRONT=us -TTML_STOREFRONT=us TTML_BASE_URL= TTML_SEARCH_PATH= TTML_LYRICS_PATH= diff --git a/.gitignore b/.gitignore index c2aeadc..f02b5c2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,9 @@ go.work.sum # Cache database cache.db stats.db +storefront_cache.json lyrics-api-go test.xml /backups -/migration +/migration \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index f737acb..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,134 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -## [Unreleased] - 2024-11-29 - -### Added - -#### Multi-Account Support -- **Comma-separated token configuration**: Support for multiple TTML API accounts via `TTML_BEARER_TOKENS` and `TTML_MEDIA_USER_TOKENS` environment variables -- **Backwards compatibility**: Legacy single-token env vars (`TTML_BEARER_TOKEN`, `TTML_MEDIA_USER_TOKEN`) still work as fallback -- **Startup validation**: Mismatch between bearer tokens and media user tokens count triggers a clear error message at startup -- **Account naming**: Accounts are automatically named `Account-1`, `Account-2`, etc. for logging and monitoring - -#### Round-Robin Load Balancing -- **Even request distribution**: Requests are distributed evenly across all configured accounts using atomic round-robin selection -- **Thread-safe implementation**: Uses `sync/atomic` for lock-free concurrent access -- **Automatic failover**: On 401/429 errors, automatically skips to the next account and retries (up to 3 attempts or number of accounts, whichever is lower) - -#### Health Check Endpoint -- **New `/health` endpoint**: Returns service health status - - **Public access**: Shows basic status (`ok`/`degraded`/`unhealthy`), account count, and circuit breaker state - - **Authenticated access**: Additionally shows detailed token expiration info for each account -- **Token status levels**: `healthy` (>7 days), `expiring_soon` (≤7 days), `expired`, `error` -- **Useful for**: Railway health checks, external monitoring, debugging - -#### Token Expiration Monitoring -- **Multi-token monitoring**: Token monitor now checks all configured accounts -- **Aggregated notifications**: Single notification lists all expiring/expired tokens -- **Per-account status**: Notification messages include each account's name and days remaining - -### Changed - -#### Circuit Breaker Improvements -- **Scaled threshold**: Circuit breaker threshold now scales with account count (`base_threshold × num_accounts`) -- **Rationale**: With round-robin, each account may fail independently; scaling prevents premature circuit opening -- **Example**: With 3 accounts and base threshold of 5, circuit opens after 15 failures instead of 5 - -#### Account Manager Refactoring -- **New methods**: - - `getNextAccount()`: Returns next account in round-robin sequence (thread-safe) - - `skipCurrentAccount()`: Advances past a failing account (for 401/429 handling) - - `accountCount()`: Returns number of configured accounts - - `hasAccounts()`: Returns true if any accounts are configured -- **Panic prevention**: All methods safely handle empty account list without panicking - -#### Logging Improvements -- **Load balancing visibility**: Startup log shows "Initialized N TTML account(s) with round-robin load balancing" -- **Circuit breaker details**: Shows scaled threshold calculation in logs -- **Error context**: 401/429 error logs now include which account failed - -### Fixed - -- **Empty accounts handling**: Prevented potential panics when no accounts are configured -- **Thread safety**: Account rotation is now safe for concurrent requests - -### Configuration - -#### New Environment Variables -```bash -# Multi-account support (comma-separated, preferred) -TTML_BEARER_TOKENS=token1,token2,token3 -TTML_MEDIA_USER_TOKENS=media1,media2,media3 -``` - -#### Legacy Environment Variables (still supported) -```bash -# Single account (backwards compatible) -TTML_BEARER_TOKEN=your_token -TTML_MEDIA_USER_TOKEN=your_media_token -``` - -### API Endpoints - -#### GET /health -Returns service health status. - -**Public Response:** -```json -{ - "status": "ok", - "accounts": 3, - "circuit_breaker": "CLOSED" -} -``` - -**Authenticated Response** (with `Authorization` header): -```json -{ - "status": "ok", - "accounts": 3, - "circuit_breaker": "CLOSED", - "circuit_breaker_failures": 0, - "tokens": [ - { - "name": "Account-1", - "status": "healthy", - "expires": "2025-02-15 10:30:00", - "days_remaining": 78 - }, - { - "name": "Account-2", - "status": "expiring_soon", - "expires": "2024-12-05 10:30:00", - "days_remaining": 6 - } - ] -} -``` - -**Status Values:** -- `ok`: All systems healthy -- `degraded`: Circuit breaker open OR tokens expiring/expired -- `unhealthy`: No accounts configured - -### Files Modified - -- `config/config.go` - Added multi-account parsing and validation -- `services/ttml/account.go` - Refactored with round-robin and thread safety -- `services/ttml/types.go` - Changed `currentIndex` to `uint64` for atomic ops -- `services/ttml/client.go` - Round-robin selection, scaled circuit breaker -- `services/ttml/ttml.go` - Added empty accounts check -- `services/notifier/monitor.go` - Multi-token monitoring support -- `main.go` - Health endpoint, updated token monitor initialization -- `.env.example` - Updated with multi-account configuration examples -- `services/ttml/account_test.go` - Updated tests for new API - -### Migration Guide - -1. **No breaking changes**: Existing single-token configuration continues to work -2. **To add more accounts**: - - Set `TTML_BEARER_TOKENS` and `TTML_MEDIA_USER_TOKENS` (comma-separated) - - Remove or leave empty `TTML_BEARER_TOKEN` and `TTML_MEDIA_USER_TOKEN` -3. **Monitoring**: Use the new `/health` endpoint for service monitoring diff --git a/cache_helpers.go b/cache_helpers.go index 4c0d5cd..5caa56c 100644 --- a/cache_helpers.go +++ b/cache_helpers.go @@ -645,9 +645,9 @@ func cacheKeys(w http.ResponseWriter, r *http.Request) { if count < limit { keys = append(keys, map[string]interface{}{ - "key": key, - "size": len(entry.Value), - "is_lyrics": strings.HasPrefix(key, "ttml_lyrics:"), + "key": key, + "size": len(entry.Value), + "is_lyrics": strings.HasPrefix(key, "ttml_lyrics:"), "is_negative": strings.HasPrefix(key, "no_lyrics:"), }) count++ @@ -658,10 +658,10 @@ func cacheKeys(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "total_keys": total, - "matched_keys": count, - "limit": limit, - "keys": keys, + "total_keys": total, + "matched_keys": count, + "limit": limit, + "keys": keys, }) } diff --git a/circuitbreaker/circuitbreaker_test.go b/circuitbreaker/circuitbreaker_test.go index 21f2e31..e9c1665 100644 --- a/circuitbreaker/circuitbreaker_test.go +++ b/circuitbreaker/circuitbreaker_test.go @@ -509,28 +509,28 @@ func TestCircuitBreaker_ResetClearsHalfOpenStart(t *testing.T) { func TestCircuitBreaker_Threshold(t *testing.T) { tests := []struct { - name string - configThreshold int + name string + configThreshold int expectedThreshold int }{ { - name: "Custom threshold", - configThreshold: 10, + name: "Custom threshold", + configThreshold: 10, expectedThreshold: 10, }, { - name: "Small threshold", - configThreshold: 2, + name: "Small threshold", + configThreshold: 2, expectedThreshold: 2, }, { - name: "Default threshold when zero", - configThreshold: 0, + name: "Default threshold when zero", + configThreshold: 0, expectedThreshold: 5, // Default value }, { - name: "Default threshold when negative", - configThreshold: -1, + name: "Default threshold when negative", + configThreshold: -1, expectedThreshold: 5, // Default value }, } diff --git a/config/config.go b/config/config.go index 9a5f089..6473d63 100644 --- a/config/config.go +++ b/config/config.go @@ -24,18 +24,18 @@ type Config struct { CachedRateLimitBurstLimit int `envconfig:"CACHED_RATE_LIMIT_BURST_LIMIT" default:"20"` CacheInvalidationIntervalInSeconds int `envconfig:"CACHE_INVALIDATION_INTERVAL_IN_SECONDS" default:"3600"` LyricsCacheTTLInSeconds int `envconfig:"LYRICS_CACHE_TTL_IN_SECONDS" default:"86400"` - CacheAccessToken string `envconfig:"CACHE_ACCESS_TOKEN" default:""` - APIKey string `envconfig:"API_KEY" default:""` - APIKeyRequired bool `envconfig:"API_KEY_REQUIRED" default:"false"` + CacheAccessToken string `envconfig:"CACHE_ACCESS_TOKEN" default:""` + APIKey string `envconfig:"API_KEY" default:""` + APIKeyRequired bool `envconfig:"API_KEY_REQUIRED" default:"false"` // TTML API Configuration - // Single account (backwards compatible) - TTMLBearerToken string `envconfig:"TTML_BEARER_TOKEN" default:""` + // Token source for auto-scraping bearer tokens (web frontend URL) + TTMLTokenSourceURL string `envconfig:"TTML_TOKEN_SOURCE_URL" default:""` + // Single account (backwards compatible) - only MUT needed, bearer is auto-scraped TTMLMediaUserToken string `envconfig:"TTML_MEDIA_USER_TOKEN" default:""` - // Multi-account support (comma-separated) - TTMLBearerTokens string `envconfig:"TTML_BEARER_TOKENS" default:""` + // Multi-account support (comma-separated media user tokens) TTMLMediaUserTokens string `envconfig:"TTML_MEDIA_USER_TOKENS" default:""` - TTMLStorefront string `envconfig:"TTML_STOREFRONT" default:"us"` + TTMLStorefront string `envconfig:"TTML_STOREFRONT" default:"in"` TTMLBaseURL string `envconfig:"TTML_BASE_URL" default:""` TTMLSearchPath string `envconfig:"TTML_SEARCH_PATH" default:""` TTMLLyricsPath string `envconfig:"TTML_LYRICS_PATH" default:""` @@ -46,24 +46,25 @@ type Config struct { CircuitBreakerCooldownSecs int `envconfig:"CIRCUIT_BREAKER_COOLDOWN_SECS" default:"300"` // Seconds to wait before retrying (default: 5 minutes) // Legacy Provider Configuration (Spotify-based) - LyricsUrl string `envconfig:"LYRICS_URL" default:""` - TrackUrl string `envconfig:"TRACK_URL" default:""` - TokenUrl string `envconfig:"TOKEN_URL" default:""` - TokenKey string `envconfig:"TOKEN_KEY" default:"sp_dc_token"` - AppPlatform string `envconfig:"APP_PLATFORM" default:"WebPlayer"` - UserAgent string `envconfig:"USER_AGENT" default:"Mozilla/5.0"` - CookieStringFormat string `envconfig:"COOKIE_STRING_FORMAT" default:"sp_dc=%s"` - CookieValue string `envconfig:"COOKIE_VALUE" default:""` - ClientID string `envconfig:"CLIENT_ID" default:""` - ClientSecret string `envconfig:"CLIENT_SECRET" default:""` - OauthTokenUrl string `envconfig:"OAUTH_TOKEN_URL" default:"https://accounts.spotify.com/api/token"` - OauthTokenKey string `envconfig:"OAUTH_TOKEN_KEY" default:"oauth_token"` - TrackCacheTTLInSeconds int `envconfig:"TRACK_CACHE_TTL_IN_SECONDS" default:"86400"` + LyricsUrl string `envconfig:"LYRICS_URL" default:""` + TrackUrl string `envconfig:"TRACK_URL" default:""` + TokenUrl string `envconfig:"TOKEN_URL" default:""` + TokenKey string `envconfig:"TOKEN_KEY" default:"sp_dc_token"` + AppPlatform string `envconfig:"APP_PLATFORM" default:"WebPlayer"` + UserAgent string `envconfig:"USER_AGENT" default:"Mozilla/5.0"` + CookieStringFormat string `envconfig:"COOKIE_STRING_FORMAT" default:"sp_dc=%s"` + CookieValue string `envconfig:"COOKIE_VALUE" default:""` + ClientID string `envconfig:"CLIENT_ID" default:""` + ClientSecret string `envconfig:"CLIENT_SECRET" default:""` + OauthTokenUrl string `envconfig:"OAUTH_TOKEN_URL" default:"https://accounts.spotify.com/api/token"` + OauthTokenKey string `envconfig:"OAUTH_TOKEN_KEY" default:"oauth_token"` + TrackCacheTTLInSeconds int `envconfig:"TRACK_CACHE_TTL_IN_SECONDS" default:"86400"` } FeatureFlags struct { CacheCompression bool `envconfig:"FF_CACHE_COMPRESSION" default:"true"` CacheOnlyMode bool `envconfig:"FF_CACHE_ONLY_MODE" default:"false"` + PrettyLogs bool `envconfig:"FF_PRETTY_LOGS" default:"false"` } } @@ -111,17 +112,17 @@ var APIKeyProtectedPaths = []string{ } // TTMLAccount represents a single TTML API account +// Bearer token is now auto-scraped, only MUT is needed per account type TTMLAccount struct { Name string - BearerToken string MediaUserToken string - OutOfService bool // true if account has empty credentials (excluded from rotation) + OutOfService bool // true if account has empty MUT (excluded from rotation) } // funNames contains artist names for account logging var funNames = []string{ "Billie", "Toliver", "Taylor", "Dua", "Olivia", - "Charli", "Khalid", "Tyler", "Gunna", "Future", + "Charli", "Khalid", "Tyler", "Crywank", "Future", "Offset", "Metro", "Burna", "Phoebe", "Mitski", "Finneas", "Clairo", "Raye", "Hozier", "Gracie", "Adele", "Ye", "Abel", "Keem", "Yeat", @@ -132,28 +133,22 @@ var funNames = []string{ "Gryffin", "Rüfüs", "Jai", "Disclosure", "Kaytranada", } -// GetTTMLAccounts parses the comma-separated tokens and returns only ACTIVE accounts. -// Accounts with empty bearer token or media user token are excluded from rotation. -// Returns an error if the number of bearer tokens doesn't match media user tokens. -// Falls back to single token env vars if multi-account vars are not set. +// GetTTMLAccounts parses the comma-separated media user tokens and returns only ACTIVE accounts. +// Accounts with empty media user token are excluded from rotation. +// Bearer token is now auto-scraped - only MUTs needed per account. +// Falls back to single token env var if multi-account var is not set. func (c *Config) GetTTMLAccounts() ([]TTMLAccount, error) { - bearerTokens := c.Configuration.TTMLBearerTokens mediaUserTokens := c.Configuration.TTMLMediaUserTokens - // If multi-account vars are empty, fall back to single account - if bearerTokens == "" { - if c.Configuration.TTMLBearerToken == "" { - return nil, nil // No accounts configured - } - // Check if single account has valid credentials + // If multi-account var is empty, fall back to single account + if mediaUserTokens == "" { + // Check if single account has valid MUT if c.Configuration.TTMLMediaUserToken == "" { - log.Warnf("%s Account 'Billie' has empty credentials, excluding from rotation", logcolors.LogConfig) - return nil, nil + return nil, nil // No accounts configured } return []TTMLAccount{ { Name: "Billie", - BearerToken: c.Configuration.TTMLBearerToken, MediaUserToken: c.Configuration.TTMLMediaUserToken, OutOfService: false, }, @@ -161,35 +156,25 @@ func (c *Config) GetTTMLAccounts() ([]TTMLAccount, error) { } // Parse comma-separated values (preserve empty strings to maintain index alignment) - bearerList := splitAndTrimPreserveEmpty(bearerTokens) mediaUserList := splitAndTrimPreserveEmpty(mediaUserTokens) - // Validate: must have same number of tokens - if len(bearerList) != len(mediaUserList) { - return nil, fmt.Errorf( - "TTML account mismatch: %d bearer tokens but %d media user tokens. Each account needs both tokens", - len(bearerList), len(mediaUserList), - ) - } - - // Build list of active accounts only (those with valid credentials) - accounts := make([]TTMLAccount, 0, len(bearerList)) - for i := range bearerList { + // Build list of active accounts only (those with valid MUT) + accounts := make([]TTMLAccount, 0, len(mediaUserList)) + for i, mut := range mediaUserList { name := fmt.Sprintf("Account-%d", i+1) if i < len(funNames) { name = funNames[i] } - // Skip accounts with empty credentials - they're out of service - if bearerList[i] == "" || mediaUserList[i] == "" { - log.Warnf("%s Account '%s' has empty credentials, excluding from rotation", logcolors.LogConfig, name) + // Skip accounts with empty MUT - they're out of service + if mut == "" { + log.Warnf("%s Account '%s' has empty MUT, excluding from rotation", logcolors.LogConfig, name) continue } accounts = append(accounts, TTMLAccount{ Name: name, - BearerToken: bearerList[i], - MediaUserToken: mediaUserList[i], + MediaUserToken: mut, OutOfService: false, }) } @@ -199,72 +184,46 @@ func (c *Config) GetTTMLAccounts() ([]TTMLAccount, error) { // GetAllTTMLAccounts returns ALL accounts including out-of-service ones (for monitoring/display). // Use GetTTMLAccounts() for active accounts only. +// Bearer token is now auto-scraped - only MUTs are configured per account. func (c *Config) GetAllTTMLAccounts() ([]TTMLAccount, error) { - bearerTokens := c.Configuration.TTMLBearerTokens mediaUserTokens := c.Configuration.TTMLMediaUserTokens - // If multi-account vars are empty, fall back to single account - if bearerTokens == "" { - if c.Configuration.TTMLBearerToken == "" { + // If multi-account var is empty, fall back to single account + if mediaUserTokens == "" { + // Check if single account is configured (empty MUT = out of service) + if c.Configuration.TTMLMediaUserToken == "" { return nil, nil // No accounts configured } - outOfService := c.Configuration.TTMLMediaUserToken == "" return []TTMLAccount{ { Name: "Billie", - BearerToken: c.Configuration.TTMLBearerToken, MediaUserToken: c.Configuration.TTMLMediaUserToken, - OutOfService: outOfService, + OutOfService: false, // MUT is present }, }, nil } // Parse comma-separated values (preserve empty strings to maintain index alignment) - bearerList := splitAndTrimPreserveEmpty(bearerTokens) mediaUserList := splitAndTrimPreserveEmpty(mediaUserTokens) - // Validate: must have same number of tokens - if len(bearerList) != len(mediaUserList) { - return nil, fmt.Errorf( - "TTML account mismatch: %d bearer tokens but %d media user tokens. Each account needs both tokens", - len(bearerList), len(mediaUserList), - ) - } - // Build list of ALL accounts (including out-of-service) - accounts := make([]TTMLAccount, len(bearerList)) - for i := range bearerList { + accounts := make([]TTMLAccount, len(mediaUserList)) + for i, mut := range mediaUserList { name := fmt.Sprintf("Account-%d", i+1) if i < len(funNames) { name = funNames[i] } - outOfService := bearerList[i] == "" || mediaUserList[i] == "" accounts[i] = TTMLAccount{ Name: name, - BearerToken: bearerList[i], - MediaUserToken: mediaUserList[i], - OutOfService: outOfService, + MediaUserToken: mut, + OutOfService: mut == "", // Out of service if empty MUT } } return accounts, nil } -// GetAllBearerTokens returns all configured bearer tokens (for monitoring purposes) -func (c *Config) GetAllBearerTokens() []string { - accounts, err := c.GetTTMLAccounts() - if err != nil || len(accounts) == 0 { - return nil - } - - tokens := make([]string, len(accounts)) - for i, acc := range accounts { - tokens[i] = acc.BearerToken - } - return tokens -} - // SplitAndTrim splits a comma-separated string and trims whitespace from each element func SplitAndTrim(s string) []string { if s == "" { diff --git a/config/config_test.go b/config/config_test.go index 8b224f2..7a56bce 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -16,6 +16,7 @@ func TestConfigDefaultValues(t *testing.T) { "LYRICS_CACHE_TTL_IN_SECONDS", "FF_CACHE_COMPRESSION", "FF_CACHE_ONLY_MODE", + "FF_PRETTY_LOGS", "TTML_STOREFRONT", } @@ -78,7 +79,7 @@ func TestConfigDefaultValues(t *testing.T) { { name: "TTMLStorefront default", got: cfg.Configuration.TTMLStorefront, - expected: "us", + expected: "in", }, { name: "CacheCompression default", @@ -90,6 +91,11 @@ func TestConfigDefaultValues(t *testing.T) { got: cfg.FeatureFlags.CacheOnlyMode, expected: false, }, + { + name: "PrettyLogs default", + got: cfg.FeatureFlags.PrettyLogs, + expected: false, + }, } for _, tt := range tests { @@ -201,15 +207,16 @@ func TestConfigEnvironmentOverrides(t *testing.T) { func TestConfigTTMLSettings(t *testing.T) { // Set TTML-specific environment variables - os.Setenv("TTML_BEARER_TOKEN", "test_bearer_token") + // Note: Bearer tokens are now auto-scraped, only MUT is configured per account os.Setenv("TTML_MEDIA_USER_TOKEN", "test_media_user_token") + os.Setenv("TTML_TOKEN_SOURCE_URL", "https://music.example.com") os.Setenv("TTML_BASE_URL", "https://api.example.com") os.Setenv("TTML_SEARCH_PATH", "/search") os.Setenv("TTML_LYRICS_PATH", "/lyrics") defer func() { - os.Unsetenv("TTML_BEARER_TOKEN") os.Unsetenv("TTML_MEDIA_USER_TOKEN") + os.Unsetenv("TTML_TOKEN_SOURCE_URL") os.Unsetenv("TTML_BASE_URL") os.Unsetenv("TTML_SEARCH_PATH") os.Unsetenv("TTML_LYRICS_PATH") @@ -220,12 +227,12 @@ func TestConfigTTMLSettings(t *testing.T) { t.Fatalf("Failed to load config: %v", err) } - if cfg.Configuration.TTMLBearerToken != "test_bearer_token" { - t.Errorf("Expected TTMLBearerToken 'test_bearer_token', got %q", cfg.Configuration.TTMLBearerToken) - } if cfg.Configuration.TTMLMediaUserToken != "test_media_user_token" { t.Errorf("Expected TTMLMediaUserToken 'test_media_user_token', got %q", cfg.Configuration.TTMLMediaUserToken) } + if cfg.Configuration.TTMLTokenSourceURL != "https://music.example.com" { + t.Errorf("Expected TTMLTokenSourceURL 'https://music.example.com', got %q", cfg.Configuration.TTMLTokenSourceURL) + } if cfg.Configuration.TTMLBaseURL != "https://api.example.com" { t.Errorf("Expected TTMLBaseURL 'https://api.example.com', got %q", cfg.Configuration.TTMLBaseURL) } @@ -368,6 +375,66 @@ func TestFeatureFlagCacheOnlyModeDefault(t *testing.T) { } } +func TestFeatureFlagPrettyLogs(t *testing.T) { + tests := []struct { + name string + envValue string + expected bool + }{ + { + name: "Pretty logs enabled (true)", + envValue: "true", + expected: true, + }, + { + name: "Pretty logs disabled (false)", + envValue: "false", + expected: false, + }, + { + name: "Pretty logs enabled (1)", + envValue: "1", + expected: true, + }, + { + name: "Pretty logs disabled (0)", + envValue: "0", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("FF_PRETTY_LOGS", tt.envValue) + defer os.Unsetenv("FF_PRETTY_LOGS") + + cfg, err := load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.FeatureFlags.PrettyLogs != tt.expected { + t.Errorf("Expected PrettyLogs %v, got %v", tt.expected, cfg.FeatureFlags.PrettyLogs) + } + }) + } +} + +func TestFeatureFlagPrettyLogsDefault(t *testing.T) { + // Ensure the env var is not set + os.Unsetenv("FF_PRETTY_LOGS") + + cfg, err := load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Default should be false (JSON format) + if cfg.FeatureFlags.PrettyLogs != false { + t.Errorf("Expected PrettyLogs default to be false, got %v", cfg.FeatureFlags.PrettyLogs) + } +} + func TestConfigStringFields(t *testing.T) { // Test that string fields handle empty values correctly os.Setenv("CACHE_ACCESS_TOKEN", "") @@ -385,18 +452,16 @@ func TestConfigStringFields(t *testing.T) { if cfg.Configuration.CacheAccessToken != "" { t.Errorf("Expected empty CacheAccessToken, got %q", cfg.Configuration.CacheAccessToken) } - if cfg.Configuration.TTMLBearerToken != "" { - t.Errorf("Expected empty TTMLBearerToken, got %q", cfg.Configuration.TTMLBearerToken) + if cfg.Configuration.TTMLMediaUserToken != "" { + t.Errorf("Expected empty TTMLMediaUserToken, got %q", cfg.Configuration.TTMLMediaUserToken) } } -func TestGetTTMLAccounts_FiltersEmptyCredentials(t *testing.T) { - // Set multi-account tokens with some empty values - // Account 1: valid, Account 2: empty MUT, Account 3: valid - os.Setenv("TTML_BEARER_TOKENS", "bearer1,bearer2,bearer3") +func TestGetTTMLAccounts_FiltersEmptyMUT(t *testing.T) { + // Set multi-account MUTs with some empty values + // Account 1: valid MUT, Account 2: empty MUT, Account 3: valid MUT os.Setenv("TTML_MEDIA_USER_TOKENS", "mut1,,mut3") // Account 2 has empty MUT defer func() { - os.Unsetenv("TTML_BEARER_TOKENS") os.Unsetenv("TTML_MEDIA_USER_TOKENS") }() @@ -427,37 +492,10 @@ func TestGetTTMLAccounts_FiltersEmptyCredentials(t *testing.T) { } } -func TestGetTTMLAccounts_FiltersEmptyBearerToken(t *testing.T) { - // Test with empty bearer token - os.Setenv("TTML_BEARER_TOKENS", "bearer1,,bearer3") - os.Setenv("TTML_MEDIA_USER_TOKENS", "mut1,mut2,mut3") - defer func() { - os.Unsetenv("TTML_BEARER_TOKENS") - os.Unsetenv("TTML_MEDIA_USER_TOKENS") - }() - - cfg, err := load() - if err != nil { - t.Fatalf("Failed to load config: %v", err) - } - - accounts, err := cfg.GetTTMLAccounts() - if err != nil { - t.Fatalf("GetTTMLAccounts failed: %v", err) - } - - // Should only return 2 accounts - if len(accounts) != 2 { - t.Errorf("Expected 2 active accounts (filtering empty bearer), got %d", len(accounts)) - } -} - func TestGetAllTTMLAccounts_IncludesOutOfService(t *testing.T) { - // Set multi-account tokens with some empty values - os.Setenv("TTML_BEARER_TOKENS", "bearer1,bearer2,bearer3") + // Set multi-account MUTs with some empty values os.Setenv("TTML_MEDIA_USER_TOKENS", "mut1,,mut3") // Account 2 has empty MUT defer func() { - os.Unsetenv("TTML_BEARER_TOKENS") os.Unsetenv("TTML_MEDIA_USER_TOKENS") }() @@ -487,11 +525,9 @@ func TestGetAllTTMLAccounts_IncludesOutOfService(t *testing.T) { } func TestGetAllTTMLAccounts_AllValid(t *testing.T) { - // All accounts have valid credentials - os.Setenv("TTML_BEARER_TOKENS", "bearer1,bearer2,bearer3") + // All accounts have valid MUTs os.Setenv("TTML_MEDIA_USER_TOKENS", "mut1,mut2,mut3") defer func() { - os.Unsetenv("TTML_BEARER_TOKENS") os.Unsetenv("TTML_MEDIA_USER_TOKENS") }() @@ -519,19 +555,16 @@ func TestGetAllTTMLAccounts_AllValid(t *testing.T) { // No accounts should be out of service for _, acc := range allAccounts { if acc.OutOfService { - t.Errorf("Account %s should not be OutOfService when credentials are valid", acc.Name) + t.Errorf("Account %s should not be OutOfService when MUT is valid", acc.Name) } } } func TestGetTTMLAccounts_SingleAccountEmptyMUT(t *testing.T) { // Test single account mode with empty MUT - os.Unsetenv("TTML_BEARER_TOKENS") os.Unsetenv("TTML_MEDIA_USER_TOKENS") - os.Setenv("TTML_BEARER_TOKEN", "single_bearer") os.Setenv("TTML_MEDIA_USER_TOKEN", "") // Empty MUT defer func() { - os.Unsetenv("TTML_BEARER_TOKEN") os.Unsetenv("TTML_MEDIA_USER_TOKEN") }() @@ -551,42 +584,10 @@ func TestGetTTMLAccounts_SingleAccountEmptyMUT(t *testing.T) { } } -func TestGetAllTTMLAccounts_SingleAccountEmptyMUT(t *testing.T) { - // Test single account mode with empty MUT - os.Unsetenv("TTML_BEARER_TOKENS") - os.Unsetenv("TTML_MEDIA_USER_TOKENS") - os.Setenv("TTML_BEARER_TOKEN", "single_bearer") - os.Setenv("TTML_MEDIA_USER_TOKEN", "") // Empty MUT - defer func() { - os.Unsetenv("TTML_BEARER_TOKEN") - os.Unsetenv("TTML_MEDIA_USER_TOKEN") - }() - - cfg, err := load() - if err != nil { - t.Fatalf("Failed to load config: %v", err) - } - - allAccounts, err := cfg.GetAllTTMLAccounts() - if err != nil { - t.Fatalf("GetAllTTMLAccounts failed: %v", err) - } - - // Should return 1 account (but marked as out of service) - if len(allAccounts) != 1 { - t.Errorf("Expected 1 total account, got %d", len(allAccounts)) - } - - if !allAccounts[0].OutOfService { - t.Error("Account with empty MUT should be marked as OutOfService") - } -} - func TestTTMLAccount_OutOfServiceField(t *testing.T) { // Test that OutOfService field is properly set acc := TTMLAccount{ Name: "TestAccount", - BearerToken: "bearer", MediaUserToken: "mut", OutOfService: false, } diff --git a/handlers.go b/handlers.go index d9ab9d4..acbc69e 100644 --- a/handlers.go +++ b/handlers.go @@ -572,8 +572,8 @@ func clearProviderCache(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{ - "error": fmt.Sprintf("Unknown provider: %s", providerName), - "valid_providers": []string{"ttml", "kugou", "legacy"}, + "error": fmt.Sprintf("Unknown provider: %s", providerName), + "valid_providers": []string{"ttml", "kugou", "legacy"}, }) return } @@ -694,12 +694,12 @@ func getHealthStatus(w http.ResponseWriter, r *http.Request) { // Basic health response (always available) // accounts field UNCHANGED for backward compatibility - shows total configured health := map[string]interface{}{ - "status": "ok", - "accounts": totalAccountCount, // UNCHANGED: total configured - "accounts_active": activeAccountCount, // NEW: working accounts - "accounts_out_of_service": outOfServiceCount, // NEW: accounts with empty credentials - "circuit_breaker": cbState, - "cache_ready": persistentCache.IsPreloadComplete(), + "status": "ok", + "accounts": totalAccountCount, // UNCHANGED: total configured + "accounts_active": activeAccountCount, // NEW: working accounts + "accounts_out_of_service": outOfServiceCount, // NEW: accounts with empty credentials + "circuit_breaker": cbState, + "cache_ready": persistentCache.IsPreloadComplete(), } // If circuit breaker is open, mark as degraded @@ -722,40 +722,56 @@ func getHealthStatus(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") == conf.Configuration.CacheAccessToken && conf.Configuration.CacheAccessToken != "" { var tokenStatuses []map[string]interface{} overallHealthy := true - warningThreshold := 7 - // Include ALL accounts (same as before, but with out_of_service handling) + // Include shared bearer token status + bearerExpiry, bearerRemaining, bearerNeedsRefresh := ttml.GetTokenStatus() + bearerStatus := map[string]interface{}{ + "name": "shared_bearer_token", + "type": "bearer", + } + if bearerExpiry.IsZero() { + bearerStatus["status"] = "not_initialized" + } else { + bearerStatus["expires"] = bearerExpiry.Format("2006-01-02 15:04:05") + bearerStatus["remaining_minutes"] = int(bearerRemaining.Minutes()) + if bearerNeedsRefresh { + bearerStatus["status"] = "refreshing_soon" + } else { + bearerStatus["status"] = "healthy" + } + } + tokenStatuses = append(tokenStatuses, bearerStatus) + + // Include ALL MUT accounts - use health check status (MUTs are not JWTs) + healthStatuses := ttml.GetHealthStatuses() for _, acc := range allAccounts { tokenStatus := map[string]interface{}{ "name": acc.Name, + "type": "mut", } // Handle out-of-service accounts if acc.OutOfService { tokenStatus["status"] = "out_of_service" - tokenStatus["reason"] = "empty credentials" + tokenStatus["reason"] = "empty MUT" tokenStatuses = append(tokenStatuses, tokenStatus) continue } - expirationDate, err := notifier.GetExpirationDate(acc.BearerToken) - if err != nil { - tokenStatus["status"] = "error" - tokenStatus["error"] = err.Error() - overallHealthy = false - } else { - daysRemaining := int(time.Until(expirationDate).Hours() / 24) - tokenStatus["expires"] = expirationDate.Format("2006-01-02 15:04:05") - tokenStatus["days_remaining"] = daysRemaining - - if daysRemaining <= 0 { - tokenStatus["status"] = "expired" - overallHealthy = false - } else if daysRemaining <= warningThreshold { - tokenStatus["status"] = "expiring_soon" - } else { + // Get health status from canary check instead of JWT parsing + // MUTs are opaque Apple credentials, not JWTs - cannot parse expiry + if status, ok := healthStatuses[acc.Name]; ok { + tokenStatus["last_checked"] = status.LastChecked.Format(time.RFC3339) + if status.Healthy { tokenStatus["status"] = "healthy" + } else { + tokenStatus["status"] = "unhealthy" + tokenStatus["last_error"] = status.LastError + overallHealthy = false } + } else { + tokenStatus["status"] = "unknown" + tokenStatus["note"] = "health check not yet run" } tokenStatuses = append(tokenStatuses, tokenStatus) @@ -773,6 +789,44 @@ func getHealthStatus(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(health) } +// handleMUTHealth handles the /health/mut endpoint for MUT health status +func handleMUTHealth(w http.ResponseWriter, r *http.Request) { + // Requires auth token + if r.Header.Get("Authorization") != conf.Configuration.CacheAccessToken || conf.Configuration.CacheAccessToken == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Option to force recheck + if r.URL.Query().Get("refresh") == "true" { + results := ttml.CheckAllMUTHealth() + response := make(map[string]interface{}) + for _, status := range results { + response[status.AccountName] = map[string]interface{}{ + "healthy": status.Healthy, + "last_checked": status.LastChecked.Format(time.RFC3339), + "last_error": status.LastError, + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + // Return cached health statuses + statuses := ttml.GetHealthStatuses() + response := make(map[string]interface{}) + for name, status := range statuses { + response[name] = map[string]interface{}{ + "healthy": status.Healthy, + "last_checked": status.LastChecked.Format(time.RFC3339), + "last_error": status.LastError, + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + func getCircuitBreakerStatus(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != conf.Configuration.CacheAccessToken { http.Error(w, "Unauthorized", http.StatusUnauthorized) @@ -868,32 +922,42 @@ func testNotifications(w http.ResponseWriter, r *http.Request) { var accountInfos []map[string]interface{} var infoLines []string + // Get health statuses from canary checks (MUTs are not JWTs) + healthStatuses := ttml.GetHealthStatuses() + for _, acc := range allAccounts { if acc.OutOfService { - infoLines = append(infoLines, fmt.Sprintf("%s: Out of service (empty credentials)", acc.Name)) + infoLines = append(infoLines, fmt.Sprintf("%s: Out of service (empty MUT)", acc.Name)) accountInfos = append(accountInfos, map[string]interface{}{ "name": acc.Name, "status": "out_of_service", - "reason": "empty credentials", + "reason": "empty MUT", }) continue } - expirationDate, err := notifier.GetExpirationDate(acc.BearerToken) - if err != nil { - infoLines = append(infoLines, fmt.Sprintf("%s: Error - %v", acc.Name, err)) + // Use health check status instead of JWT parsing + // MUTs are opaque Apple credentials, not JWTs + if status, ok := healthStatuses[acc.Name]; ok { + statusStr := "healthy" + if !status.Healthy { + statusStr = fmt.Sprintf("unhealthy (%s)", status.LastError) + } + infoLines = append(infoLines, fmt.Sprintf("%s (MUT): %s (checked %s)", + acc.Name, statusStr, status.LastChecked.Format("2006-01-02 15:04"))) accountInfos = append(accountInfos, map[string]interface{}{ - "name": acc.Name, - "error": err.Error(), + "name": acc.Name, + "status": statusStr, + "healthy": status.Healthy, + "last_checked": status.LastChecked.Format(time.RFC3339), + "last_error": status.LastError, }) } else { - daysUntilExpiration := int(time.Until(expirationDate).Hours() / 24) - infoLines = append(infoLines, fmt.Sprintf("%s: %d days remaining (expires %s)", - acc.Name, daysUntilExpiration, expirationDate.Format("2006-01-02"))) + infoLines = append(infoLines, fmt.Sprintf("%s (MUT): health check not yet run", acc.Name)) accountInfos = append(accountInfos, map[string]interface{}{ - "name": acc.Name, - "token_expires": expirationDate.Format("2006-01-02 15:04:05"), - "days_until_expiration": daysUntilExpiration, + "name": acc.Name, + "status": "unknown", + "note": "health check not yet run", }) } } @@ -903,8 +967,7 @@ func testNotifications(w http.ResponseWriter, r *http.Request) { "Current date: %s\n"+ "Accounts configured: %d (active: %d, out of service: %d)\n\n"+ "Account Status:\n %s\n\n"+ - "Warning threshold: 7 days before expiration\n"+ - "Reminder frequency: Daily until updated", + "Note: MUT validity is checked via canary requests, not JWT expiry", now.Format("2006-01-02 15:04:05"), len(allAccounts), len(activeAccounts), @@ -913,11 +976,11 @@ func testNotifications(w http.ResponseWriter, r *http.Request) { ) tokenDetails = map[string]interface{}{ - "current_date": now.Format("2006-01-02 15:04:05"), - "accounts_configured": len(allAccounts), - "accounts_active": len(activeAccounts), - "accounts_out_of_service": outOfServiceCount, - "accounts": accountInfos, + "current_date": now.Format("2006-01-02 15:04:05"), + "accounts_configured": len(allAccounts), + "accounts_active": len(activeAccounts), + "accounts_out_of_service": outOfServiceCount, + "accounts": accountInfos, } } diff --git a/logcolors/colors.go b/logcolors/colors.go index f919606..e17a320 100644 --- a/logcolors/colors.go +++ b/logcolors/colors.go @@ -32,11 +32,10 @@ const ( LogRevalidate = Cyan + "[Revalidate]" + Reset ) -// Rate limiting and monitoring log prefixes +// Rate limiting log prefixes const ( - LogRateLimit = Purple + "[RateLimit]" + Reset - LogTokenMonitor = Cyan + "[Token Monitor]" + Reset - LogAPIKey = Purple + "[APIKey]" + Reset + LogRateLimit = Purple + "[RateLimit]" + Reset + LogAPIKey = Purple + "[APIKey]" + Reset ) // CircuitBreakerPrefix returns a colored circuit breaker prefix with the given name @@ -98,3 +97,10 @@ const ( LogTTMLParser = Cyan + "[TTML Parser]" + Reset LogWarning = Red + "[Warning]" + Reset ) + +// Token and health check log prefixes +const ( + LogBearerToken = Cyan + "[Bearer Token]" + Reset + LogHealthCheck = Cyan + "[Health Check]" + Reset + LogAccountInit = Cyan + "[Account Init]" + Reset +) diff --git a/main.go b/main.go index 8262497..4eedf15 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "lyrics-api-go/logcolors" "lyrics-api-go/middleware" "lyrics-api-go/services/notifier" + "lyrics-api-go/services/providers/ttml" "lyrics-api-go/stats" "net/http" "os" @@ -29,14 +30,26 @@ var ( ) func init() { - log.SetFormatter(&log.JSONFormatter{}) - log.SetOutput(os.Stdout) - log.SetLevel(log.InfoLevel) - + // Load .env first so config is available for logger setup err := godotenv.Load() if err != nil { - log.Warnf("%s Error loading .env file, using environment variables", logcolors.LogConfig) + // Can't use colored log prefix here since formatter isn't set yet + log.Warn("Error loading .env file, using environment variables") } + + // Configure logger based on feature flag + cfg := config.Get() + if cfg.FeatureFlags.PrettyLogs { + log.SetFormatter(&log.TextFormatter{ + ForceColors: true, + FullTimestamp: true, + TimestampFormat: "15:04:05", + }) + } else { + log.SetFormatter(&log.JSONFormatter{}) + } + log.SetOutput(os.Stdout) + log.SetLevel(log.InfoLevel) } func main() { @@ -79,7 +92,11 @@ func main() { log.Infof("%s Alert handler initialized with %d notifier(s)", logcolors.LogNotifier, len(alertNotifiers)) } - go startTokenMonitor() + // Start bearer token auto-scraper (proactive refresh based on JWT expiry) + ttml.StartBearerTokenMonitor() + + // Start MUT health check scheduler (daily canary checks) + ttml.StartHealthCheckScheduler() router := mux.NewRouter() setupRoutes(router) @@ -90,7 +107,7 @@ func main() { } c := cors.New(cors.Options{ - AllowedOrigins: []string{"https://music.youtube.com", "http://localhost:3000","http://localhost:4321","https://lyrics-api-docs.boidu.dev"}, + AllowedOrigins: []string{"https://music.youtube.com", "http://localhost:3000", "http://localhost:4321", "https://lyrics-api-docs.boidu.dev"}, AllowCredentials: true, }) diff --git a/routes.go b/routes.go index 9ef9967..1229688 100644 --- a/routes.go +++ b/routes.go @@ -33,6 +33,7 @@ func setupRoutes(router *mux.Router) { // Health and stats endpoints router.HandleFunc("/health", getHealthStatus) + router.HandleFunc("/health/mut", handleMUTHealth) router.HandleFunc("/stats", getStats) // Circuit breaker endpoints diff --git a/services/notifier/alerts.go b/services/notifier/alerts.go index e9539b5..2ae3cac 100644 --- a/services/notifier/alerts.go +++ b/services/notifier/alerts.go @@ -17,10 +17,10 @@ const ( // AlertHandler handles events and sends notifications type AlertHandler struct { - notifiers []Notifier - cooldowns map[EventType]time.Time // last alert time per event type + notifiers []Notifier + cooldowns map[EventType]time.Time // last alert time per event type cooldownDuration time.Duration - mu sync.RWMutex + mu sync.RWMutex } // AlertConfig holds configuration for the alert handler @@ -124,6 +124,16 @@ func (h *AlertHandler) formatAlert(event *Event) (subject, message string) { "Action: Check and refresh the TTML bearer token for this account.", account, statusCode) + case EventMUTHealthCheckFailed: + subject = "MUT Health Check Failed" + message = "MUT health check detected unhealthy accounts:\n\n" + if accounts, ok := event.Data["unhealthy_accounts"].([]map[string]string); ok { + for _, acc := range accounts { + message += fmt.Sprintf(" • %s: %s\n", acc["name"], acc["error"]) + } + } + message += "\nAction: Check and refresh the Media User Token for these accounts." + case EventServerStartupFailed: component := event.Data["component"].(string) errMsg := event.Data["error"].(string) diff --git a/services/notifier/events.go b/services/notifier/events.go index dd95355..0dcfe58 100644 --- a/services/notifier/events.go +++ b/services/notifier/events.go @@ -14,12 +14,13 @@ const ( EventAllAccountsQuarantine EventType = "all_accounts_quarantined" EventAccountAuthFailure EventType = "account_auth_failure" EventServerStartupFailed EventType = "server_startup_failed" + EventMUTHealthCheckFailed EventType = "mut_health_check_failed" // Warning events - EventHighFailureRate EventType = "high_failure_rate" - EventHalfAccountsQuarantine EventType = "half_accounts_quarantined" - EventOneAwayFromQuarantine EventType = "one_away_from_quarantine" - EventCacheBackupFailed EventType = "cache_backup_failed" + EventHighFailureRate EventType = "high_failure_rate" + EventHalfAccountsQuarantine EventType = "half_accounts_quarantined" + EventOneAwayFromQuarantine EventType = "one_away_from_quarantine" + EventCacheBackupFailed EventType = "cache_backup_failed" // Info events EventCircuitBreakerRecovered EventType = "circuit_breaker_recovered" @@ -67,9 +68,9 @@ type EventHandler func(event *Event) // EventBus manages event publishing and subscription type EventBus struct { - handlers map[EventType][]EventHandler + handlers map[EventType][]EventHandler allHandlers []EventHandler // handlers that receive all events - mu sync.RWMutex + mu sync.RWMutex } // Global event bus instance @@ -222,3 +223,11 @@ func PublishServerStartupFailed(component string, err error) { WithData("error", err.Error()) GetEventBus().Publish(event) } + +// PublishMUTHealthCheckFailed publishes when MUT health check detects unhealthy accounts +func PublishMUTHealthCheckFailed(unhealthyAccounts interface{}) { + event := NewEvent(EventMUTHealthCheckFailed, SeverityCritical, + "MUT health check detected unhealthy accounts"). + WithData("unhealthy_accounts", unhealthyAccounts) + GetEventBus().Publish(event) +} diff --git a/services/notifier/jwt.go b/services/notifier/jwt.go deleted file mode 100644 index 29da583..0000000 --- a/services/notifier/jwt.go +++ /dev/null @@ -1,71 +0,0 @@ -package notifier - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "strings" - "time" -) - -// JWTClaims represents the decoded JWT claims -type JWTClaims struct { - Issuer string `json:"iss"` - IssuedAt int64 `json:"iat"` - ExpiresAt int64 `json:"exp"` - RootHTTPOrigin []string `json:"root_https_origin"` -} - -// DecodeJWT decodes a JWT token without verification (since we just need to read expiration) -func DecodeJWT(token string) (*JWTClaims, error) { - parts := strings.Split(token, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid JWT format") - } - - // Decode the payload (second part) - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, fmt.Errorf("failed to decode JWT payload: %v", err) - } - - var claims JWTClaims - if err := json.Unmarshal(payload, &claims); err != nil { - return nil, fmt.Errorf("failed to unmarshal JWT claims: %v", err) - } - - return &claims, nil -} - -// GetExpirationDate returns the expiration date of the JWT -func GetExpirationDate(token string) (time.Time, error) { - claims, err := DecodeJWT(token) - if err != nil { - return time.Time{}, err - } - - return time.Unix(claims.ExpiresAt, 0), nil -} - -// DaysUntilExpiration returns how many days until the token expires -func DaysUntilExpiration(token string) (int, error) { - expiration, err := GetExpirationDate(token) - if err != nil { - return 0, err - } - - duration := time.Until(expiration) - days := int(duration.Hours() / 24) - - return days, nil -} - -// IsExpiringSoon checks if token expires within the given number of days -func IsExpiringSoon(token string, daysThreshold int) (bool, int, error) { - days, err := DaysUntilExpiration(token) - if err != nil { - return false, 0, err - } - - return days <= daysThreshold && days >= 0, days, nil -} diff --git a/services/notifier/jwt_test.go b/services/notifier/jwt_test.go deleted file mode 100644 index 325a73f..0000000 --- a/services/notifier/jwt_test.go +++ /dev/null @@ -1,356 +0,0 @@ -package notifier - -import ( - "encoding/base64" - "encoding/json" - "testing" - "time" -) - -// Helper function to create a test JWT token -func createTestJWT(claims JWTClaims) string { - // Create header - header := map[string]string{ - "alg": "HS256", - "typ": "JWT", - } - headerJSON, _ := json.Marshal(header) - headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) - - // Create payload - payloadJSON, _ := json.Marshal(claims) - payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) - - // Create a dummy signature - signature := base64.RawURLEncoding.EncodeToString([]byte("dummy_signature")) - - return headerB64 + "." + payloadB64 + "." + signature -} - -func TestDecodeJWT(t *testing.T) { - now := time.Now() - futureTime := now.Add(7 * 24 * time.Hour) - - claims := JWTClaims{ - Issuer: "test-issuer", - IssuedAt: now.Unix(), - ExpiresAt: futureTime.Unix(), - RootHTTPOrigin: []string{"https://example.com"}, - } - - token := createTestJWT(claims) - - decoded, err := DecodeJWT(token) - if err != nil { - t.Fatalf("Failed to decode JWT: %v", err) - } - - if decoded.Issuer != claims.Issuer { - t.Errorf("Expected issuer %q, got %q", claims.Issuer, decoded.Issuer) - } - if decoded.IssuedAt != claims.IssuedAt { - t.Errorf("Expected issued at %d, got %d", claims.IssuedAt, decoded.IssuedAt) - } - if decoded.ExpiresAt != claims.ExpiresAt { - t.Errorf("Expected expires at %d, got %d", claims.ExpiresAt, decoded.ExpiresAt) - } - if len(decoded.RootHTTPOrigin) != len(claims.RootHTTPOrigin) { - t.Errorf("Expected %d origins, got %d", len(claims.RootHTTPOrigin), len(decoded.RootHTTPOrigin)) - } -} - -func TestDecodeJWT_InvalidFormat(t *testing.T) { - tests := []struct { - name string - token string - }{ - { - name: "Empty token", - token: "", - }, - { - name: "Single part", - token: "invalid", - }, - { - name: "Two parts", - token: "part1.part2", - }, - { - name: "Four parts", - token: "part1.part2.part3.part4", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := DecodeJWT(tt.token) - if err == nil { - t.Error("Expected error for invalid JWT format, got nil") - } - }) - } -} - -func TestDecodeJWT_InvalidBase64(t *testing.T) { - // Create a JWT with invalid base64 in the payload - token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid_base64!!!.signature" - - _, err := DecodeJWT(token) - if err == nil { - t.Error("Expected error for invalid base64, got nil") - } -} - -func TestDecodeJWT_InvalidJSON(t *testing.T) { - // Create a JWT with invalid JSON in the payload - invalidJSON := base64.RawURLEncoding.EncodeToString([]byte("{invalid json")) - token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + invalidJSON + ".signature" - - _, err := DecodeJWT(token) - if err == nil { - t.Error("Expected error for invalid JSON, got nil") - } -} - -func TestGetExpirationDate(t *testing.T) { - futureTime := time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC) - - claims := JWTClaims{ - Issuer: "test", - IssuedAt: time.Now().Unix(), - ExpiresAt: futureTime.Unix(), - } - - token := createTestJWT(claims) - - expiration, err := GetExpirationDate(token) - if err != nil { - t.Fatalf("Failed to get expiration date: %v", err) - } - - if expiration.Unix() != futureTime.Unix() { - t.Errorf("Expected expiration %v, got %v", futureTime, expiration) - } -} - -func TestGetExpirationDate_InvalidToken(t *testing.T) { - _, err := GetExpirationDate("invalid.token") - if err == nil { - t.Error("Expected error for invalid token, got nil") - } -} - -func TestDaysUntilExpiration(t *testing.T) { - tests := []struct { - name string - daysInFuture int - expectedDays int - }{ - { - name: "Expires in 7 days", - daysInFuture: 7, - expectedDays: 7, - }, - { - name: "Expires in 30 days", - daysInFuture: 30, - expectedDays: 30, - }, - { - name: "Expires in 1 day", - daysInFuture: 1, - expectedDays: 1, - }, - { - name: "Expires today (within 24 hours)", - daysInFuture: 0, - expectedDays: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - futureTime := time.Now().Add(time.Duration(tt.daysInFuture) * 24 * time.Hour) - - claims := JWTClaims{ - Issuer: "test", - IssuedAt: time.Now().Unix(), - ExpiresAt: futureTime.Unix(), - } - - token := createTestJWT(claims) - - days, err := DaysUntilExpiration(token) - if err != nil { - t.Fatalf("Failed to get days until expiration: %v", err) - } - - // Allow for a small margin of error (±1 day) due to timing - if days < tt.expectedDays-1 || days > tt.expectedDays+1 { - t.Errorf("Expected approximately %d days, got %d", tt.expectedDays, days) - } - }) - } -} - -func TestDaysUntilExpiration_ExpiredToken(t *testing.T) { - // Create a token that expired 5 days ago - pastTime := time.Now().Add(-5 * 24 * time.Hour) - - claims := JWTClaims{ - Issuer: "test", - IssuedAt: time.Now().Add(-10 * 24 * time.Hour).Unix(), - ExpiresAt: pastTime.Unix(), - } - - token := createTestJWT(claims) - - days, err := DaysUntilExpiration(token) - if err != nil { - t.Fatalf("Failed to get days until expiration: %v", err) - } - - // Should return a negative number for expired tokens - if days >= 0 { - t.Errorf("Expected negative days for expired token, got %d", days) - } -} - -func TestIsExpiringSoon(t *testing.T) { - tests := []struct { - name string - daysInFuture int - threshold int - expectingSoon bool - }{ - { - name: "Expires in 3 days, threshold 7 days", - daysInFuture: 3, - threshold: 7, - expectingSoon: true, - }, - { - name: "Expires in 10 days, threshold 7 days", - daysInFuture: 10, - threshold: 7, - expectingSoon: false, - }, - { - name: "Expires in 7 days, threshold 7 days", - daysInFuture: 7, - threshold: 7, - expectingSoon: true, - }, - { - name: "Expires in 1 day, threshold 30 days", - daysInFuture: 1, - threshold: 30, - expectingSoon: true, - }, - { - name: "Expires today, threshold 1 day", - daysInFuture: 0, - threshold: 1, - expectingSoon: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - futureTime := time.Now().Add(time.Duration(tt.daysInFuture) * 24 * time.Hour) - - claims := JWTClaims{ - Issuer: "test", - IssuedAt: time.Now().Unix(), - ExpiresAt: futureTime.Unix(), - } - - token := createTestJWT(claims) - - expiringSoon, days, err := IsExpiringSoon(token, tt.threshold) - if err != nil { - t.Fatalf("Failed to check if expiring soon: %v", err) - } - - if expiringSoon != tt.expectingSoon { - t.Errorf("Expected expiring soon to be %v, got %v (days: %d)", tt.expectingSoon, expiringSoon, days) - } - - // Verify the days returned is approximately correct - if days < tt.daysInFuture-1 || days > tt.daysInFuture+1 { - t.Errorf("Expected approximately %d days, got %d", tt.daysInFuture, days) - } - }) - } -} - -func TestIsExpiringSoon_ExpiredToken(t *testing.T) { - // Create an expired token - pastTime := time.Now().Add(-5 * 24 * time.Hour) - - claims := JWTClaims{ - Issuer: "test", - IssuedAt: time.Now().Add(-10 * 24 * time.Hour).Unix(), - ExpiresAt: pastTime.Unix(), - } - - token := createTestJWT(claims) - - expiringSoon, days, err := IsExpiringSoon(token, 7) - if err != nil { - t.Fatalf("Failed to check if expiring soon: %v", err) - } - - // Expired tokens should not be "expiring soon" (they're already expired) - if expiringSoon { - t.Errorf("Expected expired token to not be 'expiring soon', but it was (days: %d)", days) - } - - if days >= 0 { - t.Errorf("Expected negative days for expired token, got %d", days) - } -} - -func TestIsExpiringSoon_InvalidToken(t *testing.T) { - _, _, err := IsExpiringSoon("invalid.token.format", 7) - if err == nil { - t.Error("Expected error for invalid token, got nil") - } -} - -func TestJWTClaims_MultipleOrigins(t *testing.T) { - claims := JWTClaims{ - Issuer: "test", - IssuedAt: time.Now().Unix(), - ExpiresAt: time.Now().Add(7 * 24 * time.Hour).Unix(), - RootHTTPOrigin: []string{ - "https://example.com", - "https://api.example.com", - "https://cdn.example.com", - }, - } - - token := createTestJWT(claims) - - decoded, err := DecodeJWT(token) - if err != nil { - t.Fatalf("Failed to decode JWT: %v", err) - } - - if len(decoded.RootHTTPOrigin) != 3 { - t.Errorf("Expected 3 origins, got %d", len(decoded.RootHTTPOrigin)) - } - - expectedOrigins := map[string]bool{ - "https://example.com": true, - "https://api.example.com": true, - "https://cdn.example.com": true, - } - - for _, origin := range decoded.RootHTTPOrigin { - if !expectedOrigins[origin] { - t.Errorf("Unexpected origin: %s", origin) - } - } -} diff --git a/services/notifier/monitor.go b/services/notifier/monitor.go deleted file mode 100644 index ecb51b9..0000000 --- a/services/notifier/monitor.go +++ /dev/null @@ -1,270 +0,0 @@ -package notifier - -import ( - "encoding/json" - "fmt" - "lyrics-api-go/logcolors" - "os" - "time" - - log "github.com/sirupsen/logrus" -) - -// TokenInfo holds information about a single token for monitoring -type TokenInfo struct { - Name string // Account name (e.g., "Account-1") - BearerToken string -} - -// MonitorConfig holds the configuration for the token monitor -type MonitorConfig struct { - Tokens []TokenInfo // Multiple tokens to monitor - WarningThreshold int // Days before expiration to start warning - ReminderInterval int // Hours between reminders (to avoid spam) - StateFile string - Notifiers []Notifier -} - -// MonitorState tracks when we last sent notifications -type MonitorState struct { - LastNotificationSent time.Time `json:"last_notification_sent"` - LastDaysRemaining int `json:"last_days_remaining"` -} - -// TokenMonitor monitors token expiration and sends notifications -type TokenMonitor struct { - config MonitorConfig - state MonitorState -} - -// NewTokenMonitor creates a new token monitor -func NewTokenMonitor(config MonitorConfig) *TokenMonitor { - monitor := &TokenMonitor{ - config: config, - state: MonitorState{}, - } - - // Load previous state if exists - monitor.loadState() - - return monitor -} - -// loadState loads the last notification state from disk -func (m *TokenMonitor) loadState() { - if m.config.StateFile == "" { - return - } - - data, err := os.ReadFile(m.config.StateFile) - if err != nil { - // File doesn't exist yet, that's okay - return - } - - if err := json.Unmarshal(data, &m.state); err != nil { - log.Warnf("%s Failed to load state file: %v", logcolors.LogTokenMonitor, err) - } -} - -// saveState saves the current notification state to disk -func (m *TokenMonitor) saveState() { - if m.config.StateFile == "" { - return - } - - data, err := json.Marshal(m.state) - if err != nil { - log.Errorf("%s Failed to marshal state: %v", logcolors.LogTokenMonitor, err) - return - } - - if err := os.WriteFile(m.config.StateFile, data, 0644); err != nil { - log.Errorf("%s Failed to write state file: %v", logcolors.LogTokenMonitor, err) - } -} - -// shouldSendNotification determines if we should send a notification based on: -// 1. Days remaining has changed -// 2. Enough time has passed since last notification (to avoid spam) -func (m *TokenMonitor) shouldSendNotification(daysRemaining int) bool { - now := time.Now() - - // If days remaining changed, we should notify - if daysRemaining != m.state.LastDaysRemaining { - return true - } - - // Check if enough time has passed since last notification - hoursSinceLastNotification := now.Sub(m.state.LastNotificationSent).Hours() - if hoursSinceLastNotification >= float64(m.config.ReminderInterval) { - return true - } - - return false -} - -// TokenStatus holds the status of a single token check -type TokenStatus struct { - Name string - ExpiringSoon bool - DaysRemaining int - Error error -} - -// Check performs a single check of all token expirations -func (m *TokenMonitor) Check() error { - if len(m.config.Tokens) == 0 { - return fmt.Errorf("no tokens configured for monitoring") - } - - var expiringTokens []TokenStatus - minDaysRemaining := 999999 - - // Check all tokens - for _, token := range m.config.Tokens { - expiringSoon, daysRemaining, err := IsExpiringSoon(token.BearerToken, m.config.WarningThreshold) - status := TokenStatus{ - Name: token.Name, - ExpiringSoon: expiringSoon, - DaysRemaining: daysRemaining, - Error: err, - } - - if err != nil { - log.Warnf("%s Failed to check %s: %v", logcolors.LogTokenMonitor, token.Name, err) - continue - } - - log.Debugf("%s %s: expiring_soon=%v, days_remaining=%d", logcolors.LogTokenMonitor, token.Name, expiringSoon, daysRemaining) - - if expiringSoon { - expiringTokens = append(expiringTokens, status) - if daysRemaining < minDaysRemaining { - minDaysRemaining = daysRemaining - } - } - } - - // If no tokens are expiring soon, nothing to do - if len(expiringTokens) == 0 { - log.Debugf("%s All tokens are healthy", logcolors.LogTokenMonitor) - return nil - } - - // Check if we should send notification (based on the most urgent token) - if !m.shouldSendNotification(minDaysRemaining) { - log.Debugf("%s Skipping notification (too soon since last notification)", logcolors.LogTokenMonitor) - return nil - } - - // Send notifications for all expiring tokens - if err := m.sendNotifications(expiringTokens); err != nil { - return fmt.Errorf("failed to send notifications: %v", err) - } - - // Update state - m.state.LastNotificationSent = time.Now() - m.state.LastDaysRemaining = minDaysRemaining - m.saveState() - - return nil -} - -// sendNotifications sends notifications through all configured notifiers -func (m *TokenMonitor) sendNotifications(expiringTokens []TokenStatus) error { - var subject, message string - - // Build token details - var tokenDetails string - for _, t := range expiringTokens { - if t.DaysRemaining <= 0 { - tokenDetails += fmt.Sprintf(" • %s: EXPIRED\n", t.Name) - } else if t.DaysRemaining == 1 { - tokenDetails += fmt.Sprintf(" • %s: expires tomorrow\n", t.Name) - } else { - tokenDetails += fmt.Sprintf(" • %s: %d days remaining\n", t.Name, t.DaysRemaining) - } - } - - // Find the most urgent status - minDays := expiringTokens[0].DaysRemaining - for _, t := range expiringTokens { - if t.DaysRemaining < minDays { - minDays = t.DaysRemaining - } - } - - tokenWord := "token" - if len(expiringTokens) > 1 { - tokenWord = "tokens" - } - - if minDays <= 0 { - subject = fmt.Sprintf("🚨 URGENT: TTML %s EXPIRED", tokenWord) - message = fmt.Sprintf("🚨 TTML TOKEN(S) EXPIRED\n\n"+ - "The following %s have EXPIRED:\n\n%s\n"+ - "⚠️ Action Required:\n\n"+ - "The service will stop working until you update the tokens.\n\n"+ - "Update TTML_BEARER_TOKENS in your environment and restart the service immediately.", - tokenWord, tokenDetails) - } else if minDays == 1 { - subject = fmt.Sprintf("⚠️ Alert: TTML %s Expires Tomorrow", tokenWord) - message = fmt.Sprintf("⚠️ TTML TOKEN EXPIRATION WARNING\n\n"+ - "The following %s need attention:\n\n%s\n"+ - "📝 Action Required:\n\n"+ - "Update TTML_BEARER_TOKENS in your environment soon to avoid service interruption.", - tokenWord, tokenDetails) - } else { - subject = fmt.Sprintf("⏰ Notice: TTML %s Expiring Soon", tokenWord) - message = fmt.Sprintf( - "⏰ TTML TOKEN EXPIRATION NOTICE\n\n"+ - "The following %s need attention:\n\n%s\n"+ - "📝 Action Required:\n\n"+ - "Update TTML_BEARER_TOKENS in your environment before expiration to maintain service availability.\n\n"+ - "You will receive daily reminders until the tokens are updated.", - tokenWord, tokenDetails) - } - - log.Infof("%s Sending notifications: %s", logcolors.LogNotifier, subject) - - var lastErr error - successCount := 0 - - for _, notifier := range m.config.Notifiers { - if err := notifier.Send(subject, message); err != nil { - log.Errorf("%s Notifier failed: %v", logcolors.LogNotifier, err) - lastErr = err - } else { - successCount++ - } - } - - if successCount == 0 && lastErr != nil { - return fmt.Errorf("all notifiers failed, last error: %v", lastErr) - } - - log.Infof("%s Successfully sent %d/%d notifications", logcolors.LogNotifier, successCount, len(m.config.Notifiers)) - return nil -} - -// Run starts the monitor in a loop, checking at the specified interval -func (m *TokenMonitor) Run(checkInterval time.Duration) { - log.Infof("%s Starting (tokens: %d, check interval: %v, warning threshold: %d days, reminder interval: %d hours)", - logcolors.LogTokenMonitor, len(m.config.Tokens), checkInterval, m.config.WarningThreshold, m.config.ReminderInterval) - - // Do an immediate check - if err := m.Check(); err != nil { - log.Errorf("%s Initial token check failed: %v", logcolors.LogTokenMonitor, err) - } - - // Then check periodically - ticker := time.NewTicker(checkInterval) - defer ticker.Stop() - - for range ticker.C { - if err := m.Check(); err != nil { - log.Errorf("%s Token check failed: %v", logcolors.LogTokenMonitor, err) - } - } -} diff --git a/services/providers/kugou/parser.go b/services/providers/kugou/parser.go index 794316a..e8ccb79 100644 --- a/services/providers/kugou/parser.go +++ b/services/providers/kugou/parser.go @@ -19,7 +19,6 @@ var ( // Banned pattern for credit lines (e.g., "[00:05.00]Composed by:xxx") // Reference: https://github.com/mostafaalagamy/Metrolist/blob/1152eb28a9c6c0e9f7fa63c87ef50e2e4fa1eae1/kugou/src/main/kotlin/com/metrolist/kugou/KuGou.kt#L149 bannedRegex = regexp.MustCompile(`^\[\d{2}:\d{2}[\.:]\d{2,3}\].+:.+`) - ) const ( diff --git a/services/providers/kugou/parser_test.go b/services/providers/kugou/parser_test.go index 5dcd0da..fd00101 100644 --- a/services/providers/kugou/parser_test.go +++ b/services/providers/kugou/parser_test.go @@ -512,7 +512,7 @@ func TestNormalizeLanguageCode(t *testing.T) { {"Unknown long name", "Klingon", "en"}, // defaults to en {"Whitespace", " english ", "en"}, {"Case insensitive", "ENGLISH", "en"}, - {"Empty", "", ""}, // Empty string returns as-is (len <= 3) + {"Empty", "", ""}, // Empty string returns as-is (len <= 3) } for _, tt := range tests { diff --git a/services/providers/kugou/types.go b/services/providers/kugou/types.go index e8f1f23..8293004 100644 --- a/services/providers/kugou/types.go +++ b/services/providers/kugou/types.go @@ -46,8 +46,8 @@ type DownloadResponse struct { // SongSearchResponse represents the response from Kugou song search API type SongSearchResponse struct { - Status int `json:"status"` - ErrCode int `json:"errcode"` + Status int `json:"status"` + ErrCode int `json:"errcode"` Data struct { Timestamp int64 `json:"timestamp"` Total int `json:"total"` diff --git a/services/providers/ttml/account.go b/services/providers/ttml/account.go index c036535..d6e92e0 100644 --- a/services/providers/ttml/account.go +++ b/services/providers/ttml/account.go @@ -1,24 +1,43 @@ package ttml import ( - "lyrics-api-go/config" - "lyrics-api-go/logcolors" - "lyrics-api-go/services/notifier" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" "sync" "sync/atomic" "time" log "github.com/sirupsen/logrus" + + "lyrics-api-go/config" + "lyrics-api-go/logcolors" + "lyrics-api-go/services/notifier" ) const ( // QuarantineDuration is how long an account is quarantined after a 429 QuarantineDuration = 5 * time.Minute + + // StorefrontCacheFile is the filename for persistent storefront cache + StorefrontCacheFile = "storefront_cache.json" ) var ( - accountManager *AccountManager - quarantineMutex sync.RWMutex // Protects quarantineTime map + accountManager *AccountManager + quarantineMutex sync.RWMutex // Protects quarantineTime map + disabledAccounts = make(map[string]bool) // Permanently disabled accounts (stale MUT) + disabledMutex sync.RWMutex // Protects disabledAccounts map + + // Storefront cache: maps MUT hash -> storefront code + storefrontCache = make(map[string]string) + storefrontCachePath string + storefrontMutex sync.RWMutex ) func initAccountManager() { @@ -47,7 +66,6 @@ func initAccountManager() { for i, acc := range configAccounts { accounts[i] = MusicAccount{ NameID: acc.Name, - BearerToken: acc.BearerToken, MediaUserToken: acc.MediaUserToken, Storefront: storefront, } @@ -62,8 +80,8 @@ func initAccountManager() { log.Infof("Initialized %d TTML account(s) with round-robin load balancing", len(accounts)) } -// getNextAccount returns the next non-quarantined account in round-robin fashion (thread-safe) -// If all accounts are quarantined, returns the one with the shortest remaining quarantine +// getNextAccount returns the next non-quarantined, non-disabled account in round-robin fashion (thread-safe) +// If all accounts are quarantined or disabled, returns the one with the shortest remaining quarantine func (m *AccountManager) getNextAccount() MusicAccount { if len(m.accounts) == 0 { return MusicAccount{} @@ -72,23 +90,36 @@ func (m *AccountManager) getNextAccount() MusicAccount { now := time.Now().Unix() numAccounts := len(m.accounts) - // Try to find a non-quarantined account + // Try to find a non-quarantined, non-disabled account for i := 0; i < numAccounts; i++ { idx := atomic.AddUint64(&m.currentIndex, 1) - 1 accountIdx := int(idx % uint64(numAccounts)) + // Skip disabled accounts (stale MUT - permanent) + if m.IsAccountDisabled(m.accounts[accountIdx].NameID) { + log.Debugf("%s Skipping %s (disabled - stale MUT)", logcolors.LogQuarantine, logcolors.Account(m.accounts[accountIdx].NameID)) + continue + } + + // Skip quarantined accounts (rate limited - temporary) if !m.isQuarantined(accountIdx, now) { return m.accounts[accountIdx] } log.Debugf("%s Skipping %s (quarantined)", logcolors.LogQuarantine, logcolors.Account(m.accounts[accountIdx].NameID)) } - // All accounts quarantined - find the one with shortest remaining time - shortestIdx := 0 + // All accounts quarantined or disabled - find the one with shortest remaining time + // (only consider non-disabled accounts) + shortestIdx := -1 shortestTime := int64(^uint64(0) >> 1) // Max int64 quarantineMutex.RLock() for i := 0; i < numAccounts; i++ { + // Skip disabled accounts entirely + if m.IsAccountDisabled(m.accounts[i].NameID) { + continue + } + if endTime, exists := m.quarantineTime[i]; exists { remaining := endTime - now if remaining < shortestTime { @@ -104,8 +135,14 @@ func (m *AccountManager) getNextAccount() MusicAccount { } quarantineMutex.RUnlock() + // If all accounts are disabled, return empty + if shortestIdx == -1 { + log.Errorf("%s All accounts are disabled! No accounts available.", logcolors.LogQuarantine) + return MusicAccount{} + } + if shortestTime > 0 { - log.Warnf("%s All accounts quarantined! Using %s (shortest wait: %ds)", + log.Warnf("%s All available accounts quarantined! Using %s (shortest wait: %ds)", logcolors.LogQuarantine, logcolors.Account(m.accounts[shortestIdx].NameID), shortestTime) } @@ -255,14 +292,265 @@ func (m *AccountManager) accountCount() int { return len(m.accounts) } -// availableAccountCount returns the number of non-quarantined accounts +// availableAccountCount returns the number of non-quarantined, non-disabled accounts func (m *AccountManager) availableAccountCount() int { now := time.Now().Unix() count := 0 - for i := range m.accounts { + for i, acc := range m.accounts { + // Skip disabled accounts (stale MUT) + if m.IsAccountDisabled(acc.NameID) { + continue + } if !m.isQuarantined(i, now) { count++ } } return count } + +// IsAccountQuarantinedByName checks if an account is quarantined by its name ID +func (m *AccountManager) IsAccountQuarantinedByName(nameID string) bool { + now := time.Now().Unix() + for i, acc := range m.accounts { + if acc.NameID == nameID { + return m.isQuarantined(i, now) + } + } + return false +} + +// IsAccountDisabled checks if an account is permanently disabled (stale MUT) +func (m *AccountManager) IsAccountDisabled(nameID string) bool { + disabledMutex.RLock() + defer disabledMutex.RUnlock() + return disabledAccounts[nameID] +} + +// DisableAccount permanently disables an account (called when MUT is detected as stale via 404 on canary) +func (m *AccountManager) DisableAccount(account MusicAccount) { + disabledMutex.Lock() + disabledAccounts[account.NameID] = true + disabledMutex.Unlock() + + log.Errorf("%s Account %s PERMANENTLY DISABLED (stale MUT - 404 on canary)", + logcolors.LogQuarantine, logcolors.Account(account.NameID)) + + // Check if this triggers circuit breaker (all accounts unavailable) + m.checkQuarantineThresholds() +} + +// ============================================================================= +// STOREFRONT CACHE +// ============================================================================= + +// hashMUT returns a SHA256 hash of the media user token +func hashMUT(mut string) string { + h := sha256.Sum256([]byte(mut)) + return hex.EncodeToString(h[:]) +} + +// loadStorefrontCache loads the storefront cache from disk +func loadStorefrontCache() { + storefrontMutex.Lock() + defer storefrontMutex.Unlock() + + // Determine cache path (same directory as cache.db) if not already set + if storefrontCachePath == "" { + cacheDir := os.Getenv("CACHE_DB_PATH") + if cacheDir == "" { + cacheDir = "./cache.db" + } + storefrontCachePath = filepath.Join(filepath.Dir(cacheDir), StorefrontCacheFile) + } + + data, err := os.ReadFile(storefrontCachePath) + if err != nil { + if !os.IsNotExist(err) { + log.Warnf("%s Failed to read storefront cache: %v", logcolors.LogAccountInit, err) + } + return + } + + if err := json.Unmarshal(data, &storefrontCache); err != nil { + log.Warnf("%s Failed to parse storefront cache: %v", logcolors.LogAccountInit, err) + return + } + + log.Debugf("%s Loaded %d cached storefronts", logcolors.LogAccountInit, len(storefrontCache)) +} + +// saveStorefrontCache persists the storefront cache to disk +func saveStorefrontCache() { + storefrontMutex.RLock() + data, err := json.MarshalIndent(storefrontCache, "", " ") + storefrontMutex.RUnlock() + + if err != nil { + log.Warnf("%s Failed to marshal storefront cache: %v", logcolors.LogAccountInit, err) + return + } + + // Ensure directory exists + dir := filepath.Dir(storefrontCachePath) + if err := os.MkdirAll(dir, 0755); err != nil { + log.Warnf("%s Failed to create cache directory: %v", logcolors.LogAccountInit, err) + return + } + + if err := os.WriteFile(storefrontCachePath, data, 0644); err != nil { + log.Warnf("%s Failed to write storefront cache: %v", logcolors.LogAccountInit, err) + return + } + + log.Debugf("%s Saved %d storefronts to cache", logcolors.LogAccountInit, len(storefrontCache)) +} + +// getCachedStorefront returns the cached storefront for a MUT, or empty string if not cached +func getCachedStorefront(mut string) string { + storefrontMutex.RLock() + defer storefrontMutex.RUnlock() + return storefrontCache[hashMUT(mut)] +} + +// setCachedStorefront stores a storefront for a MUT in the cache +func setCachedStorefront(mut, storefront string) { + storefrontMutex.Lock() + defer storefrontMutex.Unlock() + storefrontCache[hashMUT(mut)] = storefront +} + +// ============================================================================= +// STOREFRONT FETCHING +// ============================================================================= + +// fetchAccountStorefront fetches the storefront for a specific account from Apple Music's account API. +// Returns the storefront code (e.g., "us", "in", "gb") or an error. +func fetchAccountStorefront(account MusicAccount) (string, error) { + if account.MediaUserToken == "" { + return "", fmt.Errorf("account has no media user token") + } + + // Get bearer token for auth + bearerToken, err := GetBearerToken() + if err != nil { + return "", fmt.Errorf("failed to get bearer token: %w", err) + } + + conf := config.Get() + accountURL := conf.Configuration.TTMLBaseURL + "/me/account?meta=subscription" + + req, err := http.NewRequest("GET", accountURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + // Set headers (same as lyrics API) + req.Header.Set("Authorization", "Bearer "+bearerToken) + req.Header.Set("media-user-token", account.MediaUserToken) + req.Header.Set("Origin", "https://music.apple.com") + req.Header.Set("Referer", "https://music.apple.com/") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + var accountResp AccountResponse + if err := json.Unmarshal(body, &accountResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + storefront := accountResp.Meta.Subscription.Storefront + if storefront == "" { + return "", fmt.Errorf("empty storefront in response") + } + + return storefront, nil +} + +// InitializeAccountStorefronts fetches and sets the storefront for each account. +// Uses persistent cache to avoid refetching storefronts when MUT hasn't changed. +// This should be called after the bearer token is available. +// On failure, accounts retain their default storefront from config. +func InitializeAccountStorefronts() { + // Ensure account manager is initialized + if accountManager == nil { + initAccountManager() + } + + if accountManager == nil || len(accountManager.accounts) == 0 { + log.Warnf("%s No accounts to initialize storefronts for", logcolors.LogAccountInit) + return + } + + // Load cached storefronts from disk + loadStorefrontCache() + + log.Infof("%s Initializing storefronts for %d account(s)...", logcolors.LogAccountInit, len(accountManager.accounts)) + + cacheUpdated := false + for i := range accountManager.accounts { + account := &accountManager.accounts[i] + + // Skip accounts with empty MUT (out-of-service) + if account.MediaUserToken == "" { + log.Debugf("%s Skipping %s (no MUT)", logcolors.LogAccountInit, logcolors.Account(account.NameID)) + continue + } + + // Check cache first + cachedStorefront := getCachedStorefront(account.MediaUserToken) + if cachedStorefront != "" { + if cachedStorefront != account.Storefront { + log.Infof("%s %s storefront: %s → %s (from cache)", + logcolors.LogAccountInit, logcolors.Account(account.NameID), account.Storefront, cachedStorefront) + account.Storefront = cachedStorefront + } else { + log.Infof("%s %s storefront: %s (cached)", + logcolors.LogAccountInit, logcolors.Account(account.NameID), cachedStorefront) + } + continue + } + + // Not in cache - fetch from API + storefront, err := fetchAccountStorefront(*account) + if err != nil { + log.Warnf("%s Failed to fetch storefront for %s, keeping default %q: %v", + logcolors.LogAccountInit, logcolors.Account(account.NameID), account.Storefront, err) + continue + } + + // Update account and cache + if storefront != account.Storefront { + log.Infof("%s %s storefront: %s → %s (fetched)", + logcolors.LogAccountInit, logcolors.Account(account.NameID), account.Storefront, storefront) + account.Storefront = storefront + } else { + log.Infof("%s %s storefront: %s (fetched)", + logcolors.LogAccountInit, logcolors.Account(account.NameID), storefront) + } + + // Cache the fetched storefront + setCachedStorefront(account.MediaUserToken, storefront) + cacheUpdated = true + } + + // Save cache if updated + if cacheUpdated { + saveStorefrontCache() + } + + log.Infof("%s Storefront initialization complete", logcolors.LogAccountInit) +} diff --git a/services/providers/ttml/account_test.go b/services/providers/ttml/account_test.go index 927df10..de6f4cb 100644 --- a/services/providers/ttml/account_test.go +++ b/services/providers/ttml/account_test.go @@ -1,6 +1,11 @@ package ttml import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "sync" "testing" "time" @@ -8,9 +13,9 @@ import ( func TestAccountManager_GetNextAccount_RoundRobin(t *testing.T) { accounts := []MusicAccount{ - {NameID: "Account1", BearerToken: "token1"}, - {NameID: "Account2", BearerToken: "token2"}, - {NameID: "Account3", BearerToken: "token3"}, + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, + {NameID: "Account3", MediaUserToken: "mut3"}, } manager := &AccountManager{ @@ -31,7 +36,7 @@ func TestAccountManager_GetNextAccount_RoundRobin(t *testing.T) { func TestAccountManager_SingleAccount(t *testing.T) { accounts := []MusicAccount{ - {NameID: "OnlyAccount", BearerToken: "token"}, + {NameID: "OnlyAccount", MediaUserToken: "mut"}, } manager := &AccountManager{ @@ -151,7 +156,6 @@ func TestAccountManager_ConcurrentAccess(t *testing.T) { func TestMusicAccount_Fields(t *testing.T) { account := MusicAccount{ NameID: "TestAccount", - BearerToken: "test_bearer_token_123", MediaUserToken: "test_media_token_456", Storefront: "us", } @@ -159,9 +163,6 @@ func TestMusicAccount_Fields(t *testing.T) { if account.NameID != "TestAccount" { t.Errorf("Expected NameID 'TestAccount', got %q", account.NameID) } - if account.BearerToken != "test_bearer_token_123" { - t.Errorf("Expected BearerToken 'test_bearer_token_123', got %q", account.BearerToken) - } if account.MediaUserToken != "test_media_token_456" { t.Errorf("Expected MediaUserToken 'test_media_token_456', got %q", account.MediaUserToken) } @@ -172,9 +173,9 @@ func TestMusicAccount_Fields(t *testing.T) { func TestAccountManager_Quarantine(t *testing.T) { accounts := []MusicAccount{ - {NameID: "Account1", BearerToken: "token1"}, - {NameID: "Account2", BearerToken: "token2"}, - {NameID: "Account3", BearerToken: "token3"}, + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, + {NameID: "Account3", MediaUserToken: "mut3"}, } manager := &AccountManager{ @@ -212,8 +213,8 @@ func TestAccountManager_Quarantine(t *testing.T) { func TestAccountManager_QuarantineAllAccounts(t *testing.T) { accounts := []MusicAccount{ - {NameID: "Account1", BearerToken: "token1"}, - {NameID: "Account2", BearerToken: "token2"}, + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, } manager := &AccountManager{ @@ -235,8 +236,8 @@ func TestAccountManager_QuarantineAllAccounts(t *testing.T) { func TestAccountManager_ClearQuarantine(t *testing.T) { accounts := []MusicAccount{ - {NameID: "Account1", BearerToken: "token1"}, - {NameID: "Account2", BearerToken: "token2"}, + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, } manager := &AccountManager{ @@ -266,9 +267,9 @@ func TestAccountManager_ClearQuarantine(t *testing.T) { func TestAccountManager_AvailableAccountCount(t *testing.T) { accounts := []MusicAccount{ - {NameID: "Account1", BearerToken: "token1"}, - {NameID: "Account2", BearerToken: "token2"}, - {NameID: "Account3", BearerToken: "token3"}, + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, + {NameID: "Account3", MediaUserToken: "mut3"}, } manager := &AccountManager{ @@ -297,8 +298,8 @@ func TestAccountManager_AvailableAccountCount(t *testing.T) { func TestAccountManager_QuarantineStatus(t *testing.T) { accounts := []MusicAccount{ - {NameID: "Account1", BearerToken: "token1"}, - {NameID: "Account2", BearerToken: "token2"}, + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, } manager := &AccountManager{ @@ -331,7 +332,7 @@ func TestAccountManager_QuarantineStatus(t *testing.T) { func TestAccountManager_IsQuarantined(t *testing.T) { manager := &AccountManager{ accounts: []MusicAccount{ - {NameID: "Account1", BearerToken: "token1"}, + {NameID: "Account1", MediaUserToken: "mut1"}, }, currentIndex: 0, quarantineTime: make(map[int]int64), @@ -356,3 +357,704 @@ func TestAccountManager_IsQuarantined(t *testing.T) { t.Error("Account should be quarantined (future expiry)") } } + +func TestAccountManager_DisableAccount(t *testing.T) { + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + account := MusicAccount{NameID: "TestAccount", MediaUserToken: "mut"} + + manager := &AccountManager{ + accounts: []MusicAccount{account}, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // Not disabled initially + if manager.IsAccountDisabled("TestAccount") { + t.Error("Account should not be disabled initially") + } + + // Disable it + manager.DisableAccount(account) + + // Should be disabled now + if !manager.IsAccountDisabled("TestAccount") { + t.Error("Account should be disabled after DisableAccount call") + } +} + +func TestAccountManager_IsAccountQuarantinedByName(t *testing.T) { + accounts := []MusicAccount{ + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, + } + + manager := &AccountManager{ + accounts: accounts, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // Not quarantined initially + if manager.IsAccountQuarantinedByName("Account1") { + t.Error("Account1 should not be quarantined initially") + } + + // Quarantine Account1 + manager.quarantineAccount(accounts[0]) + + // Should be quarantined now + if !manager.IsAccountQuarantinedByName("Account1") { + t.Error("Account1 should be quarantined") + } + + // Account2 should not be quarantined + if manager.IsAccountQuarantinedByName("Account2") { + t.Error("Account2 should not be quarantined") + } + + // Non-existent account should return false + if manager.IsAccountQuarantinedByName("NonExistent") { + t.Error("Non-existent account should return false") + } +} + +func TestAccountManager_GetNextAccount_SkipsDisabled(t *testing.T) { + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + accounts := []MusicAccount{ + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, + {NameID: "Account3", MediaUserToken: "mut3"}, + } + + manager := &AccountManager{ + accounts: accounts, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // Disable Account1 + disabledMutex.Lock() + disabledAccounts["Account1"] = true + disabledMutex.Unlock() + + // Should skip Account1 and return Account2 + acc := manager.getNextAccount() + if acc.NameID == "Account1" { + t.Error("Should have skipped disabled Account1") + } +} + +func TestAccountManager_AvailableAccountCount_ExcludesDisabled(t *testing.T) { + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + accounts := []MusicAccount{ + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, + {NameID: "Account3", MediaUserToken: "mut3"}, + } + + manager := &AccountManager{ + accounts: accounts, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // All available initially + if manager.availableAccountCount() != 3 { + t.Errorf("Expected 3 available accounts, got %d", manager.availableAccountCount()) + } + + // Disable one + disabledMutex.Lock() + disabledAccounts["Account1"] = true + disabledMutex.Unlock() + + if manager.availableAccountCount() != 2 { + t.Errorf("Expected 2 available accounts after disable, got %d", manager.availableAccountCount()) + } + + // Quarantine another + manager.quarantineAccount(accounts[1]) + if manager.availableAccountCount() != 1 { + t.Errorf("Expected 1 available account after disable+quarantine, got %d", manager.availableAccountCount()) + } +} + +func TestAccountManager_DisabledAndQuarantinedCombination(t *testing.T) { + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + accounts := []MusicAccount{ + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, + {NameID: "Account3", MediaUserToken: "mut3"}, + } + + manager := &AccountManager{ + accounts: accounts, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // Disable Account1 + disabledMutex.Lock() + disabledAccounts["Account1"] = true + disabledMutex.Unlock() + + // Quarantine Account2 + manager.quarantineAccount(accounts[1]) + + // Only Account3 should be available + if manager.availableAccountCount() != 1 { + t.Errorf("Expected 1 available account, got %d", manager.availableAccountCount()) + } + + // getNextAccount should eventually return Account3 + foundAccount3 := false + for i := 0; i < 5; i++ { + acc := manager.getNextAccount() + if acc.NameID == "Account3" { + foundAccount3 = true + break + } + if acc.NameID == "Account1" { + t.Error("Should never return disabled Account1") + } + } + if !foundAccount3 { + t.Error("Should have returned Account3 at least once") + } +} + +func TestAccountManager_AllAccountsDisabled(t *testing.T) { + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + accounts := []MusicAccount{ + {NameID: "Account1", MediaUserToken: "mut1"}, + {NameID: "Account2", MediaUserToken: "mut2"}, + } + + manager := &AccountManager{ + accounts: accounts, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // Disable all accounts + disabledMutex.Lock() + disabledAccounts["Account1"] = true + disabledAccounts["Account2"] = true + disabledMutex.Unlock() + + // Should return empty account when all are disabled + acc := manager.getNextAccount() + if acc.NameID != "" { + t.Errorf("Expected empty account when all are disabled, got %q", acc.NameID) + } + + // Available count should be 0 + if manager.availableAccountCount() != 0 { + t.Errorf("Expected 0 available accounts, got %d", manager.availableAccountCount()) + } +} + +func TestAccountManager_IsAccountDisabled_NotFound(t *testing.T) { + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + manager := &AccountManager{ + accounts: []MusicAccount{{NameID: "Account1", MediaUserToken: "mut1"}}, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // Non-existent account should return false + if manager.IsAccountDisabled("NonExistent") { + t.Error("Non-existent account should not be disabled") + } +} + +// ============================================================================= +// STOREFRONT FETCHING TESTS +// ============================================================================= + +func TestFetchAccountStorefront_Success(t *testing.T) { + // Create a mock server that returns a valid account response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request path + if r.URL.Path != "/v1/me/account" { + t.Errorf("Expected path /v1/me/account, got %s", r.URL.Path) + } + + // Verify query params + if r.URL.Query().Get("meta") != "subscription" { + t.Errorf("Expected meta=subscription query param") + } + + // Verify required headers + if r.Header.Get("Authorization") == "" { + t.Error("Expected Authorization header") + } + if r.Header.Get("media-user-token") != "test_mut" { + t.Errorf("Expected media-user-token header 'test_mut', got %s", r.Header.Get("media-user-token")) + } + if r.Header.Get("Origin") != "https://music.apple.com" { + t.Errorf("Expected Origin header") + } + + // Return mock response + resp := AccountResponse{ + Meta: AccountMeta{ + Subscription: SubscriptionInfo{ + Active: true, + Storefront: "in", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Save and restore original bearer token state + tokenMu.Lock() + originalToken := bearerToken + originalExpiry := tokenExpiry + bearerToken = "test_bearer_token" + tokenExpiry = time.Now().Add(1 * time.Hour) + tokenMu.Unlock() + defer func() { + tokenMu.Lock() + bearerToken = originalToken + tokenExpiry = originalExpiry + tokenMu.Unlock() + }() + + // We need to use the mock server URL, but fetchAccountStorefront uses config + // So we'll test the response parsing directly + account := MusicAccount{ + NameID: "TestAccount", + MediaUserToken: "test_mut", + Storefront: "us", + } + + // Test with empty MUT should error + emptyMutAccount := MusicAccount{ + NameID: "EmptyMUT", + MediaUserToken: "", + Storefront: "us", + } + _, err := fetchAccountStorefront(emptyMutAccount) + if err == nil { + t.Error("Expected error for account with empty MUT") + } + if err.Error() != "account has no media user token" { + t.Errorf("Expected 'account has no media user token' error, got: %v", err) + } + + // For accounts with MUT, we can't fully test without mocking config + // but we verify the function exists and handles empty MUT correctly + _ = account +} + +func TestFetchAccountStorefront_EmptyMUT(t *testing.T) { + account := MusicAccount{ + NameID: "TestAccount", + MediaUserToken: "", + Storefront: "us", + } + + _, err := fetchAccountStorefront(account) + if err == nil { + t.Error("Expected error for account with empty MUT") + } + if err.Error() != "account has no media user token" { + t.Errorf("Expected 'account has no media user token' error, got: %v", err) + } +} + +func TestAccountResponse_Parsing(t *testing.T) { + // Test that AccountResponse struct can parse the expected JSON format + jsonData := `{ + "meta": { + "subscription": { + "active": true, + "storefront": "gb" + } + } + }` + + var resp AccountResponse + err := json.Unmarshal([]byte(jsonData), &resp) + if err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if !resp.Meta.Subscription.Active { + t.Error("Expected subscription to be active") + } + if resp.Meta.Subscription.Storefront != "gb" { + t.Errorf("Expected storefront 'gb', got %q", resp.Meta.Subscription.Storefront) + } +} + +func TestAccountResponse_EmptyStorefront(t *testing.T) { + // Test handling of empty storefront in response + jsonData := `{ + "meta": { + "subscription": { + "active": true, + "storefront": "" + } + } + }` + + var resp AccountResponse + err := json.Unmarshal([]byte(jsonData), &resp) + if err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if resp.Meta.Subscription.Storefront != "" { + t.Errorf("Expected empty storefront, got %q", resp.Meta.Subscription.Storefront) + } +} + +func TestAccountResponse_MissingFields(t *testing.T) { + // Test handling of missing fields (should default to zero values) + jsonData := `{ + "meta": {} + }` + + var resp AccountResponse + err := json.Unmarshal([]byte(jsonData), &resp) + if err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if resp.Meta.Subscription.Storefront != "" { + t.Errorf("Expected empty storefront for missing field, got %q", resp.Meta.Subscription.Storefront) + } + if resp.Meta.Subscription.Active { + t.Error("Expected Active to be false for missing field") + } +} + +func TestInitializeAccountStorefronts_NoAccounts(t *testing.T) { + // Save and restore original account manager + originalManager := accountManager + defer func() { + accountManager = originalManager + }() + + // Test with nil account manager + accountManager = nil + InitializeAccountStorefronts() // Should not panic + + // Test with empty accounts + accountManager = &AccountManager{ + accounts: []MusicAccount{}, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + InitializeAccountStorefronts() // Should not panic +} + +func TestInitializeAccountStorefronts_SkipsEmptyMUT(t *testing.T) { + // Save and restore original state + originalManager := accountManager + tokenMu.Lock() + originalToken := bearerToken + originalExpiry := tokenExpiry + bearerToken = "test_bearer_token" + tokenExpiry = time.Now().Add(1 * time.Hour) + tokenMu.Unlock() + + defer func() { + accountManager = originalManager + tokenMu.Lock() + bearerToken = originalToken + tokenExpiry = originalExpiry + tokenMu.Unlock() + }() + + // Create manager with one account with empty MUT + accountManager = &AccountManager{ + accounts: []MusicAccount{ + {NameID: "Account1", MediaUserToken: "", Storefront: "us"}, + {NameID: "Account2", MediaUserToken: "valid_mut", Storefront: "us"}, + }, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // This will attempt to fetch but fail (no real API) + // The important thing is it doesn't panic and skips empty MUT + InitializeAccountStorefronts() + + // Account with empty MUT should still have default storefront + if accountManager.accounts[0].Storefront != "us" { + t.Errorf("Account with empty MUT should keep default storefront, got %q", accountManager.accounts[0].Storefront) + } +} + +// ============================================================================= +// STOREFRONT CACHE TESTS +// ============================================================================= + +func TestHashMUT(t *testing.T) { + // Test that hashMUT returns consistent results + mut := "test_media_user_token_12345" + hash1 := hashMUT(mut) + hash2 := hashMUT(mut) + + if hash1 != hash2 { + t.Errorf("hashMUT should be deterministic, got %q and %q", hash1, hash2) + } + + // Hash should be 64 characters (SHA256 = 32 bytes = 64 hex chars) + if len(hash1) != 64 { + t.Errorf("Expected hash length 64, got %d", len(hash1)) + } + + // Different MUTs should produce different hashes + hash3 := hashMUT("different_token") + if hash1 == hash3 { + t.Error("Different MUTs should produce different hashes") + } +} + +func TestStorefrontCache_GetSet(t *testing.T) { + // Save and restore original cache + storefrontMutex.Lock() + originalCache := storefrontCache + storefrontCache = make(map[string]string) + storefrontMutex.Unlock() + defer func() { + storefrontMutex.Lock() + storefrontCache = originalCache + storefrontMutex.Unlock() + }() + + mut := "test_mut_for_cache" + + // Initially should be empty + sf := getCachedStorefront(mut) + if sf != "" { + t.Errorf("Expected empty storefront for uncached MUT, got %q", sf) + } + + // Set and retrieve + setCachedStorefront(mut, "in") + sf = getCachedStorefront(mut) + if sf != "in" { + t.Errorf("Expected storefront 'in', got %q", sf) + } + + // Update should overwrite + setCachedStorefront(mut, "gb") + sf = getCachedStorefront(mut) + if sf != "gb" { + t.Errorf("Expected storefront 'gb' after update, got %q", sf) + } +} + +func TestStorefrontCache_Persistence(t *testing.T) { + // Create a temp directory for the test + tmpDir, err := os.MkdirTemp("", "storefront_cache_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Save and restore original state + storefrontMutex.Lock() + originalCache := storefrontCache + originalPath := storefrontCachePath + storefrontCache = make(map[string]string) + storefrontCachePath = filepath.Join(tmpDir, StorefrontCacheFile) + storefrontMutex.Unlock() + defer func() { + storefrontMutex.Lock() + storefrontCache = originalCache + storefrontCachePath = originalPath + storefrontMutex.Unlock() + }() + + // Set some values + setCachedStorefront("mut1", "us") + setCachedStorefront("mut2", "in") + + // Save to disk + saveStorefrontCache() + + // Verify file exists + if _, err := os.Stat(storefrontCachePath); os.IsNotExist(err) { + t.Error("Cache file should exist after save") + } + + // Clear in-memory cache + storefrontMutex.Lock() + storefrontCache = make(map[string]string) + storefrontMutex.Unlock() + + // Load from disk + loadStorefrontCache() + + // Verify values are restored + if getCachedStorefront("mut1") != "us" { + t.Errorf("Expected 'us' for mut1 after load, got %q", getCachedStorefront("mut1")) + } + if getCachedStorefront("mut2") != "in" { + t.Errorf("Expected 'in' for mut2 after load, got %q", getCachedStorefront("mut2")) + } +} + +func TestStorefrontCache_LoadNonexistentFile(t *testing.T) { + // Save and restore original state + storefrontMutex.Lock() + originalCache := storefrontCache + originalPath := storefrontCachePath + storefrontCache = make(map[string]string) + storefrontCachePath = "/nonexistent/path/storefront_cache.json" + storefrontMutex.Unlock() + defer func() { + storefrontMutex.Lock() + storefrontCache = originalCache + storefrontCachePath = originalPath + storefrontMutex.Unlock() + }() + + // Set CACHE_DB_PATH to trigger the path calculation in loadStorefrontCache + oldEnv := os.Getenv("CACHE_DB_PATH") + os.Setenv("CACHE_DB_PATH", "/nonexistent/cache.db") + defer os.Setenv("CACHE_DB_PATH", oldEnv) + + // Should not panic when file doesn't exist + loadStorefrontCache() + + // Cache should remain empty + storefrontMutex.RLock() + if len(storefrontCache) != 0 { + t.Error("Cache should be empty after loading nonexistent file") + } + storefrontMutex.RUnlock() +} + +func TestInitializeAccountStorefronts_UsesCache(t *testing.T) { + // Create a temp directory for the test + tmpDir, err := os.MkdirTemp("", "storefront_init_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Save and restore original state + originalManager := accountManager + storefrontMutex.Lock() + originalCache := storefrontCache + originalPath := storefrontCachePath + storefrontCache = make(map[string]string) + storefrontCachePath = filepath.Join(tmpDir, StorefrontCacheFile) + storefrontMutex.Unlock() + + tokenMu.Lock() + originalToken := bearerToken + originalExpiry := tokenExpiry + bearerToken = "test_bearer_token" + tokenExpiry = time.Now().Add(1 * time.Hour) + tokenMu.Unlock() + + defer func() { + accountManager = originalManager + storefrontMutex.Lock() + storefrontCache = originalCache + storefrontCachePath = originalPath + storefrontMutex.Unlock() + tokenMu.Lock() + bearerToken = originalToken + tokenExpiry = originalExpiry + tokenMu.Unlock() + }() + + // Pre-populate cache with a storefront + testMut := "test_cached_mut" + setCachedStorefront(testMut, "jp") + saveStorefrontCache() + + // Clear in-memory cache to simulate fresh start + storefrontMutex.Lock() + storefrontCache = make(map[string]string) + storefrontMutex.Unlock() + + // Create account manager with the same MUT + accountManager = &AccountManager{ + accounts: []MusicAccount{ + {NameID: "CachedAccount", MediaUserToken: testMut, Storefront: "us"}, + }, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // Initialize - should use cached value without API call + InitializeAccountStorefronts() + + // Account should have the cached storefront + if accountManager.accounts[0].Storefront != "jp" { + t.Errorf("Expected storefront 'jp' from cache, got %q", accountManager.accounts[0].Storefront) + } +} diff --git a/services/providers/ttml/client.go b/services/providers/ttml/client.go index b663eee..29e81f9 100644 --- a/services/providers/ttml/client.go +++ b/services/providers/ttml/client.go @@ -35,10 +35,7 @@ func initCircuitBreaker() { // Scale threshold by number of accounts for fair distribution // With round-robin, each account may fail independently, so we need // a higher threshold to avoid premature circuit opening - numAccounts := accountManager.accountCount() - if numAccounts < 1 { - numAccounts = 1 - } + numAccounts := max(accountManager.accountCount(), 1) scaledThreshold := baseThreshold * numAccounts apiCircuitBreaker = circuitbreaker.New(circuitbreaker.Config{ @@ -126,14 +123,8 @@ func stringSimilarity(s1, s2 string) float64 { // One contains the other if strings.Contains(n1, n2) || strings.Contains(n2, n1) { - shorter := len(n1) - if len(n2) < shorter { - shorter = len(n2) - } - longer := len(n1) - if len(n2) > longer { - longer = len(n2) - } + shorter := min(len(n1), len(n2)) + longer := max(len(n1), len(n2)) return 0.7 + (0.3 * float64(shorter) / float64(longer)) } @@ -232,8 +223,15 @@ func makeAPIRequestWithAccount(urlStr string, account MusicAccount, retries int) return nil, account, err } + // Get shared bearer token (auto-scraped) + bearerToken, err := GetBearerToken() + if err != nil { + log.Errorf("%s Failed to get bearer token: %v", logcolors.LogHTTP, err) + return nil, account, fmt.Errorf("failed to get bearer token: %w", err) + } + // Set headers for web auth - req.Header.Set("Authorization", "Bearer "+account.BearerToken) + req.Header.Set("Authorization", "Bearer "+bearerToken) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") req.Header.Set("Origin", "https://music.apple.com") req.Header.Set("Referer", "https://music.apple.com") @@ -251,6 +249,9 @@ func makeAPIRequestWithAccount(urlStr string, account MusicAccount, retries int) log.Infof("%s Response from %s: status %d", logcolors.LogHTTP, logcolors.Account(account.NameID), resp.StatusCode) + // Calculate max retries based on account count (capped at 3) + maxRetries := min(accountManager.accountCount(), 3) + // Handle rate limiting - quarantine account and retry with different one if resp.StatusCode == 429 { // Log rate limit headers for debugging @@ -270,18 +271,12 @@ func makeAPIRequestWithAccount(urlStr string, account MusicAccount, retries int) accountManager.quarantineAccount(account) // Only count toward circuit breaker if no healthy accounts remain - // This prevents circuit breaker from opening when we still have working accounts availableAccounts := accountManager.availableAccountCount() if availableAccounts == 0 { apiCircuitBreaker.RecordFailure() log.Warnf("%s All accounts quarantined, recording circuit breaker failure", logcolors.LogRateLimit) } - // Get next available (non-quarantined) account and retry - maxRetries := accountManager.accountCount() - if maxRetries > 3 { - maxRetries = 3 // Cap at 3 retries even with more accounts - } if retries < maxRetries { resp.Body.Close() nextAccount := accountManager.getNextAccount() @@ -298,13 +293,11 @@ func makeAPIRequestWithAccount(urlStr string, account MusicAccount, retries int) return nil, account, fmt.Errorf("TTML API returned status 429: %s", string(body)) } - // Handle auth errors (don't count as circuit breaker failure, just retry) - maxRetries := accountManager.accountCount() - if maxRetries > 3 { - maxRetries = 3 - } + // Handle auth errors - since bearer is auto-refreshed, 401 indicates MUT issue + // Don't count as circuit breaker failure, just retry with different account if resp.StatusCode == 401 { // Emit auth failure event (only on first occurrence per account to avoid spam during retries) + // Since bearer is always fresh, this indicates the MUT is invalid/expired if retries == 0 { notifier.PublishAccountAuthFailure(account.NameID, resp.StatusCode) } @@ -313,7 +306,7 @@ func makeAPIRequestWithAccount(urlStr string, account MusicAccount, retries int) resp.Body.Close() nextAccount := accountManager.getNextAccount() sleepDuration := time.Duration(retries+1) * time.Second - log.Warnf("%s 401 on %s, switching to %s (attempt %d/%d, sleeping %v)...", + log.Warnf("%s 401 on %s (MUT invalid), switching to %s (attempt %d/%d, sleeping %v)...", logcolors.LogAuthError, logcolors.Account(account.NameID), logcolors.Account(nextAccount.NameID), attemptNum, maxRetries, sleepDuration) time.Sleep(sleepDuration) return makeAPIRequestWithAccount(urlStr, nextAccount, retries+1) diff --git a/services/providers/ttml/healthcheck.go b/services/providers/ttml/healthcheck.go new file mode 100644 index 0000000..876c619 --- /dev/null +++ b/services/providers/ttml/healthcheck.go @@ -0,0 +1,182 @@ +package ttml + +import ( + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "lyrics-api-go/logcolors" + "lyrics-api-go/services/notifier" +) + +const ( + // HealthCheckSongID is a known song ID that has lyrics - used as health check canary + // "Breathe in the air" by Pink Floyd - a popular song that should always have lyrics + HealthCheckSongID = "1065973704" + + // HealthCheckInterval is the time between health checks + HealthCheckInterval = 24 * time.Hour +) + +// MUTHealthStatus holds the health status of a single account's MUT +type MUTHealthStatus struct { + AccountName string `json:"account_name"` + Healthy bool `json:"healthy"` + LastChecked time.Time `json:"last_checked"` + LastError string `json:"last_error,omitempty"` +} + +var ( + healthStatuses = make(map[string]*MUTHealthStatus) + healthMu sync.RWMutex +) + +// CheckMUTHealth tests a single account's MUT against the canary song. +// Only 404 errors are considered "unhealthy" (stale MUT) - the canary song definitely +// has lyrics, so 404 means the MUT can't access them (stale/expired). +// 429 is handled by quarantine, 401 is a bearer token issue (separate system). +func CheckMUTHealth(account MusicAccount) *MUTHealthStatus { + status := &MUTHealthStatus{ + AccountName: account.NameID, + LastChecked: time.Now(), + } + + // Attempt to fetch lyrics for canary song + _, err := fetchLyricsTTML(HealthCheckSongID, account.Storefront, account) + + if err == nil { + status.Healthy = true + log.Debugf("%s Account %s: healthy", logcolors.LogHealthCheck, logcolors.Account(account.NameID)) + } else { + status.LastError = err.Error() + + // 404 on canary song = stale MUT (song definitely has lyrics, MUT can't access them) + if strings.Contains(err.Error(), "404") { + status.Healthy = false + log.Warnf("%s Account %s: STALE MUT (404 on canary) - %v", logcolors.LogHealthCheck, logcolors.Account(account.NameID), err) + + // Permanently disable this account + accountManager.DisableAccount(account) + } else { + // 429, 401, network errors don't mean the MUT is stale + status.Healthy = true + log.Debugf("%s Account %s: transient error (not stale) - %v", logcolors.LogHealthCheck, logcolors.Account(account.NameID), err) + } + } + + healthMu.Lock() + healthStatuses[account.NameID] = status + healthMu.Unlock() + + return status +} + +// CheckAllMUTHealth runs health checks on all ACTIVE accounts. +// Skips out-of-service accounts (empty MUT), quarantined accounts (rate limited), +// and already disabled accounts (stale MUT detected previously). +func CheckAllMUTHealth() []*MUTHealthStatus { + if accountManager == nil { + initAccountManager() + } + + accounts := accountManager.getAllAccounts() + results := make([]*MUTHealthStatus, 0, len(accounts)) + + for _, account := range accounts { + // Skip out-of-service accounts (empty MUT) + if account.MediaUserToken == "" { + log.Debugf("%s Skipping out-of-service account: %s", logcolors.LogHealthCheck, account.NameID) + continue + } + + // Skip quarantined accounts (rate limited, not stale) + if accountManager.IsAccountQuarantinedByName(account.NameID) { + log.Debugf("%s Skipping quarantined account: %s", logcolors.LogHealthCheck, account.NameID) + continue + } + + // Skip already disabled accounts (stale MUT detected previously) + if accountManager.IsAccountDisabled(account.NameID) { + log.Debugf("%s Skipping disabled account: %s", logcolors.LogHealthCheck, account.NameID) + continue + } + + status := CheckMUTHealth(account) + results = append(results, status) + } + + return results +} + +// GetHealthStatuses returns current health status of all MUTs +func GetHealthStatuses() map[string]*MUTHealthStatus { + healthMu.RLock() + defer healthMu.RUnlock() + + // Return a copy to avoid race conditions + result := make(map[string]*MUTHealthStatus, len(healthStatuses)) + for k, v := range healthStatuses { + statusCopy := *v + result[k] = &statusCopy + } + return result +} + +// StartHealthCheckScheduler runs health checks daily +func StartHealthCheckScheduler() { + // Run immediately on startup + go runHealthCheck() + + // Schedule daily checks + ticker := time.NewTicker(HealthCheckInterval) + go func() { + for range ticker.C { + runHealthCheck() + } + }() +} + +func runHealthCheck() { + log.Infof("%s Starting MUT health check...", logcolors.LogHealthCheck) + + results := CheckAllMUTHealth() + + var healthy int + var staleMUTs []*MUTHealthStatus + + for _, status := range results { + if status.Healthy { + healthy++ + } else { + // Only 404s (stale MUT) are marked unhealthy by CheckMUTHealth + staleMUTs = append(staleMUTs, status) + } + } + + log.Infof("%s Health check complete: %d healthy, %d stale MUTs (404)", + logcolors.LogHealthCheck, healthy, len(staleMUTs)) + + if len(staleMUTs) == 0 { + return + } + + // Convert to simplified format for notifier + unhealthyData := make([]map[string]string, 0, len(staleMUTs)) + for _, status := range staleMUTs { + unhealthyData = append(unhealthyData, map[string]string{ + "name": status.AccountName, + "error": status.LastError, + }) + } + notifier.PublishMUTHealthCheckFailed(unhealthyData) +} + +// getAllAccounts returns all accounts from the manager (for health checks) +func (m *AccountManager) getAllAccounts() []MusicAccount { + if m == nil { + return nil + } + return m.accounts +} diff --git a/services/providers/ttml/healthcheck_test.go b/services/providers/ttml/healthcheck_test.go new file mode 100644 index 0000000..c746601 --- /dev/null +++ b/services/providers/ttml/healthcheck_test.go @@ -0,0 +1,371 @@ +package ttml + +import ( + "strings" + "testing" + "time" +) + +func TestMUTHealthStatus_Fields(t *testing.T) { + status := &MUTHealthStatus{ + AccountName: "TestAccount", + Healthy: true, + LastChecked: time.Now(), + LastError: "", + } + + if status.AccountName != "TestAccount" { + t.Errorf("Expected AccountName 'TestAccount', got %q", status.AccountName) + } + if !status.Healthy { + t.Error("Expected Healthy to be true") + } + if status.LastError != "" { + t.Errorf("Expected empty LastError, got %q", status.LastError) + } +} + +func TestMUTHealthStatus_Unhealthy(t *testing.T) { + status := &MUTHealthStatus{ + AccountName: "FailedAccount", + Healthy: false, + LastChecked: time.Now(), + LastError: "HTTP 404: lyrics not found", + } + + if status.Healthy { + t.Error("Expected Healthy to be false") + } + if status.LastError != "HTTP 404: lyrics not found" { + t.Errorf("Expected LastError 'HTTP 404: lyrics not found', got %q", status.LastError) + } +} + +func TestCheckAllMUTHealth_SkipsQuarantined(t *testing.T) { + // Setup test account manager with quarantined account + accounts := []MusicAccount{ + {NameID: "Account1", MediaUserToken: "mut1", Storefront: "us"}, + {NameID: "Account2", MediaUserToken: "mut2", Storefront: "us"}, + } + + testManager := &AccountManager{ + accounts: accounts, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + // Store original and replace + originalManager := accountManager + accountManager = testManager + defer func() { accountManager = originalManager }() + + // Quarantine Account1 + testManager.quarantineTime[0] = time.Now().Add(5 * time.Minute).Unix() + + // Reset disabled accounts for test + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + // Verify quarantine detection + if !testManager.IsAccountQuarantinedByName("Account1") { + t.Error("Account1 should be quarantined") + } + if testManager.IsAccountQuarantinedByName("Account2") { + t.Error("Account2 should not be quarantined") + } +} + +func TestCheckAllMUTHealth_SkipsDisabled(t *testing.T) { + accounts := []MusicAccount{ + {NameID: "Account1", MediaUserToken: "mut1", Storefront: "us"}, + {NameID: "Account2", MediaUserToken: "mut2", Storefront: "us"}, + } + + testManager := &AccountManager{ + accounts: accounts, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + originalManager := accountManager + accountManager = testManager + defer func() { accountManager = originalManager }() + + // Reset and setup disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledAccounts["Account1"] = true + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + if !testManager.IsAccountDisabled("Account1") { + t.Error("Account1 should be disabled") + } + if testManager.IsAccountDisabled("Account2") { + t.Error("Account2 should not be disabled") + } +} + +func TestCheckAllMUTHealth_SkipsEmptyMUT(t *testing.T) { + accounts := []MusicAccount{ + {NameID: "Account1", MediaUserToken: "", Storefront: "us"}, // Out of service + {NameID: "Account2", MediaUserToken: "mut2", Storefront: "us"}, // Active + } + + testManager := &AccountManager{ + accounts: accounts, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + originalManager := accountManager + accountManager = testManager + defer func() { accountManager = originalManager }() + + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + // The empty MUT account should be skipped in CheckAllMUTHealth + // We can't fully test without mocking HTTP, but verify the account structure + if accounts[0].MediaUserToken != "" { + t.Error("Account1 should have empty MUT") + } + if accounts[1].MediaUserToken == "" { + t.Error("Account2 should have MUT") + } +} + +func TestGetHealthStatuses_ReturnsCleanCopy(t *testing.T) { + // Clear health statuses + healthMu.Lock() + originalStatuses := healthStatuses + healthStatuses = make(map[string]*MUTHealthStatus) + healthStatuses["TestAccount"] = &MUTHealthStatus{ + AccountName: "TestAccount", + Healthy: true, + LastChecked: time.Now(), + } + healthMu.Unlock() + defer func() { + healthMu.Lock() + healthStatuses = originalStatuses + healthMu.Unlock() + }() + + // Get statuses + statuses := GetHealthStatuses() + + if len(statuses) != 1 { + t.Errorf("Expected 1 status, got %d", len(statuses)) + } + + if status, ok := statuses["TestAccount"]; !ok { + t.Error("Expected TestAccount in statuses") + } else if !status.Healthy { + t.Error("Expected TestAccount to be healthy") + } + + // Modify returned map and verify original is unchanged + statuses["TestAccount"].Healthy = false + + healthMu.RLock() + if !healthStatuses["TestAccount"].Healthy { + t.Error("Original healthStatuses should not be modified") + } + healthMu.RUnlock() +} + +func TestHealthCheckConstants(t *testing.T) { + // Verify constants are set correctly + if HealthCheckSongID != "1065973704" { + t.Errorf("Expected HealthCheckSongID '1065973704', got %q", HealthCheckSongID) + } + + if HealthCheckInterval != 24*time.Hour { + t.Errorf("Expected HealthCheckInterval 24h, got %v", HealthCheckInterval) + } +} + +// TestHealthCheck_401DoesNotDisable verifies that 401 errors (bearer token issues) +// do NOT disable accounts - only 404 (stale MUT on canary) should disable. +func TestHealthCheck_401DoesNotDisable(t *testing.T) { + // Setup test account manager + account := MusicAccount{NameID: "TestAccount401", MediaUserToken: "mut", Storefront: "us"} + + testManager := &AccountManager{ + accounts: []MusicAccount{account}, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + originalManager := accountManager + accountManager = testManager + defer func() { accountManager = originalManager }() + + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + // Simulate what CheckMUTHealth does when it encounters a 401 error + err401 := "HTTP 401: Unauthorized" + + // This is the exact logic from CheckMUTHealth - 401 should NOT trigger disable + if strings.Contains(err401, "404") { + testManager.DisableAccount(account) + } + + // Verify account was NOT disabled + if testManager.IsAccountDisabled("TestAccount401") { + t.Error("401 error should NOT disable account - only 404 should") + } +} + +// TestHealthCheck_404DisablesAccount verifies that 404 errors on the canary song +// DO disable accounts (indicates stale MUT). +func TestHealthCheck_404DisablesAccount(t *testing.T) { + // Setup test account manager + account := MusicAccount{NameID: "TestAccount404", MediaUserToken: "mut", Storefront: "us"} + + testManager := &AccountManager{ + accounts: []MusicAccount{account}, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + originalManager := accountManager + accountManager = testManager + defer func() { accountManager = originalManager }() + + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + // Simulate what CheckMUTHealth does when it encounters a 404 error + err404 := "HTTP 404: lyrics not found" + + // This is the exact logic from CheckMUTHealth - 404 SHOULD trigger disable + if strings.Contains(err404, "404") { + testManager.DisableAccount(account) + } + + // Verify account WAS disabled + if !testManager.IsAccountDisabled("TestAccount404") { + t.Error("404 error should disable account (stale MUT)") + } +} + +// TestHealthCheck_429DoesNotDisable verifies that 429 errors (rate limit) +// do NOT disable accounts - rate limiting is handled by quarantine system. +func TestHealthCheck_429DoesNotDisable(t *testing.T) { + // Setup test account manager + account := MusicAccount{NameID: "TestAccount429", MediaUserToken: "mut", Storefront: "us"} + + testManager := &AccountManager{ + accounts: []MusicAccount{account}, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + originalManager := accountManager + accountManager = testManager + defer func() { accountManager = originalManager }() + + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + // Simulate what CheckMUTHealth does when it encounters a 429 error + err429 := "HTTP 429: Too Many Requests" + + // This is the exact logic from CheckMUTHealth - 429 should NOT trigger disable + if strings.Contains(err429, "404") { + testManager.DisableAccount(account) + } + + // Verify account was NOT disabled + if testManager.IsAccountDisabled("TestAccount429") { + t.Error("429 error should NOT disable account - handled by quarantine system") + } +} + +// TestHealthCheck_NetworkErrorDoesNotDisable verifies that network errors +// do NOT disable accounts - they are transient. +func TestHealthCheck_NetworkErrorDoesNotDisable(t *testing.T) { + // Setup test account manager + account := MusicAccount{NameID: "TestAccountNetwork", MediaUserToken: "mut", Storefront: "us"} + + testManager := &AccountManager{ + accounts: []MusicAccount{account}, + currentIndex: 0, + quarantineTime: make(map[int]int64), + } + + originalManager := accountManager + accountManager = testManager + defer func() { accountManager = originalManager }() + + // Reset disabled accounts + disabledMutex.Lock() + originalDisabled := disabledAccounts + disabledAccounts = make(map[string]bool) + disabledMutex.Unlock() + defer func() { + disabledMutex.Lock() + disabledAccounts = originalDisabled + disabledMutex.Unlock() + }() + + // Simulate what CheckMUTHealth does when it encounters a network error + errNetwork := "dial tcp: connection refused" + + // This is the exact logic from CheckMUTHealth - network errors should NOT trigger disable + if strings.Contains(errNetwork, "404") { + testManager.DisableAccount(account) + } + + // Verify account was NOT disabled + if testManager.IsAccountDisabled("TestAccountNetwork") { + t.Error("Network errors should NOT disable account - they are transient") + } +} diff --git a/services/providers/ttml/token.go b/services/providers/ttml/token.go new file mode 100644 index 0000000..a3765d7 --- /dev/null +++ b/services/providers/ttml/token.go @@ -0,0 +1,252 @@ +package ttml + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "lyrics-api-go/config" + "lyrics-api-go/logcolors" +) + +var ( + bearerToken string + tokenExpiry time.Time + tokenMu sync.RWMutex + + // Refresh token when it has less than this time remaining + refreshThreshold = 5 * time.Minute +) + +// JWTClaims represents the relevant claims from the bearer token +type JWTClaims struct { + Exp int64 `json:"exp"` // Expiration time (Unix timestamp) +} + +// GetBearerToken returns the current bearer token, scraping a fresh one if expired or near expiry +func GetBearerToken() (string, error) { + tokenMu.RLock() + if bearerToken != "" && !isTokenExpiringSoon() { + defer tokenMu.RUnlock() + return bearerToken, nil + } + tokenMu.RUnlock() + + return refreshBearerToken() +} + +// isTokenExpiringSoon checks if the token will expire within the refresh threshold. +// Note: This function does not acquire locks - caller must hold at least a read lock. +func isTokenExpiringSoon() bool { + return tokenExpiry.IsZero() || time.Now().Add(refreshThreshold).After(tokenExpiry) +} + +// GetTokenStatus returns the current token's expiry status for monitoring +func GetTokenStatus() (expiry time.Time, remaining time.Duration, needsRefresh bool) { + tokenMu.RLock() + defer tokenMu.RUnlock() + + if tokenExpiry.IsZero() { + return time.Time{}, 0, true + } + + remaining = time.Until(tokenExpiry) + needsRefresh = isTokenExpiringSoon() + return tokenExpiry, remaining, needsRefresh +} + +func refreshBearerToken() (string, error) { + tokenMu.Lock() + defer tokenMu.Unlock() + + // Double-check after acquiring write lock + if bearerToken != "" && !isTokenExpiringSoon() { + return bearerToken, nil + } + + log.Infof("%s Refreshing bearer token...", logcolors.LogBearerToken) + + token, err := scrapeToken() + if err != nil { + return "", err + } + + // Parse JWT to get actual expiry time + expiry, err := parseJWTExpiry(token) + if err != nil { + // If we can't parse expiry, use a conservative default (1 hour) + log.Warnf("%s Could not parse JWT expiry, using 1h default: %v", logcolors.LogBearerToken, err) + expiry = time.Now().Add(1 * time.Hour) + } + + bearerToken = token + tokenExpiry = expiry + + remaining := time.Until(expiry) + log.Infof("%s Bearer token refreshed, expires in %v (at %s)", + logcolors.LogBearerToken, remaining.Round(time.Minute), expiry.Format(time.RFC3339)) + + return token, nil +} + +// parseJWTExpiry extracts the expiration time from a JWT token +func parseJWTExpiry(token string) (time.Time, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts)) + } + + // Decode payload (second part) + payload := parts[1] + + // Add padding if needed (JWT uses unpadded base64url) + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + // Try standard encoding as fallback + decoded, err = base64.StdEncoding.DecodeString(payload) + if err != nil { + return time.Time{}, fmt.Errorf("failed to decode JWT payload: %w", err) + } + } + + var claims JWTClaims + if err := json.Unmarshal(decoded, &claims); err != nil { + return time.Time{}, fmt.Errorf("failed to parse JWT claims: %w", err) + } + + if claims.Exp == 0 { + return time.Time{}, fmt.Errorf("JWT has no exp claim") + } + + return time.Unix(claims.Exp, 0), nil +} + +func scrapeToken() (string, error) { + conf := config.Get() + baseURL := conf.Configuration.TTMLTokenSourceURL + if baseURL == "" { + return "", fmt.Errorf("TTML_TOKEN_SOURCE_URL not configured") + } + + storefront := conf.Configuration.TTMLStorefront + if storefront == "" { + storefront = "us" + } + browsePath := "/" + storefront + "/browse" + + // 1. Fetch upstream provider's browse page + client := &http.Client{Timeout: 15 * time.Second} + + req, err := http.NewRequest("GET", baseURL+browsePath, nil) + if err != nil { + return "", fmt.Errorf("failed to create browse request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch token source: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token source returned status %d", resp.StatusCode) + } + + html, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read token source response: %w", err) + } + + // 2. Extract JS bundle path + jsPathRe := regexp.MustCompile(`/assets/index[~\-][a-zA-Z0-9]+\.js`) + jsPath := jsPathRe.FindString(string(html)) + if jsPath == "" { + return "", fmt.Errorf("could not find JS bundle path in HTML") + } + + log.Debugf("%s Found JS bundle: %s", logcolors.LogBearerToken, jsPath) + + // 3. Fetch JS bundle + jsReq, err := http.NewRequest("GET", baseURL+jsPath, nil) + if err != nil { + return "", fmt.Errorf("failed to create JS bundle request: %w", err) + } + jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") + + jsResp, err := client.Do(jsReq) + if err != nil { + return "", fmt.Errorf("failed to fetch JS bundle: %w", err) + } + defer jsResp.Body.Close() + + jsContent, err := io.ReadAll(jsResp.Body) + if err != nil { + return "", fmt.Errorf("failed to read JS bundle: %w", err) + } + + // 4. Extract JWT token - look for ES256 signed developer token + tokenRe := regexp.MustCompile(`"(eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6[^"]+)"`) + match := tokenRe.FindStringSubmatch(string(jsContent)) + if len(match) > 1 { + log.Debugf("%s Extracted ES256 JWT from JS bundle", logcolors.LogBearerToken) + return match[1], nil + } + + // Fallback: any JWT with three parts + jwtRe := regexp.MustCompile(`"(eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})"`) + match = jwtRe.FindStringSubmatch(string(jsContent)) + if len(match) > 1 { + log.Debugf("%s Extracted fallback JWT from JS bundle", logcolors.LogBearerToken) + return match[1], nil + } + + return "", fmt.Errorf("could not extract JWT from JS bundle") +} + +// StartBearerTokenMonitor fetches the initial bearer token and storefronts synchronously, +// then starts a background goroutine that proactively refreshes the token before it expires. +func StartBearerTokenMonitor() { + // Initial fetch - synchronous to ensure storefronts are available before server starts + _, err := GetBearerToken() + if err != nil { + log.Errorf("%s Initial token fetch failed: %v", logcolors.LogBearerToken, err) + } else { + // Bearer token available - fetch per-account storefronts + InitializeAccountStorefronts() + } + + // Background monitor for proactive refresh + go func() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + tokenMu.RLock() + needsRefresh := isTokenExpiringSoon() + tokenMu.RUnlock() + + if needsRefresh { + _, err := GetBearerToken() + if err != nil { + log.Errorf("%s Proactive token refresh failed: %v", logcolors.LogBearerToken, err) + } + } + } + }() +} diff --git a/services/providers/ttml/token_test.go b/services/providers/ttml/token_test.go new file mode 100644 index 0000000..cdbb944 --- /dev/null +++ b/services/providers/ttml/token_test.go @@ -0,0 +1,689 @@ +package ttml + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// createTestJWT creates a valid JWT with the given expiry time for testing +func createTestJWT(exp time.Time) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256","typ":"JWT","kid":"test"}`)) + claims := map[string]interface{}{ + "exp": exp.Unix(), + "iss": "test-issuer", + } + claimsJSON, _ := json.Marshal(claims) + payload := base64.RawURLEncoding.EncodeToString(claimsJSON) + signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature")) + return header + "." + payload + "." + signature +} + +// createTestJWTWithClaims creates a JWT with custom claims for testing edge cases +func createTestJWTWithClaims(claims map[string]interface{}) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256","typ":"JWT"}`)) + claimsJSON, _ := json.Marshal(claims) + payload := base64.RawURLEncoding.EncodeToString(claimsJSON) + signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature")) + return header + "." + payload + "." + signature +} + +func TestParseJWTExpiry_ValidToken(t *testing.T) { + // Create a token expiring in 1 hour + expectedExpiry := time.Now().Add(1 * time.Hour).Truncate(time.Second) + token := createTestJWT(expectedExpiry) + + expiry, err := parseJWTExpiry(token) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + + // Compare Unix timestamps to avoid nanosecond differences + if expiry.Unix() != expectedExpiry.Unix() { + t.Errorf("Expected expiry %v, got %v", expectedExpiry, expiry) + } +} + +func TestParseJWTExpiry_VariousExpiryTimes(t *testing.T) { + tests := []struct { + name string + expiry time.Time + }{ + { + name: "Expires in 1 minute", + expiry: time.Now().Add(1 * time.Minute), + }, + { + name: "Expires in 24 hours", + expiry: time.Now().Add(24 * time.Hour), + }, + { + name: "Expires in 30 days", + expiry: time.Now().Add(30 * 24 * time.Hour), + }, + { + name: "Already expired", + expiry: time.Now().Add(-1 * time.Hour), + }, + { + name: "Expires at epoch + 1 year", + expiry: time.Unix(31536000, 0), // 1971-01-01 + }, + { + name: "Far future expiry", + expiry: time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := createTestJWT(tt.expiry) + expiry, err := parseJWTExpiry(token) + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if expiry.Unix() != tt.expiry.Unix() { + t.Errorf("Expected expiry Unix %d, got %d", tt.expiry.Unix(), expiry.Unix()) + } + }) + } +} + +func TestParseJWTExpiry_InvalidFormat(t *testing.T) { + tests := []struct { + name string + token string + expectedErr string + }{ + { + name: "Empty string", + token: "", + expectedErr: "invalid JWT format: expected 3 parts, got 1", + }, + { + name: "Single part", + token: "eyJhbGciOiJFUzI1NiJ9", + expectedErr: "invalid JWT format: expected 3 parts, got 1", + }, + { + name: "Two parts", + token: "eyJhbGciOiJFUzI1NiJ9.eyJleHAiOjE2MDAwMDAwMDB9", + expectedErr: "invalid JWT format: expected 3 parts, got 2", + }, + { + name: "Four parts", + token: "part1.part2.part3.part4", + expectedErr: "invalid JWT format: expected 3 parts, got 4", + }, + { + name: "Just dots", + token: "..", + expectedErr: "failed to parse JWT claims", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseJWTExpiry(tt.token) + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error containing %q, got %q", tt.expectedErr, err.Error()) + } + }) + } +} + +func TestParseJWTExpiry_InvalidPayload(t *testing.T) { + tests := []struct { + name string + payload string + expectedErr string + }{ + { + name: "Invalid base64", + payload: "not-valid-base64!!!", + expectedErr: "failed to decode JWT payload", + }, + { + name: "Valid base64 but not JSON", + payload: base64.RawURLEncoding.EncodeToString([]byte("not json")), + expectedErr: "failed to parse JWT claims", + }, + { + name: "Empty JSON object", + payload: base64.RawURLEncoding.EncodeToString([]byte("{}")), + expectedErr: "JWT has no exp claim", + }, + { + name: "Exp is zero", + payload: base64.RawURLEncoding.EncodeToString([]byte(`{"exp":0}`)), + expectedErr: "JWT has no exp claim", + }, + { + name: "Exp is string instead of number", + payload: base64.RawURLEncoding.EncodeToString([]byte(`{"exp":"not a number"}`)), + expectedErr: "failed to parse JWT claims", // JSON unmarshal fails on type mismatch + }, + } + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256"}`)) + signature := base64.RawURLEncoding.EncodeToString([]byte("sig")) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := header + "." + tt.payload + "." + signature + _, err := parseJWTExpiry(token) + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("Expected error containing %q, got %q", tt.expectedErr, err.Error()) + } + }) + } +} + +func TestParseJWTExpiry_Base64Padding(t *testing.T) { + // JWT uses base64url encoding WITHOUT padding, but parseJWTExpiry should handle + // payloads that need different amounts of padding + + tests := []struct { + name string + claims map[string]interface{} + }{ + { + name: "Payload needs no padding", + claims: map[string]interface{}{"exp": time.Now().Add(time.Hour).Unix()}, + }, + { + name: "Payload needs single padding", + claims: map[string]interface{}{"exp": time.Now().Add(time.Hour).Unix(), "iss": "a"}, + }, + { + name: "Payload needs double padding", + claims: map[string]interface{}{"exp": time.Now().Add(time.Hour).Unix(), "iss": "ab"}, + }, + { + name: "Larger payload", + claims: map[string]interface{}{ + "exp": time.Now().Add(time.Hour).Unix(), + "iss": "test-issuer-with-longer-name", + "sub": "user@example.com", + "aud": "api.example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := createTestJWTWithClaims(tt.claims) + expiry, err := parseJWTExpiry(token) + if err != nil { + t.Fatalf("Expected no error for padding test, got: %v", err) + } + expectedExp := tt.claims["exp"].(int64) + if expiry.Unix() != expectedExp { + t.Errorf("Expected expiry %d, got %d", expectedExp, expiry.Unix()) + } + }) + } +} + +func TestParseJWTExpiry_RealWorldTokenFormat(t *testing.T) { + // Test with token format similar to real Apple Music tokens (ES256 signed) + // The actual token in production starts with: eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6... + header := `{"alg":"ES256","typ":"JWT","kid":"ABC123DEF"}` + claims := map[string]interface{}{ + "iss": "TEAM_ID", + "iat": time.Now().Unix(), + "exp": time.Now().Add(6 * 30 * 24 * time.Hour).Unix(), // 6 months + } + + headerB64 := base64.RawURLEncoding.EncodeToString([]byte(header)) + claimsJSON, _ := json.Marshal(claims) + payloadB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) + signatureB64 := base64.RawURLEncoding.EncodeToString([]byte("fake-es256-signature-would-be-here")) + + token := headerB64 + "." + payloadB64 + "." + signatureB64 + + expiry, err := parseJWTExpiry(token) + if err != nil { + t.Fatalf("Failed to parse real-world format token: %v", err) + } + + expectedExp := claims["exp"].(int64) + if expiry.Unix() != expectedExp { + t.Errorf("Expected expiry %d, got %d", expectedExp, expiry.Unix()) + } +} + +func TestIsTokenExpiringSoon(t *testing.T) { + // Save original state + originalToken := bearerToken + originalExpiry := tokenExpiry + defer func() { + tokenMu.Lock() + bearerToken = originalToken + tokenExpiry = originalExpiry + tokenMu.Unlock() + }() + + tests := []struct { + name string + tokenExpiry time.Time + expectedResult bool + }{ + { + name: "Zero time - needs refresh", + tokenExpiry: time.Time{}, + expectedResult: true, + }, + { + name: "Expired - needs refresh", + tokenExpiry: time.Now().Add(-1 * time.Hour), + expectedResult: true, + }, + { + name: "Expires in 1 minute - needs refresh", + tokenExpiry: time.Now().Add(1 * time.Minute), + expectedResult: true, + }, + { + name: "Expires in 4 minutes - needs refresh (within 5min threshold)", + tokenExpiry: time.Now().Add(4 * time.Minute), + expectedResult: true, + }, + { + name: "Expires in exactly 5 minutes - edge case, needs refresh", + tokenExpiry: time.Now().Add(5 * time.Minute), + expectedResult: true, // time.Now().Add(5min).After(expiry) is true when equal + }, + { + name: "Expires in 6 minutes - does not need refresh", + tokenExpiry: time.Now().Add(6 * time.Minute), + expectedResult: false, + }, + { + name: "Expires in 1 hour - does not need refresh", + tokenExpiry: time.Now().Add(1 * time.Hour), + expectedResult: false, + }, + { + name: "Expires in 24 hours - does not need refresh", + tokenExpiry: time.Now().Add(24 * time.Hour), + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenMu.Lock() + tokenExpiry = tt.tokenExpiry + tokenMu.Unlock() + + tokenMu.RLock() + result := isTokenExpiringSoon() + tokenMu.RUnlock() + + if result != tt.expectedResult { + t.Errorf("Expected isTokenExpiringSoon()=%v for expiry %v, got %v", + tt.expectedResult, tt.tokenExpiry, result) + } + }) + } +} + +func TestGetTokenStatus(t *testing.T) { + // Save original state + originalToken := bearerToken + originalExpiry := tokenExpiry + defer func() { + tokenMu.Lock() + bearerToken = originalToken + tokenExpiry = originalExpiry + tokenMu.Unlock() + }() + + t.Run("Zero expiry returns needs refresh", func(t *testing.T) { + tokenMu.Lock() + tokenExpiry = time.Time{} + tokenMu.Unlock() + + expiry, remaining, needsRefresh := GetTokenStatus() + + if !expiry.IsZero() { + t.Errorf("Expected zero expiry, got %v", expiry) + } + if remaining != 0 { + t.Errorf("Expected 0 remaining, got %v", remaining) + } + if !needsRefresh { + t.Error("Expected needsRefresh=true for zero expiry") + } + }) + + t.Run("Valid expiry returns correct values", func(t *testing.T) { + futureExpiry := time.Now().Add(1 * time.Hour) + tokenMu.Lock() + tokenExpiry = futureExpiry + tokenMu.Unlock() + + expiry, remaining, needsRefresh := GetTokenStatus() + + if expiry.Unix() != futureExpiry.Unix() { + t.Errorf("Expected expiry %v, got %v", futureExpiry, expiry) + } + + // Remaining should be close to 1 hour (allow 1 second tolerance) + expectedRemaining := time.Until(futureExpiry) + if remaining < expectedRemaining-time.Second || remaining > expectedRemaining+time.Second { + t.Errorf("Expected remaining ~%v, got %v", expectedRemaining, remaining) + } + + if needsRefresh { + t.Error("Expected needsRefresh=false for token expiring in 1 hour") + } + }) + + t.Run("Expiring soon returns needsRefresh true", func(t *testing.T) { + soonExpiry := time.Now().Add(3 * time.Minute) + tokenMu.Lock() + tokenExpiry = soonExpiry + tokenMu.Unlock() + + _, remaining, needsRefresh := GetTokenStatus() + + if remaining < 0 { + t.Errorf("Expected positive remaining time, got %v", remaining) + } + if !needsRefresh { + t.Error("Expected needsRefresh=true for token expiring in 3 minutes") + } + }) +} + +func TestGetBearerToken_UsesCachedToken(t *testing.T) { + // Save original state + originalToken := bearerToken + originalExpiry := tokenExpiry + defer func() { + tokenMu.Lock() + bearerToken = originalToken + tokenExpiry = originalExpiry + tokenMu.Unlock() + }() + + // Set a valid cached token + cachedToken := "cached-test-token" + tokenMu.Lock() + bearerToken = cachedToken + tokenExpiry = time.Now().Add(1 * time.Hour) // Not expiring soon + tokenMu.Unlock() + + // GetBearerToken should return the cached token without refreshing + token, err := GetBearerToken() + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if token != cachedToken { + t.Errorf("Expected cached token %q, got %q", cachedToken, token) + } +} + +func TestGetBearerToken_RefreshesExpiredToken(t *testing.T) { + // Save original state + originalToken := bearerToken + originalExpiry := tokenExpiry + defer func() { + tokenMu.Lock() + bearerToken = originalToken + tokenExpiry = originalExpiry + tokenMu.Unlock() + }() + + // Set an expired token + tokenMu.Lock() + bearerToken = "expired-token" + tokenExpiry = time.Now().Add(-1 * time.Hour) // Already expired + tokenMu.Unlock() + + // Without a mock server, this will fail - but we verify it TRIES to refresh + _, err := GetBearerToken() + // Error is expected since TTML_TOKEN_SOURCE_URL is not configured in tests + if err == nil { + // If no error, the refresh somehow succeeded (unlikely without config) + t.Log("GetBearerToken succeeded unexpectedly - check if config is set") + } else if !strings.Contains(err.Error(), "TOKEN_SOURCE_URL") && + !strings.Contains(err.Error(), "failed") { + t.Logf("GetBearerToken returned expected error type: %v", err) + } +} + +func TestScrapeToken_MissingConfig(t *testing.T) { + // scrapeToken should fail gracefully when config is missing + // This tests the error path without needing HTTP mocking + + // Note: This test relies on the config not having TTML_TOKEN_SOURCE_URL set + // In a real test environment, you might need to temporarily clear it + + _, err := scrapeToken() + if err == nil { + // If config is set in test environment, skip this assertion + t.Log("scrapeToken succeeded - TTML_TOKEN_SOURCE_URL may be configured in test env") + return + } + + // Should get a configuration error + if !strings.Contains(err.Error(), "TOKEN_SOURCE_URL") && + !strings.Contains(err.Error(), "failed") { + t.Logf("scrapeToken error: %v", err) + } +} + +func TestScrapeToken_WithMockServer(t *testing.T) { + // Create a mock server that simulates the upstream provider + expectedToken := createTestJWT(time.Now().Add(6 * 30 * 24 * time.Hour)) + + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/browse"): + // Return HTML with JS bundle path + html := ` + + Browse + + + + ` + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(html)) + + case strings.HasPrefix(r.URL.Path, "/assets/index"): + // Return JS bundle with embedded token + js := fmt.Sprintf(` + var config = { + token: "%s", + other: "data" + }; + `, expectedToken) + w.Header().Set("Content-Type", "application/javascript") + w.Write([]byte(js)) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // We can't easily test scrapeToken directly since it uses config.Get() + // But we can test the HTTP flow logic indirectly + + // Test that the server responds correctly + resp, err := http.Get(server.URL + "/us/browse") + if err != nil { + t.Fatalf("Failed to fetch browse page: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + + // Test JS bundle endpoint + resp2, err := http.Get(server.URL + "/assets/index~abc123.js") + if err != nil { + t.Fatalf("Failed to fetch JS bundle: %v", err) + } + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + t.Errorf("Expected 200 for JS bundle, got %d", resp2.StatusCode) + } +} + +func TestScrapeToken_ServerErrors(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + expectsErr bool + }{ + { + name: "Server returns 500", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + expectsErr: true, + }, + { + name: "Server returns 404", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + expectsErr: true, + }, + { + name: "HTML without JS bundle path", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("No JS here")) + }, + expectsErr: true, + }, + { + name: "JS bundle without token", + handler: func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/browse") { + w.Write([]byte(``)) + } else { + w.Write([]byte("var x = 1; // no token here")) + } + }, + expectsErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + // We can't directly call scrapeToken with a custom URL + // But this documents the expected error scenarios + t.Logf("Server URL: %s - would expect error: %v", server.URL, tt.expectsErr) + }) + } +} + +func TestRefreshBearerToken_DoubleCheckPattern(t *testing.T) { + // This tests the double-check pattern in refreshBearerToken + // After acquiring write lock, it should re-check if token is still valid + + // Save original state + originalToken := bearerToken + originalExpiry := tokenExpiry + defer func() { + tokenMu.Lock() + bearerToken = originalToken + tokenExpiry = originalExpiry + tokenMu.Unlock() + }() + + // Set a valid token that doesn't need refresh + validToken := "valid-token-from-another-goroutine" + tokenMu.Lock() + bearerToken = validToken + tokenExpiry = time.Now().Add(1 * time.Hour) + tokenMu.Unlock() + + // refreshBearerToken should return the existing valid token + // because of the double-check after acquiring the lock + token, err := refreshBearerToken() + if err != nil { + // If config isn't set up, refresh will fail - that's ok for this test + t.Logf("refreshBearerToken error (expected in test env): %v", err) + return + } + + if token != validToken { + t.Errorf("Expected double-check to return existing valid token %q, got %q", + validToken, token) + } +} + +func TestTokenMonitor_Integration(t *testing.T) { + // This is more of a documentation test showing how the monitor works + // We can't easily test the goroutine behavior, but we verify the setup + + // StartBearerTokenMonitor starts a background goroutine + // It should: + // 1. Do initial fetch + // 2. Check every minute if refresh is needed + // 3. Proactively refresh before expiry + + // For actual testing, you'd need to: + // 1. Mock the config + // 2. Mock the HTTP client + // 3. Use time mocking or very short intervals + + t.Log("TokenMonitor: starts background refresh goroutine") + t.Log("TokenMonitor: checks every 1 minute") + t.Log("TokenMonitor: refreshes when token expires within 5 minutes") +} + +func TestRefreshThreshold(t *testing.T) { + // Verify the refresh threshold constant + if refreshThreshold != 5*time.Minute { + t.Errorf("Expected refresh threshold of 5 minutes, got %v", refreshThreshold) + } +} + +// Benchmark for JWT parsing +func BenchmarkParseJWTExpiry(b *testing.B) { + token := createTestJWT(time.Now().Add(time.Hour)) + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _, _ = parseJWTExpiry(token) + } +} + +// Benchmark for token expiry check +func BenchmarkIsTokenExpiringSoon(b *testing.B) { + tokenMu.Lock() + tokenExpiry = time.Now().Add(time.Hour) + tokenMu.Unlock() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + tokenMu.RLock() + _ = isTokenExpiringSoon() + tokenMu.RUnlock() + } +} diff --git a/services/providers/ttml/types.go b/services/providers/ttml/types.go index f03b649..18fc949 100644 --- a/services/providers/ttml/types.go +++ b/services/providers/ttml/types.go @@ -20,9 +20,10 @@ type Syllable = providers.Syllable // ACCOUNT MANAGEMENT TYPES // ============================================================================= +// MusicAccount represents a single TTML API account. +// Bearer token is now shared and auto-scraped - only MUT is per-account. type MusicAccount struct { NameID string - BearerToken string MediaUserToken string Storefront string } @@ -68,6 +69,24 @@ type LyricsResponse struct { } `json:"data"` } +// ============================================================================= +// ACCOUNT API RESPONSE STRUCTURES +// ============================================================================= + +// AccountResponse for /v1/me/account endpoint +type AccountResponse struct { + Meta AccountMeta `json:"meta"` +} + +type AccountMeta struct { + Subscription SubscriptionInfo `json:"subscription"` +} + +type SubscriptionInfo struct { + Active bool `json:"active"` + Storefront string `json:"storefront"` +} + // ============================================================================= // TTML XML STRUCTURES // ============================================================================= diff --git a/startup.go b/startup.go index 6292892..cd29350 100644 --- a/startup.go +++ b/startup.go @@ -9,7 +9,6 @@ import ( "lyrics-api-go/stats" "net/http" "os" - "time" log "github.com/sirupsen/logrus" ) @@ -47,7 +46,7 @@ func setupNotifiers() []notifier.Notifier { ToEmail: os.Getenv("NOTIFIER_TO_EMAIL"), } notifiers = append(notifiers, emailNotifier) - log.Infof("%s Email notifier enabled", logcolors.LogTokenMonitor) + log.Infof("%s Email notifier enabled", logcolors.LogNotifier) } if botToken := os.Getenv("NOTIFIER_TELEGRAM_BOT_TOKEN"); botToken != "" { @@ -56,7 +55,7 @@ func setupNotifiers() []notifier.Notifier { ChatID: os.Getenv("NOTIFIER_TELEGRAM_CHAT_ID"), } notifiers = append(notifiers, telegramNotifier) - log.Infof("%s Telegram notifier enabled", logcolors.LogTokenMonitor) + log.Infof("%s Telegram notifier enabled", logcolors.LogNotifier) } if topic := os.Getenv("NOTIFIER_NTFY_TOPIC"); topic != "" { @@ -65,54 +64,12 @@ func setupNotifiers() []notifier.Notifier { Server: getEnvOrDefault("NOTIFIER_NTFY_SERVER", "https://ntfy.sh"), } notifiers = append(notifiers, ntfyNotifier) - log.Infof("%s Ntfy.sh notifier enabled", logcolors.LogTokenMonitor) + log.Infof("%s Ntfy.sh notifier enabled", logcolors.LogNotifier) } return notifiers } -func startTokenMonitor() { - accounts, err := conf.GetTTMLAccounts() - if err != nil { - log.Warnf("%s Failed to get TTML accounts: %v", logcolors.LogTokenMonitor, err) - return - } - - if len(accounts) == 0 { - log.Warnf("%s No TTML accounts configured, token monitoring disabled", logcolors.LogTokenMonitor) - return - } - - notifiers := setupNotifiers() - - if len(notifiers) == 0 { - log.Infof("%s No notifiers configured, token monitoring disabled", logcolors.LogTokenMonitor) - log.Infof("%s To enable notifications, configure at least one notifier (Email, Telegram, or Ntfy.sh)", logcolors.LogTokenMonitor) - return - } - - // Convert accounts to TokenInfo for the monitor - tokens := make([]notifier.TokenInfo, len(accounts)) - for i, acc := range accounts { - tokens[i] = notifier.TokenInfo{ - Name: acc.Name, - BearerToken: acc.BearerToken, - } - } - - log.Infof("%s Starting with %d account(s) and %d notifier(s) configured", logcolors.LogTokenMonitor, len(tokens), len(notifiers)) - - monitor := notifier.NewTokenMonitor(notifier.MonitorConfig{ - Tokens: tokens, - WarningThreshold: 7, - ReminderInterval: 24, - StateFile: "/tmp/ttml-pager.state", - Notifiers: notifiers, - }) - - monitor.Run(6 * time.Hour) -} - func limitMiddleware(next http.Handler, limiter *middleware.IPRateLimiter) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check for API key to bypass rate limits diff --git a/types.go b/types.go index 48ba6a3..846327c 100644 --- a/types.go +++ b/types.go @@ -73,14 +73,14 @@ const ( // MigrationJob tracks an async cache migration type MigrationJob struct { - ID string `json:"id"` - Status MigrationJobStatus `json:"status"` - StartedAt int64 `json:"started_at"` - CompletedAt int64 `json:"completed_at,omitempty"` - Recompress bool `json:"recompress"` - Progress MigrationProgress `json:"progress"` - Result *MigrationResult `json:"result,omitempty"` - Error string `json:"error,omitempty"` + ID string `json:"id"` + Status MigrationJobStatus `json:"status"` + StartedAt int64 `json:"started_at"` + CompletedAt int64 `json:"completed_at,omitempty"` + Recompress bool `json:"recompress"` + Progress MigrationProgress `json:"progress"` + Result *MigrationResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` } // MigrationProgress tracks migration progress