ensureCliAccess(); $this->loadExternalServiceKeys(); $this->validateAuthorization(); } private function loadExternalServiceKeys(): void { $this->navidromeApiUrl = getenv('NAVIDROME_API_URL'); $this->navidromeAuthToken = getenv('NAVIDROME_API_TOKEN'); $this->forwardEmailApiKey = getenv('FORWARDEMAIL_API_KEY'); } private function validateAuthorization(): void { $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; $expectedToken = 'Bearer '.getenv('NAVIDROME_SCROBBLE_TOKEN'); if ($authHeader !== $expectedToken) { http_response_code(401); echo json_encode(['error' => 'Unauthorized.']); exit(); } } public function runScrobbleCheck(): void { $recentTracks = $this->fetchRecentlyPlayed(); if (empty($recentTracks)) { return; } foreach ($recentTracks as $track) { if ($this->isTrackAlreadyScrobbled($track)) { continue; } $this->handleTrackScrobble($track); } } private function fetchRecentlyPlayed(): array { $client = new Client(); try { $response = $client->request('GET', "{$this->navidromeApiUrl}/api/song", [ 'query' => [ '_end' => 20, '_order' => 'DESC', '_sort' => 'play_date', '_start' => 0, 'recently_played' => 'true', ], 'headers' => [ 'x-nd-authorization' => "Bearer {$this->navidromeAuthToken}", 'Accept' => 'application/json', ], ]); $data = json_decode($response->getBody()->getContents(), true); return $data ?? []; } catch (\Exception $e) { error_log('Error fetching tracks: '.$e->getMessage()); return []; } } private function isTrackAlreadyScrobbled(array $track): bool { $playDateString = $track['playDate'] ?? null; if (! $playDateString) { return false; } $playDate = strtotime($playDateString); if ($playDate === false) { return false; } $existingListen = $this->fetchFromApi('listens', "listened_at=eq.{$playDate}&limit=1"); return ! empty($existingListen); } private function handleTrackScrobble(array $track): void { $artistData = $this->getOrCreateArtist($track['artist']); if (empty($artistData)) { error_log('Failed to retrieve or create artist: '.$track['artist']); return; } $albumData = $this->getOrCreateAlbum($track['album'], $artistData); if (empty($albumData)) { error_log('Failed to retrieve or create album: '.$track['album']); return; } $this->insertListen($track, $albumData['key']); } private function getOrCreateArtist(string $artistName): array { if (! $this->isDatabaseAvailable()) { return []; } if (isset($this->artistCache[$artistName])) { return $this->artistCache[$artistName]; } $encodedArtist = rawurlencode($artistName); $existingArtist = $this->fetchFromApi('artists', "name_string=eq.{$encodedArtist}&limit=1"); if (! empty($existingArtist)) { return $this->artistCache[$artistName] = $existingArtist[0]; } $response = $this->makeRequest('POST', 'artists', [ 'json' => [ 'mbid' => '', 'art' => '4cef75db-831f-4f5d-9333-79eaa5bb55ee', 'name_string' => $artistName, 'slug' => '/music', 'country' => '', 'description' => '', 'tentative' => true, 'favorite' => false, 'tattoo' => false, 'total_plays' => 0, ], 'headers' => ['Prefer' => 'return=representation'], ]); $inserted = $response[0] ?? null; if ($inserted) { $this->sendFailureEmail('New tentative artist record', "A new tentative artist record was inserted for: $artistName"); } return $this->artistCache[$artistName] = $inserted ?? []; } private function getOrCreateAlbum(string $albumName, array $artistData): array { if (! $this->isDatabaseAvailable()) { return []; } $albumKey = $this->generateAlbumKey($artistData['name_string'], $albumName); if (isset($this->albumCache[$albumKey])) { return $this->albumCache[$albumKey]; } $encodedAlbumKey = rawurlencode($albumKey); $existingAlbum = $this->fetchFromApi('albums', "key=eq.{$encodedAlbumKey}&limit=1"); if (! empty($existingAlbum)) { return $this->albumCache[$albumKey] = $existingAlbum[0]; } $artistId = $artistData['id'] ?? null; if (! $artistId) { error_log('Artist ID missing for album creation: '.$albumName); return []; } $response = $this->makeRequest('POST', 'albums', [ 'json' => [ 'mbid' => null, 'art' => '4cef75db-831f-4f5d-9333-79eaa5bb55ee', 'key' => $albumKey, 'name' => $albumName, 'tentative' => true, 'total_plays' => 0, 'artist' => $artistId, ], 'headers' => ['Prefer' => 'return=representation'], ]); $inserted = $response[0] ?? null; if ($inserted) { $this->sendFailureEmail('New tentative album record', "A new tentative album record was inserted:\n\nAlbum: $albumName\nKey: $albumKey"); } return $this->albumCache[$albumKey] = $inserted ?? []; } private function insertListen(array $track, string $albumKey): void { $payload = [ 'artist_name' => $track['artist'], 'album_name' => $track['album'], 'track_name' => $track['title'], 'album_key' => $albumKey, ]; if (! empty($track['playDate'])) { $playDate = strtotime($track['playDate']); if ($playDate !== false) { $payload['listened_at'] = $playDate; } } if (! isset($payload['listened_at'])) { error_log('Skipping track due to missing or invalid listened_at: '.json_encode($track)); return; } $this->makeRequest('POST', 'listens', ['json' => $payload]); } private function generateAlbumKey(string $artistName, string $albumName): string { $artistKey = sanitizeMediaString($artistName); $albumKey = sanitizeMediaString($albumName); return "{$artistKey}-{$albumKey}"; } private function sendFailureEmail(string $subject, string $message): void { if (! $this->isDatabaseAvailable()) { return; } $authHeader = 'Basic '.base64_encode($this->forwardEmailApiKey.':'); $client = new Client(['base_uri' => 'https://api.forwardemail.net/']); try { $client->post('v1/emails', [ 'headers' => [ 'Authorization' => $authHeader, 'Content-Type' => 'application/x-www-form-urlencoded', ], 'form_params' => [ 'from' => 'coryd.dev ', 'to' => 'hi@coryd.dev', 'subject' => $subject, 'text' => $message, ], ]); } catch (\GuzzleHttp\Exception\RequestException $e) { error_log('Request Exception: '.$e->getMessage()); if ($e->hasResponse()) { error_log('Error Response: '.(string) $e->getResponse()->getBody()); } } catch (\Exception $e) { error_log('General Exception: '.$e->getMessage()); } } private function isDatabaseAvailable(): bool { try { $response = $this->fetchFromApi('listens', 'limit=1'); return is_array($response); } catch (\Exception $e) { error_log('Database check failed: '.$e->getMessage()); return false; } } } try { $handler = new NavidromeScrobbleHandler(); $handler->runScrobbleCheck(); } catch (\Exception $e) { http_response_code(500); echo json_encode(['error' => $e->getMessage()]); }