coryd.dev/api/scrobble.php

312 lines
9 KiB
PHP

<?php
require_once __DIR__.'/../bootstrap.php';
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
header('Content-Type: application/json');
class NavidromeScrobbleHandler extends ApiHandler
{
private string $navidromeApiUrl;
private string $navidromeAuthToken;
private string $forwardEmailApiKey;
private array $artistCache = [];
private array $albumCache = [];
public function __construct()
{
parent::__construct();
$this->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 <hi@admin.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()]);
}