diff --git a/.prettierignore b/.prettierignore index 7325e23..5ac016d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,3 +13,6 @@ vendor/ # env .env + +# php +*.php diff --git a/api/artist-import.php b/api/artist-import.php index b03cdcb..3f083e9 100644 --- a/api/artist-import.php +++ b/api/artist-import.php @@ -1,187 +1,207 @@ ensureCliAccess(); - $this->artistImportToken = getenv("ARTIST_IMPORT_TOKEN"); - $this->navidromeApiUrl = getenv("NAVIDROME_API_URL"); - $this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN"); - } + private string $navidromeApiUrl; - public function handleRequest(): void - { - $input = json_decode(file_get_contents("php://input"), true); + private string $navidromeAuthToken; - if (!$input) $this->sendJsonResponse("error", "Invalid or missing JSON body", 400); + public function __construct() + { + parent::__construct(); - $providedToken = $input["token"] ?? null; - $artistId = $input["artistId"] ?? null; - - if ($providedToken !== $this->artistImportToken) $this->sendJsonResponse("error", "Unauthorized access", 401); - if (!$artistId) $this->sendJsonResponse("error", "Artist ID is required", 400); - - try { - $artistData = $this->fetchNavidromeArtist($artistId); - $albumData = $this->fetchNavidromeAlbums($artistId); - $genre = $albumData[0]["genre"] ?? ($albumData[0]["genres"][0]["name"] ?? ""); - $artistExists = $this->processArtist($artistData, $genre); - - if ($artistExists) $this->processAlbums($artistId, $artistData->name, $albumData); - - $this->sendJsonResponse("message", "Artist and albums synced successfully", 200); - } catch (\Exception $e) { - $this->sendJsonResponse("error", "Error: " . $e->getMessage(), 500); + $this->ensureCliAccess(); + $this->artistImportToken = getenv('ARTIST_IMPORT_TOKEN'); + $this->navidromeApiUrl = getenv('NAVIDROME_API_URL'); + $this->navidromeAuthToken = getenv('NAVIDROME_API_TOKEN'); } - } - private function sendJsonResponse(string $key, string $message, int $statusCode): void - { - http_response_code($statusCode); - header("Content-Type: application/json"); + public function handleRequest(): void + { + $input = json_decode(file_get_contents('php://input'), true); - echo json_encode([$key => $message]); + if (! $input) { + $this->sendJsonResponse('error', 'Invalid or missing JSON body', 400); + } - exit(); - } + $providedToken = $input['token'] ?? null; + $artistId = $input['artistId'] ?? null; - private function fetchNavidromeArtist(string $artistId): object - { - $client = new Client(); - $response = $client->get("{$this->navidromeApiUrl}/api/artist/{$artistId}", [ - "headers" => [ - "x-nd-authorization" => "Bearer {$this->navidromeAuthToken}", - "Accept" => "application/json" - ] - ]); + if ($providedToken !== $this->artistImportToken) { + $this->sendJsonResponse('error', 'Unauthorized access', 401); + } + if (! $artistId) { + $this->sendJsonResponse('error', 'Artist ID is required', 400); + } - return json_decode($response->getBody()); - } + try { + $artistData = $this->fetchNavidromeArtist($artistId); + $albumData = $this->fetchNavidromeAlbums($artistId); + $genre = $albumData[0]['genre'] ?? ($albumData[0]['genres'][0]['name'] ?? ''); + $artistExists = $this->processArtist($artistData, $genre); - private function fetchNavidromeAlbums(string $artistId): array - { - $client = new Client(); - $response = $client->get("{$this->navidromeApiUrl}/api/album", [ - "query" => [ - "_end" => 0, - "_order" => "ASC", - "_sort" => "max_year", - "_start" => 0, - "artist_id" => $artistId - ], - "headers" => [ - "x-nd-authorization" => "Bearer {$this->navidromeAuthToken}", - "Accept" => "application/json" - ] - ]); + if ($artistExists) { + $this->processAlbums($artistId, $artistData->name, $albumData); + } - return json_decode($response->getBody(), true); - } + $this->sendJsonResponse('message', 'Artist and albums synced successfully', 200); + } catch (\Exception $e) { + $this->sendJsonResponse('error', 'Error: '.$e->getMessage(), 500); + } + } - private function processArtist(object $artistData, string $genreName = ""): bool - { - $artistName = $artistData->name ?? ""; + private function sendJsonResponse(string $key, string $message, int $statusCode): void + { + http_response_code($statusCode); + header('Content-Type: application/json'); - if (!$artistName) throw new \Exception("Artist name is missing."); + echo json_encode([$key => $message]); - $existingArtist = $this->getArtistByName($artistName); + exit(); + } - if ($existingArtist) return true; + private function fetchNavidromeArtist(string $artistId): object + { + $client = new Client(); + $response = $client->get("{$this->navidromeApiUrl}/api/artist/{$artistId}", [ + 'headers' => [ + 'x-nd-authorization' => "Bearer {$this->navidromeAuthToken}", + 'Accept' => 'application/json', + ], + ]); - $artistKey = sanitizeMediaString($artistName); - $slug = "/music/artists/{$artistKey}"; - $description = strip_tags($artistData->biography ?? ""); - $genre = $this->resolveGenreId(strtolower($genreName)); - $starred = $artistData->starred ?? false; - $artistPayload = [ - "name_string" => $artistName, - "slug" => $slug, - "description" => $description, - "tentative" => true, - "art" => $this->placeholderImageId, - "mbid" => "", - "favorite" => $starred, - "genres" => $genre, - ]; + return json_decode($response->getBody()); + } - $this->makeRequest("POST", "artists", ["json" => $artistPayload]); + private function fetchNavidromeAlbums(string $artistId): array + { + $client = new Client(); + $response = $client->get("{$this->navidromeApiUrl}/api/album", [ + 'query' => [ + '_end' => 0, + '_order' => 'ASC', + '_sort' => 'max_year', + '_start' => 0, + 'artist_id' => $artistId, + ], + 'headers' => [ + 'x-nd-authorization' => "Bearer {$this->navidromeAuthToken}", + 'Accept' => 'application/json', + ], + ]); - return true; - } + return json_decode($response->getBody(), true); + } - private function processAlbums(string $artistId, string $artistName, array $albumData): void - { - $artist = $this->getArtistByName($artistName); + private function processArtist(object $artistData, string $genreName = ''): bool + { + $artistName = $artistData->name ?? ''; - if (!$artist) throw new \Exception("Artist not found after insert."); + if (! $artistName) { + throw new \Exception('Artist name is missing.'); + } - $existingAlbums = $this->getExistingAlbums($artist["id"]); - $existingAlbumKeys = array_column($existingAlbums, "key"); + $existingArtist = $this->getArtistByName($artistName); - foreach ($albumData as $album) { - $albumName = $album["name"] ?? ""; - $releaseYearRaw = $album["date"] ?? null; - $releaseYear = null; + if ($existingArtist) { + return true; + } - if ($releaseYearRaw && preg_match('/^\d{4}/', $releaseYearRaw, $matches)) $releaseYear = (int)$matches[0]; - - $artistKey = sanitizeMediaString($artistName); - $albumKey = "{$artistKey}-" . sanitizeMediaString($albumName); - - if (in_array($albumKey, $existingAlbumKeys)) { - error_log("Skipping existing album: {$albumName}"); - continue; - } - - try { - $albumPayload = [ - "name" => $albumName, - "key" => $albumKey, - "release_year" => $releaseYear, - "artist" => $artist["id"], - "artist_name" => $artistName, - "art" => $this->placeholderImageId, - "tentative" => true, + $artistKey = sanitizeMediaString($artistName); + $slug = "/music/artists/{$artistKey}"; + $description = strip_tags($artistData->biography ?? ''); + $genre = $this->resolveGenreId(strtolower($genreName)); + $starred = $artistData->starred ?? false; + $artistPayload = [ + 'name_string' => $artistName, + 'slug' => $slug, + 'description' => $description, + 'tentative' => true, + 'art' => $this->placeholderImageId, + 'mbid' => '', + 'favorite' => $starred, + 'genres' => $genre, ]; - $this->makeRequest("POST", "albums", ["json" => $albumPayload]); - } catch (\Exception $e) { - error_log("Error adding album '{$albumName}': " . $e->getMessage()); - } + $this->makeRequest('POST', 'artists', ['json' => $artistPayload]); + + return true; } - } - private function getArtistByName(string $nameString): ?array - { - $response = $this->fetchFromApi("artists", "name_string=eq." . urlencode($nameString)); + private function processAlbums(string $artistId, string $artistName, array $albumData): void + { + $artist = $this->getArtistByName($artistName); - return $response[0] ?? null; - } + if (! $artist) { + throw new \Exception('Artist not found after insert.'); + } - private function getExistingAlbums(string $artistId): array - { - return $this->fetchFromApi("albums", "artist=eq." . urlencode($artistId)); - } + $existingAlbums = $this->getExistingAlbums($artist['id']); + $existingAlbumKeys = array_column($existingAlbums, 'key'); - private function resolveGenreId(string $genreName): ?string - { - $genres = $this->fetchFromApi("genres", "name=eq." . urlencode(strtolower($genreName))); + foreach ($albumData as $album) { + $albumName = $album['name'] ?? ''; + $releaseYearRaw = $album['date'] ?? null; + $releaseYear = null; - return $genres[0]["id"] ?? null; - } + if ($releaseYearRaw && preg_match('/^\d{4}/', $releaseYearRaw, $matches)) { + $releaseYear = (int) $matches[0]; + } + + $artistKey = sanitizeMediaString($artistName); + $albumKey = "{$artistKey}-".sanitizeMediaString($albumName); + + if (in_array($albumKey, $existingAlbumKeys)) { + error_log("Skipping existing album: {$albumName}"); + + continue; + } + + try { + $albumPayload = [ + 'name' => $albumName, + 'key' => $albumKey, + 'release_year' => $releaseYear, + 'artist' => $artist['id'], + 'artist_name' => $artistName, + 'art' => $this->placeholderImageId, + 'tentative' => true, + ]; + + $this->makeRequest('POST', 'albums', ['json' => $albumPayload]); + } catch (\Exception $e) { + error_log("Error adding album '{$albumName}': ".$e->getMessage()); + } + } + } + + private function getArtistByName(string $nameString): ?array + { + $response = $this->fetchFromApi('artists', 'name_string=eq.'.urlencode($nameString)); + + return $response[0] ?? null; + } + + private function getExistingAlbums(string $artistId): array + { + return $this->fetchFromApi('albums', 'artist=eq.'.urlencode($artistId)); + } + + private function resolveGenreId(string $genreName): ?string + { + $genres = $this->fetchFromApi('genres', 'name=eq.'.urlencode(strtolower($genreName))); + + return $genres[0]['id'] ?? null; + } } $handler = new ArtistImportHandler(); diff --git a/api/book-import.php b/api/book-import.php index 577b9d3..617ef09 100644 --- a/api/book-import.php +++ b/api/book-import.php @@ -1,96 +1,108 @@ ensureCliAccess(); - $this->bookImportToken = $_ENV["BOOK_IMPORT_TOKEN"] ?? getenv("BOOK_IMPORT_TOKEN"); - } - - public function handleRequest(): void - { - $input = json_decode(file_get_contents("php://input"), true); - - if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400); - - $providedToken = $input["token"] ?? null; - $isbn = $input["isbn"] ?? null; - - if ($providedToken !== $this->bookImportToken) $this->sendErrorResponse("Unauthorized access", 401); - if (!$isbn) $this->sendErrorResponse("isbn parameter is required", 400); - - try { - $bookData = $this->fetchBookData($isbn); - $this->processBook($bookData); - $this->sendResponse(["message" => "Book imported successfully"], 200); - } catch (\Exception $e) { - $this->sendErrorResponse("Error: " . $e->getMessage(), 500); + $this->ensureCliAccess(); + $this->bookImportToken = $_ENV['BOOK_IMPORT_TOKEN'] ?? getenv('BOOK_IMPORT_TOKEN'); } - } - private function fetchBookData(string $isbn): array - { - $client = new Client(); - $response = $client->get("https://openlibrary.org/api/books", [ - "query" => [ - "bibkeys" => "ISBN:{$isbn}", - "format" => "json", - "jscmd" => "data", - ], - "headers" => ["Accept" => "application/json"], - ]); + public function handleRequest(): void + { + $input = json_decode(file_get_contents('php://input'), true); - $data = json_decode($response->getBody(), true); - $bookKey = "ISBN:{$isbn}"; + if (! $input) { + $this->sendErrorResponse('Invalid or missing JSON body', 400); + } - if (empty($data[$bookKey])) throw new \Exception("Book data not found for ISBN: {$isbn}"); + $providedToken = $input['token'] ?? null; + $isbn = $input['isbn'] ?? null; - return $data[$bookKey]; - } + if ($providedToken !== $this->bookImportToken) { + $this->sendErrorResponse('Unauthorized access', 401); + } + if (! $isbn) { + $this->sendErrorResponse('isbn parameter is required', 400); + } - private function processBook(array $bookData): void - { - $isbn = - $bookData["identifiers"]["isbn_13"][0] ?? - ($bookData["identifiers"]["isbn_10"][0] ?? null); - $title = $bookData["title"] ?? null; - $author = $bookData["authors"][0]["name"] ?? null; - $description = $bookData["description"] ?? ($bookData["notes"] ?? ""); + try { + $bookData = $this->fetchBookData($isbn); + $this->processBook($bookData); + $this->sendResponse(['message' => 'Book imported successfully'], 200); + } catch (\Exception $e) { + $this->sendErrorResponse('Error: '.$e->getMessage(), 500); + } + } - if (!$isbn || !$title || !$author) throw new \Exception("Missing essential book data (title, author, or ISBN)."); + private function fetchBookData(string $isbn): array + { + $client = new Client(); + $response = $client->get('https://openlibrary.org/api/books', [ + 'query' => [ + 'bibkeys' => "ISBN:{$isbn}", + 'format' => 'json', + 'jscmd' => 'data', + ], + 'headers' => ['Accept' => 'application/json'], + ]); - $existingBook = $this->getBookByISBN($isbn); + $data = json_decode($response->getBody(), true); + $bookKey = "ISBN:{$isbn}"; - if ($existingBook) throw new \Exception("Book with ISBN {$isbn} already exists."); + if (empty($data[$bookKey])) { + throw new \Exception("Book data not found for ISBN: {$isbn}"); + } - $bookPayload = [ - "isbn" => $isbn, - "title" => $title, - "author" => $author, - "description" => $description, - "read_status" => "want to read", - "slug" => "/reading/books/" . $isbn, - ]; + return $data[$bookKey]; + } - $this->makeRequest("POST", "books", ["json" => $bookPayload]); - } + private function processBook(array $bookData): void + { + $isbn = + $bookData['identifiers']['isbn_13'][0] ?? + ($bookData['identifiers']['isbn_10'][0] ?? null); + $title = $bookData['title'] ?? null; + $author = $bookData['authors'][0]['name'] ?? null; + $description = $bookData['description'] ?? ($bookData['notes'] ?? ''); - private function getBookByISBN(string $isbn): ?array - { - $response = $this->fetchFromApi("books", "isbn=eq." . urlencode($isbn)); + if (! $isbn || ! $title || ! $author) { + throw new \Exception('Missing essential book data (title, author, or ISBN).'); + } - return $response[0] ?? null; - } + $existingBook = $this->getBookByISBN($isbn); + + if ($existingBook) { + throw new \Exception("Book with ISBN {$isbn} already exists."); + } + + $bookPayload = [ + 'isbn' => $isbn, + 'title' => $title, + 'author' => $author, + 'description' => $description, + 'read_status' => 'want to read', + 'slug' => '/reading/books/'.$isbn, + ]; + + $this->makeRequest('POST', 'books', ['json' => $bookPayload]); + } + + private function getBookByISBN(string $isbn): ?array + { + $response = $this->fetchFromApi('books', 'isbn=eq.'.urlencode($isbn)); + + return $response[0] ?? null; + } } $handler = new BookImportHandler(); diff --git a/api/contact.php b/api/contact.php index 54438b1..21d28f2 100644 --- a/api/contact.php +++ b/api/contact.php @@ -1,214 +1,241 @@ httpClient = $httpClient ?? new Client(); - $this->forwardEmailApiKey = $_ENV["FORWARDEMAIL_API_KEY"] ?? getenv("FORWARDEMAIL_API_KEY"); - } + private string $forwardEmailApiKey; - public function handleRequest(): void - { - try { - $this->validateReferer(); - $this->checkRateLimit(); - $this->enforceHttps(); + private Client $httpClient; - $contentType = $_SERVER["CONTENT_TYPE"] ?? ""; - $formData = null; + public function __construct(?Client $httpClient = null) + { + parent::__construct(); - if (strpos($contentType, "application/json") !== false) { - $rawBody = file_get_contents("php://input"); - $formData = json_decode($rawBody, true); - - if (!$formData || !isset($formData["data"])) throw new \Exception("Invalid JSON payload."); - - $formData = $formData["data"]; - } elseif ( - strpos($contentType, "application/x-www-form-urlencoded") !== false - ) { - $formData = $_POST; - } else { - $this->sendErrorResponse("Unsupported Content-Type. Use application/json or application/x-www-form-urlencoded.", 400); - } - - if (!empty($formData["hp_name"])) $this->sendErrorResponse("Invalid submission.", 400); - - $name = htmlspecialchars( - trim($formData["name"] ?? ""), - ENT_QUOTES, - "UTF-8" - ); - $email = filter_var($formData["email"] ?? "", FILTER_VALIDATE_EMAIL); - $message = htmlspecialchars( - trim($formData["message"] ?? ""), - ENT_QUOTES, - "UTF-8" - ); - - if (empty($name)) $this->sendErrorResponse("Name is required.", 400); - if (!$email) $this->sendErrorResponse("Valid email is required.", 400); - if (empty($message)) $this->sendErrorResponse("Message is required.", 400); - if (strlen($name) > 100) $this->sendErrorResponse("Name is too long. Max 100 characters allowed.", 400); - if (strlen($message) > 1000) $this->sendErrorResponse("Message is too long. Max 1000 characters allowed.", 400); - if ($this->isBlockedDomain($email)) $this->sendErrorResponse("Submission from blocked domain.", 400); - - $contactData = [ - "name" => $name, - "email" => $email, - "message" => $message, - "replied" => false, - ]; - - $this->saveToDatabase($contactData); - $this->sendNotificationEmail($contactData); - $this->sendRedirect("/contact/success"); - } catch (\Exception $e) { - error_log("Error handling contact form submission: " . $e->getMessage()); - - $this->sendErrorResponse($e->getMessage(), 400); + $this->httpClient = $httpClient ?? new Client(); + $this->forwardEmailApiKey = $_ENV['FORWARDEMAIL_API_KEY'] ?? getenv('FORWARDEMAIL_API_KEY'); } - } - private function validateReferer(): void - { - $referer = $_SERVER["HTTP_REFERER"] ?? ""; - $allowedDomain = "coryd.dev"; + public function handleRequest(): void + { + try { + $this->validateReferer(); + $this->checkRateLimit(); + $this->enforceHttps(); - if (!str_contains($referer, $allowedDomain)) throw new \Exception("Invalid submission origin."); - } + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + $formData = null; - private function checkRateLimit(): void - { - $ipAddress = $_SERVER["REMOTE_ADDR"] ?? "unknown"; - $cacheFile = sys_get_temp_dir() . "/rate_limit_" . md5($ipAddress); - $rateLimitDuration = 60; - $maxRequests = 5; + if (strpos($contentType, 'application/json') !== false) { + $rawBody = file_get_contents('php://input'); + $formData = json_decode($rawBody, true); - if (file_exists($cacheFile)) { - $data = json_decode(file_get_contents($cacheFile), true); + if (! $formData || ! isset($formData['data'])) { + throw new \Exception('Invalid JSON payload.'); + } + + $formData = $formData['data']; + } elseif ( + strpos($contentType, 'application/x-www-form-urlencoded') !== false + ) { + $formData = $_POST; + } else { + $this->sendErrorResponse('Unsupported Content-Type. Use application/json or application/x-www-form-urlencoded.', 400); + } + + if (! empty($formData['hp_name'])) { + $this->sendErrorResponse('Invalid submission.', 400); + } + + $name = htmlspecialchars( + trim($formData['name'] ?? ''), + ENT_QUOTES, + 'UTF-8' + ); + $email = filter_var($formData['email'] ?? '', FILTER_VALIDATE_EMAIL); + $message = htmlspecialchars( + trim($formData['message'] ?? ''), + ENT_QUOTES, + 'UTF-8' + ); + + if (empty($name)) { + $this->sendErrorResponse('Name is required.', 400); + } + if (! $email) { + $this->sendErrorResponse('Valid email is required.', 400); + } + if (empty($message)) { + $this->sendErrorResponse('Message is required.', 400); + } + if (strlen($name) > 100) { + $this->sendErrorResponse('Name is too long. Max 100 characters allowed.', 400); + } + if (strlen($message) > 1000) { + $this->sendErrorResponse('Message is too long. Max 1000 characters allowed.', 400); + } + if ($this->isBlockedDomain($email)) { + $this->sendErrorResponse('Submission from blocked domain.', 400); + } + + $contactData = [ + 'name' => $name, + 'email' => $email, + 'message' => $message, + 'replied' => false, + ]; + + $this->saveToDatabase($contactData); + $this->sendNotificationEmail($contactData); + $this->sendRedirect('/contact/success'); + } catch (\Exception $e) { + error_log('Error handling contact form submission: '.$e->getMessage()); + + $this->sendErrorResponse($e->getMessage(), 400); + } + } + + private function validateReferer(): void + { + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + $allowedDomain = 'coryd.dev'; + + if (! str_contains($referer, $allowedDomain)) { + throw new \Exception('Invalid submission origin.'); + } + } + + private function checkRateLimit(): void + { + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $cacheFile = sys_get_temp_dir().'/rate_limit_'.md5($ipAddress); + $rateLimitDuration = 60; + $maxRequests = 5; + + if (file_exists($cacheFile)) { + $data = json_decode(file_get_contents($cacheFile), true); + + if (time() < $data['timestamp'] + $rateLimitDuration && $data['count'] >= $maxRequests) { + header('Location: /429', true, 302); + exit(); + } + + $data['count']++; + } else { + $data = ['count' => 1, 'timestamp' => time()]; + } + + file_put_contents($cacheFile, json_encode($data)); + } + + private function enforceHttps(): void + { + if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') { + throw new \Exception('Secure connection required. Use HTTPS.'); + } + } + + private function isBlockedDomain(string $email): bool + { + $domain = substr(strrchr($email, '@'), 1); + + if (! $domain) { + return false; + } + + $response = $this->httpClient->get( + "{$this->postgrestUrl}/blocked_domains", + [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer {$this->postgrestApiKey}", + ], + 'query' => [ + 'domain_name' => "eq.{$domain}", + 'limit' => 1, + ], + ] + ); + $blockedDomains = json_decode($response->getBody(), true); + + return ! empty($blockedDomains); + } + + private function saveToDatabase(array $contactData): void + { + $response = $this->httpClient->post("{$this->postgrestUrl}/contacts", [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer {$this->postgrestApiKey}", + ], + 'json' => $contactData, + ]); + + if ($response->getStatusCode() >= 400) { + $errorResponse = json_decode($response->getBody(), true); + + throw new \Exception('PostgREST error: '.($errorResponse['message'] ?? 'Unknown error')); + } + } + + private function sendNotificationEmail(array $contactData): void + { + $authHeader = 'Basic '.base64_encode("{$this->forwardEmailApiKey}:"); + $emailSubject = 'Contact form submission'; + $emailText = sprintf( + "Name: %s\nEmail: %s\nMessage: %s\n", + $contactData['name'], + $contactData['email'], + $contactData['message'] + ); + $response = $this->httpClient->post( + 'https://api.forwardemail.net/v1/emails', + [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Authorization' => $authHeader, + ], + 'form_params' => [ + 'from' => 'coryd.dev ', + 'to' => 'hi@coryd.dev', + 'subject' => $emailSubject, + 'text' => $emailText, + 'replyTo' => $contactData['email'], + ], + ] + ); + + if ($response->getStatusCode() >= 400) { + throw new \Exception('Failed to send email notification.'); + } + } + + private function sendRedirect(string $path): void + { + $protocol = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST']; + $redirectUrl = "{$protocol}://{$host}{$path}"; + + header("Location: $redirectUrl", true, 302); - if ($data["timestamp"] + $rateLimitDuration > time() && $data["count"] >= $maxRequests) { - header("Location: /429", true, 302); exit(); - } - - $data["count"]++; - } else { - $data = ["count" => 1, "timestamp" => time()]; } - - file_put_contents($cacheFile, json_encode($data)); - } - - private function enforceHttps(): void - { - if (empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] !== "on") throw new \Exception("Secure connection required. Use HTTPS."); - } - - private function isBlockedDomain(string $email): bool - { - $domain = substr(strrchr($email, "@"), 1); - - if (!$domain) return false; - - $response = $this->httpClient->get( - "{$this->postgrestUrl}/blocked_domains", - [ - "headers" => [ - "Content-Type" => "application/json", - "Authorization" => "Bearer {$this->postgrestApiKey}", - ], - "query" => [ - "domain_name" => "eq.{$domain}", - "limit" => 1, - ], - ] - ); - $blockedDomains = json_decode($response->getBody(), true); - - return !empty($blockedDomains); - } - - private function saveToDatabase(array $contactData): void - { - $response = $this->httpClient->post("{$this->postgrestUrl}/contacts", [ - "headers" => [ - "Content-Type" => "application/json", - "Authorization" => "Bearer {$this->postgrestApiKey}", - ], - "json" => $contactData, - ]); - - if ($response->getStatusCode() >= 400) { - $errorResponse = json_decode($response->getBody(), true); - - throw new \Exception("PostgREST error: " . ($errorResponse["message"] ?? "Unknown error")); - } - } - - private function sendNotificationEmail(array $contactData): void - { - $authHeader = "Basic " . base64_encode("{$this->forwardEmailApiKey}:"); - $emailSubject = "Contact form submission"; - $emailText = sprintf( - "Name: %s\nEmail: %s\nMessage: %s\n", - $contactData["name"], - $contactData["email"], - $contactData["message"] - ); - $response = $this->httpClient->post( - "https://api.forwardemail.net/v1/emails", - [ - "headers" => [ - "Content-Type" => "application/x-www-form-urlencoded", - "Authorization" => $authHeader, - ], - "form_params" => [ - "from" => "coryd.dev ", - "to" => "hi@coryd.dev", - "subject" => $emailSubject, - "text" => $emailText, - "replyTo" => $contactData["email"], - ], - ] - ); - - if ($response->getStatusCode() >= 400) throw new \Exception("Failed to send email notification."); - } - - private function sendRedirect(string $path): void - { - $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http"; - $host = $_SERVER['HTTP_HOST']; - $redirectUrl = "{$protocol}://{$host}{$path}"; - - header("Location: $redirectUrl", true, 302); - - exit(); - } } try { - $handler = new ContactHandler(); - $handler->handleRequest(); + $handler = new ContactHandler(); + $handler->handleRequest(); } catch (\Exception $e) { - error_log("Contact form error: " . $e->getMessage()); + error_log('Contact form error: '.$e->getMessage()); - echo json_encode(["error" => $e->getMessage()]); + echo json_encode(['error' => $e->getMessage()]); - http_response_code(500); + http_response_code(500); } diff --git a/api/mastodon.php b/api/mastodon.php index 9337897..92657fb 100644 --- a/api/mastodon.php +++ b/api/mastodon.php @@ -1,174 +1,187 @@ ensureCliAccess(); - $this->mastodonAccessToken = getenv("MASTODON_ACCESS_TOKEN") ?: $_ENV["MASTODON_ACCESS_TOKEN"] ?? ""; - $this->httpClient = $httpClient ?: new Client(); - $this->validateAuthorization(); - } + private string $baseUrl = 'https://www.coryd.dev'; - private function validateAuthorization(): void - { - $authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? ""; - $expectedToken = "Bearer " . getenv("MASTODON_SYNDICATION_TOKEN"); + private const MASTODON_API_STATUS = 'https://follow.coryd.dev/api/v1/statuses'; - if ($authHeader !== $expectedToken) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit(); - } - } + private Client $httpClient; - public function handlePost(): void - { - if (!$this->isDatabaseAvailable()) { - echo "Database is unavailable. Exiting.\n"; - return; + public function __construct(?Client $httpClient = null) + { + parent::__construct(); + + $this->ensureCliAccess(); + $this->mastodonAccessToken = getenv('MASTODON_ACCESS_TOKEN') ?: $_ENV['MASTODON_ACCESS_TOKEN'] ?? ''; + $this->httpClient = $httpClient ?: new Client(); + $this->validateAuthorization(); } - $latestItems = $this->fetchRSSFeed($this->rssFeedUrl); + private function validateAuthorization(): void + { + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + $expectedToken = 'Bearer '.getenv('MASTODON_SYNDICATION_TOKEN'); - foreach (array_reverse($latestItems) as $item) { - $existing = $this->fetchFromApi("mastodon_posts", "link=eq." . urlencode($item["link"])); - - if (!empty($existing)) continue; - - $content = $this->truncateContent( - $item["title"], - strip_tags($item["description"]), - $item["link"], - 500 - ); - $timestamp = date("Y-m-d H:i:s"); - - if (!$this->storeInDatabase($item["link"], $timestamp)) { - echo "Skipping post: database write failed for {$item["link"]}\n"; - continue; - } - - $postedUrl = $this->postToMastodon($content, $item["image"] ?? null); - - if ($postedUrl) { - echo "Posted: {$postedUrl}\n"; - } else { - echo "Failed to post to Mastodon for: {$item["link"]}\n"; - } + if ($authHeader !== $expectedToken) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); + exit(); + } } - echo "RSS processed successfully.\n"; - } + public function handlePost(): void + { + if (! $this->isDatabaseAvailable()) { + echo "Database is unavailable. Exiting.\n"; - private function fetchRSSFeed(string $rssFeedUrl): array - { - $rssText = file_get_contents($rssFeedUrl); + return; + } - if (!$rssText) throw new \Exception("Failed to fetch RSS feed."); + $latestItems = $this->fetchRSSFeed($this->rssFeedUrl); - $rss = new \SimpleXMLElement($rssText); - $items = []; + foreach (array_reverse($latestItems) as $item) { + $existing = $this->fetchFromApi('mastodon_posts', 'link=eq.'.urlencode($item['link'])); - foreach ($rss->channel->item as $item) { - $items[] = [ - "title" => $this->cleanText((string) $item->title), - "link" => (string) $item->link, - "description" => $this->cleanText((string) $item->description), - ]; + if (! empty($existing)) { + continue; + } + + $content = $this->truncateContent( + $item['title'], + strip_tags($item['description']), + $item['link'], + 500 + ); + $timestamp = date('Y-m-d H:i:s'); + + if (! $this->storeInDatabase($item['link'], $timestamp)) { + echo "Skipping post: database write failed for {$item['link']}\n"; + + continue; + } + + $postedUrl = $this->postToMastodon($content, $item['image'] ?? null); + + if ($postedUrl) { + echo "Posted: {$postedUrl}\n"; + } else { + echo "Failed to post to Mastodon for: {$item['link']}\n"; + } + } + + echo "RSS processed successfully.\n"; } - return $items; - } + private function fetchRSSFeed(string $rssFeedUrl): array + { + $rssText = file_get_contents($rssFeedUrl); - private function cleanText(string $text): string - { - $decoded = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8'); - return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8'); - } + if (! $rssText) { + throw new \Exception('Failed to fetch RSS feed.'); + } - private function postToMastodon(string $content): ?string - { - $headers = [ - "Authorization" => "Bearer {$this->mastodonAccessToken}", - "Content-Type" => "application/json", - ]; - $postData = ["status" => $content]; - $response = $this->httpClient->request("POST", self::MASTODON_API_STATUS, [ - "headers" => $headers, - "json" => $postData - ]); + $rss = new \SimpleXMLElement($rssText); + $items = []; - if ($response->getStatusCode() >= 400) throw new \Exception("Mastodon post failed: {$response->getBody()}"); + foreach ($rss->channel->item as $item) { + $items[] = [ + 'title' => $this->cleanText((string) $item->title), + 'link' => (string) $item->link, + 'description' => $this->cleanText((string) $item->description), + ]; + } - $body = json_decode($response->getBody()->getContents(), true); - - return $body["url"] ?? null; - } - - private function storeInDatabase(string $link, string $timestamp): bool - { - try { - $this->makeRequest("POST", "mastodon_posts", [ - "json" => [ - "link" => $link, - "created_at" => $timestamp - ] - ]); - - return true; - } catch (\Exception $e) { - echo "Error storing post in DB: " . $e->getMessage() . "\n"; - - return false; - } - } - - private function isDatabaseAvailable(): bool - { - try { - $response = $this->fetchFromApi("mastodon_posts", "limit=1"); - - return is_array($response); - } catch (\Exception $e) { - echo "Database check failed: " . $e->getMessage() . "\n"; - - return false; - } - } - - private function truncateContent(string $title, string $description, string $link, int $maxLength): string - { - $baseLength = strlen("$title\n\n$link"); - $available = $maxLength - $baseLength - 4; - - if (strlen($description) > $available) { - $description = substr($description, 0, $available); - $description = preg_replace('/\s+\S*$/', "", $description) . "..."; + return $items; } - return "$title\n\n$description\n\n$link"; - } + private function cleanText(string $text): string + { + $decoded = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8'); + + return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8'); + } + + private function postToMastodon(string $content): ?string + { + $headers = [ + 'Authorization' => "Bearer {$this->mastodonAccessToken}", + 'Content-Type' => 'application/json', + ]; + $postData = ['status' => $content]; + $response = $this->httpClient->request('POST', self::MASTODON_API_STATUS, [ + 'headers' => $headers, + 'json' => $postData, + ]); + + if ($response->getStatusCode() >= 400) { + throw new \Exception("Mastodon post failed: {$response->getBody()}"); + } + + $body = json_decode($response->getBody()->getContents(), true); + + return $body['url'] ?? null; + } + + private function storeInDatabase(string $link, string $timestamp): bool + { + try { + $this->makeRequest('POST', 'mastodon_posts', [ + 'json' => [ + 'link' => $link, + 'created_at' => $timestamp, + ], + ]); + + return true; + } catch (\Exception $e) { + echo 'Error storing post in DB: '.$e->getMessage()."\n"; + + return false; + } + } + + private function isDatabaseAvailable(): bool + { + try { + $response = $this->fetchFromApi('mastodon_posts', 'limit=1'); + + return is_array($response); + } catch (\Exception $e) { + echo 'Database check failed: '.$e->getMessage()."\n"; + + return false; + } + } + + private function truncateContent(string $title, string $description, string $link, int $maxLength): string + { + $baseLength = strlen("$title\n\n$link"); + $available = $maxLength - $baseLength - 4; + + if (strlen($description) > $available) { + $description = substr($description, 0, $available); + $description = preg_replace('/\s+\S*$/', '', $description).'...'; + } + + return "$title\n\n$description\n\n$link"; + } } try { - $handler = new MastodonPostHandler(); - $handler->handlePost(); + $handler = new MastodonPostHandler(); + $handler->handlePost(); } catch (\Exception $e) { - http_response_code(500); + http_response_code(500); - echo json_encode(["error" => $e->getMessage()]); + echo json_encode(['error' => $e->getMessage()]); } diff --git a/api/oembed.php b/api/oembed.php index 21b1187..8c06a08 100644 --- a/api/oembed.php +++ b/api/oembed.php @@ -1,111 +1,124 @@ fetchGlobals(); - $parsed = $requestUrl ? parse_url($requestUrl) : null; - $relativePath = $parsed['path'] ?? null; + public function handleRequest(): void + { + $requestUrl = $_GET['url'] ?? null; + $globals = $this->fetchGlobals(); + $parsed = $requestUrl ? parse_url($requestUrl) : null; + $relativePath = $parsed['path'] ?? null; - if (!$requestUrl || $relativePath === '/') $this->sendResponse($this->buildResponse( - $globals['site_name'], - $globals['url'], - $globals['metadata']['open_graph_image'], - $globals, - $globals['site_description'] - )); + if (! $requestUrl || $relativePath === '/') { + $this->sendResponse($this->buildResponse( + $globals['site_name'], + $globals['url'], + $globals['metadata']['open_graph_image'], + $globals, + $globals['site_description'] + )); + } - if (!$relativePath) $this->sendErrorResponse('Invalid url', 400); + if (! $relativePath) { + $this->sendErrorResponse('Invalid url', 400); + } - $relativePath = '/' . ltrim($relativePath ?? '', '/'); + $relativePath = '/'.ltrim($relativePath ?? '', '/'); - if ($relativePath !== '/' && str_ends_with($relativePath, '/')) $relativePath = rtrim($relativePath, '/'); + if ($relativePath !== '/' && str_ends_with($relativePath, '/')) { + $relativePath = rtrim($relativePath, '/'); + } - $cacheKey = 'oembed:' . md5($relativePath); + $cacheKey = 'oembed:'.md5($relativePath); - if ($this->cache && $this->cache->exists($cacheKey)) { - $cachedItem = json_decode($this->cache->get($cacheKey), true); + if ($this->cache && $this->cache->exists($cacheKey)) { + $cachedItem = json_decode($this->cache->get($cacheKey), true); - $this->sendResponse($this->buildResponse( - $cachedItem['title'], - $cachedItem['url'], - $cachedItem['image_url'], - $globals, - $cachedItem['description'] ?? '' - )); + $this->sendResponse($this->buildResponse( + $cachedItem['title'], + $cachedItem['url'], + $cachedItem['image_url'], + $globals, + $cachedItem['description'] ?? '' + )); + } + + $results = $this->fetchFromApi('optimized_oembed', 'url=eq.'.urlencode($relativePath)); + + if (! empty($results)) { + $item = $results[0]; + + if ($this->cache) { + $this->cache->setex($cacheKey, 300, json_encode($item)); + } + + $this->sendResponse($this->buildResponse( + $item['title'], + $item['url'], + $item['image_url'], + $globals, + $item['description'] ?? '' + )); + } + + $segments = explode('/', trim($relativePath, '/')); + + if (count($segments) === 1 && $segments[0] !== '') { + $title = ucwords(str_replace('-', ' ', $segments[0])).' • '.$globals['author']; + + $this->sendResponse($this->buildResponse( + $title, + $relativePath, + $globals['metadata']['open_graph_image'], + $globals + )); + } + + $this->sendErrorResponse('No match found', 404); } - $results = $this->fetchFromApi('optimized_oembed', 'url=eq.' . urlencode($relativePath)); + private function buildResponse(string $title, string $url, string $imagePath, array $globals, string $description = ''): array + { + $safeDescription = truncateText(strip_tags(parseMarkdown($description)), 175); + $html = '

'.htmlspecialchars($title).'

'; - if (!empty($results)) { - $item = $results[0]; + if ($description) { + $html .= '

'.htmlspecialchars($safeDescription, ENT_QUOTES, 'UTF-8').'

'; + } - if ($this->cache) $this->cache->setex($cacheKey, 300, json_encode($item)); - - $this->sendResponse($this->buildResponse( - $item['title'], - $item['url'], - $item['image_url'], - $globals, - $item['description'] ?? '' - )); + return [ + 'version' => '1.0', + 'type' => 'link', + 'title' => $title, + 'author_name' => $globals['author'], + 'provider_name' => $globals['site_name'], + 'provider_url' => $globals['url'], + 'thumbnail_url' => $globals['url'].'/og/w800'.$imagePath, + 'html' => $html ?? $globals['site_description'], + 'description' => $safeDescription ?? $globals['site_description'], + ]; } - $segments = explode('/', trim($relativePath, '/')); + private function fetchGlobals(): array + { + $cacheKey = 'globals_data'; - if (count($segments) === 1 && $segments[0] !== '') { - $title = ucwords(str_replace('-', ' ', $segments[0])) . ' • ' . $globals['author']; + if ($this->cache && $this->cache->exists($cacheKey)) { + return json_decode($this->cache->get($cacheKey), true); + } - $this->sendResponse($this->buildResponse( - $title, - $relativePath, - $globals['metadata']['open_graph_image'], - $globals - )); + $globals = $this->fetchFromApi('optimized_globals', 'limit=1')[0]; + + if ($this->cache) { + $this->cache->setex($cacheKey, 3600, json_encode($globals)); + } + + return $globals; } - - $this->sendErrorResponse('No match found', 404); - } - - private function buildResponse(string $title, string $url, string $imagePath, array $globals, string $description = ''): array - { - $safeDescription = truncateText(strip_tags(parseMarkdown($description)), 175); - $html = '

' . htmlspecialchars($title) . '

'; - - if ($description) $html .= '

' . htmlspecialchars($safeDescription, ENT_QUOTES, 'UTF-8') . '

'; - - return [ - 'version' => '1.0', - 'type' => 'link', - 'title' => $title, - 'author_name' => $globals['author'], - 'provider_name' => $globals['site_name'], - 'provider_url' => $globals['url'], - 'thumbnail_url' => $globals['url'] . '/og/w800' . $imagePath, - 'html' => $html ?? $globals['site_description'], - 'description' => $safeDescription ?? $globals['site_description'], - ]; - } - - private function fetchGlobals(): array - { - $cacheKey = 'globals_data'; - - if ($this->cache && $this->cache->exists($cacheKey)) return json_decode($this->cache->get($cacheKey), true); - - $globals = $this->fetchFromApi('optimized_globals', 'limit=1')[0]; - - if ($this->cache) $this->cache->setex($cacheKey, 3600, json_encode($globals)); - - return $globals; - } } $handler = new OembedHandler(); diff --git a/api/og-image.php b/api/og-image.php index c1c51eb..28cb817 100644 --- a/api/og-image.php +++ b/api/og-image.php @@ -1,30 +1,34 @@ ensureAllowedOrigin(); - } - - protected function ensureAllowedOrigin(): void - { - $allowedHosts = ['coryd.dev', 'www.coryd.dev', 'localhost']; - $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; - $referer = $_SERVER['HTTP_REFERER'] ?? ''; - $hostAllowed = fn($url) => in_array(parse_url($url, PHP_URL_HOST), $allowedHosts, true); - - if (!$hostAllowed($origin) && !$hostAllowed($referer)) $this->sendErrorResponse("Forbidden: invalid origin", 403); - - $allowedSource = $origin ?: $referer; - $scheme = parse_url($allowedSource, PHP_URL_SCHEME) ?? 'https'; - $host = parse_url($allowedSource, PHP_URL_HOST); - - header("Access-Control-Allow-Origin: {$scheme}://{$host}"); - header("Access-Control-Allow-Headers: Content-Type"); - header("Access-Control-Allow-Methods: GET, POST"); - } - - public function handleRequest(): void - { - $data = $_GET['data'] ?? null; - $id = $_GET['id'] ?? null; - $cacheDuration = intval($_GET['cacheDuration'] ?? 3600); - - if (!$data) $this->sendErrorResponse("Missing 'data' parameter", 400); - - $cacheKey = $this->buildCacheKey($data, $id); - - if ($this->cache) { - $cached = $this->cache->get($cacheKey); - - if ($cached) { - header('Content-Type: application/json'); - echo $cached; - exit(); - } + public function __construct() + { + parent::__construct(); + $this->ensureAllowedOrigin(); } - $query = $id ? "id=eq.$id" : ""; + protected function ensureAllowedOrigin(): void + { + $allowedHosts = ['coryd.dev', 'www.coryd.dev', 'localhost']; + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + $referer = $_SERVER['HTTP_REFERER'] ?? ''; + $hostAllowed = fn ($url) => in_array(parse_url($url, PHP_URL_HOST), $allowedHosts, true); - try { - $response = $this->fetchFromApi($data, $query); - $markdownFields = $this->getMarkdownFieldsFromQuery(); + if (! $hostAllowed($origin) && ! $hostAllowed($referer)) { + $this->sendErrorResponse('Forbidden: invalid origin', 403); + } - if (!empty($response) && !empty($markdownFields)) $response = $this->parseMarkdownFields($response, $markdownFields); + $allowedSource = $origin ?: $referer; + $scheme = parse_url($allowedSource, PHP_URL_SCHEME) ?? 'https'; + $host = parse_url($allowedSource, PHP_URL_HOST); - $json = json_encode($response); - - if ($this->cache) $this->cache->setex($cacheKey, $cacheDuration, $json); - - header('Content-Type: application/json'); - echo $json; - } catch (\Exception $e) { - $this->sendErrorResponse("PostgREST fetch failed: " . $e->getMessage(), 500); - } - } - - private function buildCacheKey(string $data, ?string $id): string - { - return "proxy_{$data}" . ($id ? "_{$id}" : ""); - } - - private function getMarkdownFieldsFromQuery(): array { - $fields = $_GET['markdown'] ?? []; - - if (!is_array($fields)) $fields = explode(',', $fields); - - return array_map('trim', array_filter($fields)); - } - - private function parseMarkdownFields(array $data, array $fields): array { - foreach ($data as &$item) { - foreach ($fields as $field) { - if (!empty($item[$field])) $item["{$field}_html"] = parseMarkdown($item[$field]); - } + header("Access-Control-Allow-Origin: {$scheme}://{$host}"); + header('Access-Control-Allow-Headers: Content-Type'); + header('Access-Control-Allow-Methods: GET, POST'); } - return $data; - } + public function handleRequest(): void + { + $data = $_GET['data'] ?? null; + $id = $_GET['id'] ?? null; + $cacheDuration = intval($_GET['cacheDuration'] ?? 3600); + + if (! $data) { + $this->sendErrorResponse("Missing 'data' parameter", 400); + } + + $cacheKey = $this->buildCacheKey($data, $id); + + if ($this->cache) { + $cached = $this->cache->get($cacheKey); + + if ($cached) { + header('Content-Type: application/json'); + echo $cached; + exit(); + } + } + + $query = $id ? "id=eq.$id" : ''; + + try { + $response = $this->fetchFromApi($data, $query); + $markdownFields = $this->getMarkdownFieldsFromQuery(); + + if (! empty($response) && ! empty($markdownFields)) { + $response = $this->parseMarkdownFields($response, $markdownFields); + } + + $json = json_encode($response); + + if ($this->cache) { + $this->cache->setex($cacheKey, $cacheDuration, $json); + } + + header('Content-Type: application/json'); + echo $json; + } catch (\Exception $e) { + $this->sendErrorResponse('PostgREST fetch failed: '.$e->getMessage(), 500); + } + } + + private function buildCacheKey(string $data, ?string $id): string + { + return "proxy_{$data}".($id ? "_{$id}" : ''); + } + + private function getMarkdownFieldsFromQuery(): array + { + $fields = $_GET['markdown'] ?? []; + + if (! is_array($fields)) { + $fields = explode(',', $fields); + } + + return array_map('trim', array_filter($fields)); + } + + private function parseMarkdownFields(array $data, array $fields): array + { + foreach ($data as &$item) { + foreach ($fields as $field) { + if (! empty($item[$field])) { + $item["{$field}_html"] = parseMarkdown($item[$field]); + } + } + } + + return $data; + } } $handler = new QueryHandler(); diff --git a/api/scrobble.php b/api/scrobble.php index d12c8d6..5b31022 100644 --- a/api/scrobble.php +++ b/api/scrobble.php @@ -1,274 +1,312 @@ ensureCliAccess(); - $this->loadExternalServiceKeys(); - $this->validateAuthorization(); - } + private string $navidromeAuthToken; - private function loadExternalServiceKeys(): void - { - $this->navidromeApiUrl = getenv("NAVIDROME_API_URL"); - $this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN"); - $this->forwardEmailApiKey = getenv("FORWARDEMAIL_API_KEY"); - } + private string $forwardEmailApiKey; - private function validateAuthorization(): void - { - $authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? ""; - $expectedToken = "Bearer " . getenv("NAVIDROME_SCROBBLE_TOKEN"); + private array $artistCache = []; - if ($authHeader !== $expectedToken) { - http_response_code(401); + private array $albumCache = []; - 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; + public function __construct() + { + parent::__construct(); + $this->ensureCliAccess(); + $this->loadExternalServiceKeys(); + $this->validateAuthorization(); } - $albumData = $this->getOrCreateAlbum($track["album"], $artistData); - - if (empty($albumData)) { - error_log("Failed to retrieve or create album: " . $track["album"]); - return; + private function loadExternalServiceKeys(): void + { + $this->navidromeApiUrl = getenv('NAVIDROME_API_URL'); + $this->navidromeAuthToken = getenv('NAVIDROME_API_TOKEN'); + $this->forwardEmailApiKey = getenv('FORWARDEMAIL_API_KEY'); } - $this->insertListen($track, $albumData["key"]); - } + private function validateAuthorization(): void + { + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + $expectedToken = 'Bearer '.getenv('NAVIDROME_SCROBBLE_TOKEN'); - private function getOrCreateArtist(string $artistName): array - { - if (!$this->isDatabaseAvailable()) return []; - if (isset($this->artistCache[$artistName])) return $this->artistCache[$artistName]; + if ($authHeader !== $expectedToken) { + http_response_code(401); - $encodedArtist = rawurlencode($artistName); - $existingArtist = $this->fetchFromApi("artists", "name_string=eq.{$encodedArtist}&limit=1"); + echo json_encode(['error' => 'Unauthorized.']); - 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 []; + exit(); + } } - $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"] - ]); + public function runScrobbleCheck(): void + { + $recentTracks = $this->fetchRecentlyPlayed(); - $inserted = $response[0] ?? null; - if ($inserted) $this->sendFailureEmail("New tentative album record", "A new tentative album record was inserted:\n\nAlbum: $albumName\nKey: $albumKey"); + if (empty($recentTracks)) { + return; + } - return $this->albumCache[$albumKey] = $inserted ?? []; - } + foreach ($recentTracks as $track) { + if ($this->isTrackAlreadyScrobbled($track)) { + continue; + } - 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; + $this->handleTrackScrobble($track); + } } - if (!isset($payload["listened_at"])) { - error_log("Skipping track due to missing or invalid listened_at: " . json_encode($track)); - return; + 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 []; + } } - $this->makeRequest("POST", "listens", ["json" => $payload]); - } + private function isTrackAlreadyScrobbled(array $track): bool + { + $playDateString = $track['playDate'] ?? null; - private function generateAlbumKey(string $artistName, string $albumName): string - { - $artistKey = sanitizeMediaString($artistName); - $albumKey = sanitizeMediaString($albumName); + if (! $playDateString) { + return false; + } - return "{$artistKey}-{$albumKey}"; - } + $playDate = strtotime($playDateString); - private function sendFailureEmail(string $subject, string $message): void - { - if (!$this->isDatabaseAvailable()) return; + if ($playDate === false) { + return false; + } - $authHeader = "Basic " . base64_encode($this->forwardEmailApiKey . ":"); - $client = new Client(["base_uri" => "https://api.forwardemail.net/"]); + $existingListen = $this->fetchFromApi('listens', "listened_at=eq.{$playDate}&limit=1"); - 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()); + return ! empty($existingListen); } - } - private function isDatabaseAvailable(): bool - { - try { - $response = $this->fetchFromApi("listens", "limit=1"); + private function handleTrackScrobble(array $track): void + { + $artistData = $this->getOrCreateArtist($track['artist']); - return is_array($response); - } catch (\Exception $e) { - error_log("Database check failed: " . $e->getMessage()); + if (empty($artistData)) { + error_log('Failed to retrieve or create artist: '.$track['artist']); - return false; + 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(); + $handler = new NavidromeScrobbleHandler(); + $handler->runScrobbleCheck(); } catch (\Exception $e) { - http_response_code(500); + http_response_code(500); - echo json_encode(["error" => $e->getMessage()]); + echo json_encode(['error' => $e->getMessage()]); } diff --git a/api/search.php b/api/search.php index 15b212f..3962607 100644 --- a/api/search.php +++ b/api/search.php @@ -1,150 +1,164 @@ initializeCache(); - } - - public function handleRequest(): void - { - try { - $query = $this->validateAndSanitizeQuery($_GET["q"] ?? null); - $sections = $this->validateAndSanitizeSections($_GET["section"] ?? ""); - $page = isset($_GET["page"]) ? intval($_GET["page"]) : 1; - $pageSize = isset($_GET["pageSize"]) ? intval($_GET["pageSize"]) : 10; - $offset = ($page - 1) * $pageSize; - $cacheKey = $this->generateCacheKey($query, $sections, $page, $pageSize); - $results = []; - $results = $this->getCachedResults($cacheKey) ?? $this->fetchSearchResults($query, $sections, $pageSize, $offset); - - if (empty($results) || empty($results["data"])) { - $this->sendResponse(["results" => [], "total" => 0, "page" => $page, "pageSize" => $pageSize], 200); - return; - } - - $this->cacheResults($cacheKey, $results); - $this->sendResponse( - [ - "results" => $results["data"], - "total" => $results["total"], - "page" => $page, - "pageSize" => $pageSize, - ], - 200 - ); - } catch (\Exception $e) { - error_log("Search API Error: " . $e->getMessage()); - $this->sendErrorResponse("Invalid request. Please check your query and try again.", 400); + public function __construct() + { + parent::__construct(); + $this->initializeCache(); } - } - private function validateAndSanitizeQuery(?string $query): string - { - if (empty($query) || !is_string($query)) throw new \Exception("Invalid 'q' parameter. Must be a non-empty string."); + public function handleRequest(): void + { + try { + $query = $this->validateAndSanitizeQuery($_GET['q'] ?? null); + $sections = $this->validateAndSanitizeSections($_GET['section'] ?? ''); + $page = isset($_GET['page']) ? intval($_GET['page']) : 1; + $pageSize = isset($_GET['pageSize']) ? intval($_GET['pageSize']) : 10; + $offset = ($page - 1) * $pageSize; + $cacheKey = $this->generateCacheKey($query, $sections, $page, $pageSize); + $results = []; + $results = $this->getCachedResults($cacheKey) ?? $this->fetchSearchResults($query, $sections, $pageSize, $offset); - $query = trim($query); + if (empty($results) || empty($results['data'])) { + $this->sendResponse(['results' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize], 200); - if (strlen($query) > 255) throw new \Exception("Invalid 'q' parameter. Exceeds maximum length of 255 characters."); - if (!preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) throw new \Exception("Invalid 'q' parameter. Contains unsupported characters."); + return; + } - $query = preg_replace("/\s+/", " ", $query); - - return $query; - } - - private function validateAndSanitizeSections(string $rawSections): ?array - { - $allowedSections = ["post", "artist", "genre", "book", "movie", "show"]; - - if (empty($rawSections)) return null; - - $sections = array_map( - fn($section) => strtolower( - trim(htmlspecialchars($section, ENT_QUOTES, "UTF-8")) - ), - explode(",", $rawSections) - ); - $invalidSections = array_diff($sections, $allowedSections); - - if (!empty($invalidSections)) throw new Exception("Invalid 'section' parameter. Unsupported sections: " . implode(", ", $invalidSections)); - - return $sections; - } - - private function fetchSearchResults( - string $query, - ?array $sections, - int $pageSize, - int $offset - ): array { - $sectionsParam = $sections && count($sections) > 0 ? "%7B" . implode(",", $sections) . "%7D" : ""; - $endpoint = "rpc/search_optimized_index"; - $queryString = - "search_query=" . - urlencode($query) . - "&page_size={$pageSize}&page_offset={$offset}" . - ($sectionsParam ? "§ions={$sectionsParam}" : ""); - $data = $this->makeRequest("GET", "{$endpoint}?{$queryString}"); - $total = count($data) > 0 ? $data[0]["total_count"] : 0; - $results = array_map(function ($item) { - unset($item["total_count"]); - - if (!empty($item["description"])) $item["description"] = truncateText(strip_tags(parseMarkdown($item["description"])), 225); - - return $item; - - }, $data); - - return ["data" => $results, "total" => $total]; - } - - private function generateCacheKey( - string $query, - ?array $sections, - int $page, - int $pageSize - ): string { - $sectionsKey = $sections ? implode(",", $sections) : "all"; - - return sprintf( - "search:%s:sections:%s:page:%d:pageSize:%d", - md5($query), - $sectionsKey, - $page, - $pageSize - ); - } - - private function getCachedResults(string $cacheKey): ?array - { - if ($this->cache instanceof \Redis) { - $cachedData = $this->cache->get($cacheKey); - - return $cachedData ? json_decode($cachedData, true) : null; - } elseif (is_array($this->cache)) { - return $this->cache[$cacheKey] ?? null; + $this->cacheResults($cacheKey, $results); + $this->sendResponse( + [ + 'results' => $results['data'], + 'total' => $results['total'], + 'page' => $page, + 'pageSize' => $pageSize, + ], + 200 + ); + } catch (\Exception $e) { + error_log('Search API Error: '.$e->getMessage()); + $this->sendErrorResponse('Invalid request. Please check your query and try again.', 400); + } } - return null; - } - private function cacheResults(string $cacheKey, array $results): void - { - if ($this->cache instanceof \Redis) { - $this->cache->set($cacheKey, json_encode($results)); - $this->cache->expire($cacheKey, $this->cacheTTL); - } elseif (is_array($this->cache)) { - $this->cache[$cacheKey] = $results; + private function validateAndSanitizeQuery(?string $query): string + { + if (empty($query) || ! is_string($query)) { + throw new \Exception("Invalid 'q' parameter. Must be a non-empty string."); + } + + $query = trim($query); + + if (strlen($query) > 255) { + throw new \Exception("Invalid 'q' parameter. Exceeds maximum length of 255 characters."); + } + if (! preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) { + throw new \Exception("Invalid 'q' parameter. Contains unsupported characters."); + } + + $query = preg_replace("/\s+/", ' ', $query); + + return $query; + } + + private function validateAndSanitizeSections(string $rawSections): ?array + { + $allowedSections = ['post', 'artist', 'genre', 'book', 'movie', 'show']; + + if (empty($rawSections)) { + return null; + } + + $sections = array_map( + fn ($section) => strtolower( + trim(htmlspecialchars($section, ENT_QUOTES, 'UTF-8')) + ), + explode(',', $rawSections) + ); + $invalidSections = array_diff($sections, $allowedSections); + + if (! empty($invalidSections)) { + throw new Exception("Invalid 'section' parameter. Unsupported sections: ".implode(', ', $invalidSections)); + } + + return $sections; + } + + private function fetchSearchResults( + string $query, + ?array $sections, + int $pageSize, + int $offset + ): array { + $sectionsParam = $sections && count($sections) > 0 ? '%7B'.implode(',', $sections).'%7D' : ''; + $endpoint = 'rpc/search_optimized_index'; + $queryString = + 'search_query='. + urlencode($query). + "&page_size={$pageSize}&page_offset={$offset}". + ($sectionsParam ? "§ions={$sectionsParam}" : ''); + $data = $this->makeRequest('GET', "{$endpoint}?{$queryString}"); + $total = count($data) > 0 ? $data[0]['total_count'] : 0; + $results = array_map(function ($item) { + unset($item['total_count']); + + if (! empty($item['description'])) { + $item['description'] = truncateText(strip_tags(parseMarkdown($item['description'])), 225); + } + + return $item; + + }, $data); + + return ['data' => $results, 'total' => $total]; + } + + private function generateCacheKey( + string $query, + ?array $sections, + int $page, + int $pageSize + ): string { + $sectionsKey = $sections ? implode(',', $sections) : 'all'; + + return sprintf( + 'search:%s:sections:%s:page:%d:pageSize:%d', + md5($query), + $sectionsKey, + $page, + $pageSize + ); + } + + private function getCachedResults(string $cacheKey): ?array + { + if ($this->cache instanceof \Redis) { + $cachedData = $this->cache->get($cacheKey); + + return $cachedData ? json_decode($cachedData, true) : null; + } elseif (is_array($this->cache)) { + return $this->cache[$cacheKey] ?? null; + } + + return null; + } + + private function cacheResults(string $cacheKey, array $results): void + { + if ($this->cache instanceof \Redis) { + $this->cache->set($cacheKey, json_encode($results)); + $this->cache->expire($cacheKey, $this->cacheTTL); + } elseif (is_array($this->cache)) { + $this->cache[$cacheKey] = $results; + } } - } } $handler = new SearchHandler(); diff --git a/api/seasons-import.php b/api/seasons-import.php index 09e3e53..b10af14 100644 --- a/api/seasons-import.php +++ b/api/seasons-import.php @@ -1,168 +1,196 @@ ensureCliAccess(); - $this->tmdbApiKey = getenv("TMDB_API_KEY") ?: $_ENV["TMDB_API_KEY"]; - $this->seasonsImportToken = getenv("SEASONS_IMPORT_TOKEN") ?: $_ENV["SEASONS_IMPORT_TOKEN"]; - $this->authenticateRequest(); - } + public function __construct() + { + parent::__construct(); - private function authenticateRequest(): void - { - if ($_SERVER["REQUEST_METHOD"] !== "POST") $this->sendErrorResponse("Method Not Allowed", 405); - - $authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? ""; - - if (!preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) $this->sendErrorResponse("Unauthorized", 401); - - $providedToken = trim($matches[1]); - - if ($providedToken !== $this->seasonsImportToken) $this->sendErrorResponse("Forbidden", 403); - } - - public function importSeasons(): void - { - $ongoingShows = $this->fetchFromApi("optimized_shows", "ongoing=eq.true"); - - if (empty($ongoingShows)) $this->sendResponse(["message" => "No ongoing shows to update"], 200); - - foreach ($ongoingShows as $show) { - $this->processShowSeasons($show); + $this->ensureCliAccess(); + $this->tmdbApiKey = getenv('TMDB_API_KEY') ?: $_ENV['TMDB_API_KEY']; + $this->seasonsImportToken = getenv('SEASONS_IMPORT_TOKEN') ?: $_ENV['SEASONS_IMPORT_TOKEN']; + $this->authenticateRequest(); } - $this->sendResponse(["message" => "Season import completed"], 200); - } + private function authenticateRequest(): void + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->sendErrorResponse('Method Not Allowed', 405); + } - private function processShowSeasons(array $show): void - { - $tmdbId = $show["tmdb_id"] ?? null; - $showId = $show["id"] ?? null; + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; - if (!$tmdbId || !$showId) return; + if (! preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) { + $this->sendErrorResponse('Unauthorized', 401); + } - $tmdbShowData = $this->fetchShowDetails($tmdbId); - $seasons = $tmdbShowData["seasons"] ?? []; - $status = $tmdbShowData["status"] ?? "Unknown"; + $providedToken = trim($matches[1]); - if (empty($seasons) && !$this->shouldKeepOngoing($status)) { - $this->disableOngoingStatus($showId); - return; + if ($providedToken !== $this->seasonsImportToken) { + $this->sendErrorResponse('Forbidden', 403); + } } - foreach ($seasons as $season) { - $this->processSeasonEpisodes($showId, $tmdbId, $season); + public function importSeasons(): void + { + $ongoingShows = $this->fetchFromApi('optimized_shows', 'ongoing=eq.true'); + + if (empty($ongoingShows)) { + $this->sendResponse(['message' => 'No ongoing shows to update'], 200); + } + + foreach ($ongoingShows as $show) { + $this->processShowSeasons($show); + } + + $this->sendResponse(['message' => 'Season import completed'], 200); } - } - private function shouldKeepOngoing(string $status): bool - { - return in_array($status, ["Returning Series", "In Production"]); - } + private function processShowSeasons(array $show): void + { + $tmdbId = $show['tmdb_id'] ?? null; + $showId = $show['id'] ?? null; - private function fetchShowDetails(string $tmdbId): array - { - $client = new Client(); - $url = "https://api.themoviedb.org/3/tv/{$tmdbId}?api_key={$this->tmdbApiKey}&append_to_response=seasons"; + if (! $tmdbId || ! $showId) { + return; + } - try { - $response = $client->get($url, ["headers" => ["Accept" => "application/json"]]); + $tmdbShowData = $this->fetchShowDetails($tmdbId); + $seasons = $tmdbShowData['seasons'] ?? []; + $status = $tmdbShowData['status'] ?? 'Unknown'; - return json_decode($response->getBody(), true) ?? []; - } catch (\Exception $e) { - return []; + if (empty($seasons) && ! $this->shouldKeepOngoing($status)) { + $this->disableOngoingStatus($showId); + + return; + } + + foreach ($seasons as $season) { + $this->processSeasonEpisodes($showId, $tmdbId, $season); + } } - } - private function fetchWatchedEpisodes(int $showId): array - { - $episodes = $this->fetchFromApi("optimized_last_watched_episodes", "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1"); - - if (empty($episodes)) return []; - - return [ - "season_number" => (int) $episodes[0]["season_number"], - "episode_number" => (int) $episodes[0]["episode_number"], - ]; - } - - private function processSeasonEpisodes(int $showId, string $tmdbId, array $season): void - { - $seasonNumber = $season["season_number"] ?? null; - - if ($seasonNumber === null || $seasonNumber == 0) return; - - $episodes = $this->fetchSeasonEpisodes($tmdbId, $seasonNumber); - - if (empty($episodes)) return; - - $watched = $this->fetchWatchedEpisodes($showId); - $lastWatchedSeason = $watched["season_number"] ?? null; - $lastWatchedEpisode = $watched["episode_number"] ?? null; - - $scheduled = $this->fetchFromApi( - "optimized_scheduled_episodes", - "show_id=eq.{$showId}&season_number=eq.{$seasonNumber}" - ); - - $scheduledEpisodeNumbers = array_column($scheduled, "episode_number"); - - foreach ($episodes as $episode) { - $episodeNumber = $episode["episode_number"] ?? null; - - if ($episodeNumber === null) continue; - if (in_array($episodeNumber, $scheduledEpisodeNumbers)) continue; - if ($lastWatchedSeason !== null && $seasonNumber < $lastWatchedSeason) return; - if ($seasonNumber == $lastWatchedSeason && $episodeNumber <= $lastWatchedEpisode) continue; - - $this->addEpisodeToSchedule($showId, $seasonNumber, $episode); + private function shouldKeepOngoing(string $status): bool + { + return in_array($status, ['Returning Series', 'In Production']); } - } - private function fetchSeasonEpisodes(string $tmdbId, int $seasonNumber): array - { - $client = new Client(); - $url = "https://api.themoviedb.org/3/tv/{$tmdbId}/season/{$seasonNumber}?api_key={$this->tmdbApiKey}"; + private function fetchShowDetails(string $tmdbId): array + { + $client = new Client(); + $url = "https://api.themoviedb.org/3/tv/{$tmdbId}?api_key={$this->tmdbApiKey}&append_to_response=seasons"; - try { - $response = $client->get($url, ["headers" => ["Accept" => "application/json"]]); + try { + $response = $client->get($url, ['headers' => ['Accept' => 'application/json']]); - return json_decode($response->getBody(), true)["episodes"] ?? []; - } catch (\Exception $e) { - return []; + return json_decode($response->getBody(), true) ?? []; + } catch (\Exception $e) { + return []; + } } - } - private function addEpisodeToSchedule(int $showId, int $seasonNumber, array $episode): void - { - $airDate = $episode["air_date"] ?? null; + private function fetchWatchedEpisodes(int $showId): array + { + $episodes = $this->fetchFromApi('optimized_last_watched_episodes', "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1"); - if (!$airDate) return; + if (empty($episodes)) { + return []; + } - $today = date("Y-m-d"); - $status = ($airDate < $today) ? "aired" : "upcoming"; - $payload = [ - "show_id" => $showId, - "season_number" => $seasonNumber, - "episode_number" => $episode["episode_number"], - "air_date" => $airDate, - "status" => $status, - ]; + return [ + 'season_number' => (int) $episodes[0]['season_number'], + 'episode_number' => (int) $episodes[0]['episode_number'], + ]; + } - $this->makeRequest("POST", "scheduled_episodes", ["json" => $payload]); - } + private function processSeasonEpisodes(int $showId, string $tmdbId, array $season): void + { + $seasonNumber = $season['season_number'] ?? null; + + if ($seasonNumber === null || $seasonNumber == 0) { + return; + } + + $episodes = $this->fetchSeasonEpisodes($tmdbId, $seasonNumber); + + if (empty($episodes)) { + return; + } + + $watched = $this->fetchWatchedEpisodes($showId); + $lastWatchedSeason = $watched['season_number'] ?? null; + $lastWatchedEpisode = $watched['episode_number'] ?? null; + + $scheduled = $this->fetchFromApi( + 'optimized_scheduled_episodes', + "show_id=eq.{$showId}&season_number=eq.{$seasonNumber}" + ); + + $scheduledEpisodeNumbers = array_column($scheduled, 'episode_number'); + + foreach ($episodes as $episode) { + $episodeNumber = $episode['episode_number'] ?? null; + + if ($episodeNumber === null) { + continue; + } + if (in_array($episodeNumber, $scheduledEpisodeNumbers)) { + continue; + } + if ($lastWatchedSeason !== null && $seasonNumber < $lastWatchedSeason) { + return; + } + if ($seasonNumber == $lastWatchedSeason && $episodeNumber <= $lastWatchedEpisode) { + continue; + } + + $this->addEpisodeToSchedule($showId, $seasonNumber, $episode); + } + } + + private function fetchSeasonEpisodes(string $tmdbId, int $seasonNumber): array + { + $client = new Client(); + $url = "https://api.themoviedb.org/3/tv/{$tmdbId}/season/{$seasonNumber}?api_key={$this->tmdbApiKey}"; + + try { + $response = $client->get($url, ['headers' => ['Accept' => 'application/json']]); + + return json_decode($response->getBody(), true)['episodes'] ?? []; + } catch (\Exception $e) { + return []; + } + } + + private function addEpisodeToSchedule(int $showId, int $seasonNumber, array $episode): void + { + $airDate = $episode['air_date'] ?? null; + + if (! $airDate) { + return; + } + + $today = date('Y-m-d'); + $status = ($airDate < $today) ? 'aired' : 'upcoming'; + $payload = [ + 'show_id' => $showId, + 'season_number' => $seasonNumber, + 'episode_number' => $episode['episode_number'], + 'air_date' => $airDate, + 'status' => $status, + ]; + + $this->makeRequest('POST', 'scheduled_episodes', ['json' => $payload]); + } } $handler = new SeasonImportHandler(); diff --git a/api/umami.php b/api/umami.php index 10e1311..c5c569e 100644 --- a/api/umami.php +++ b/api/umami.php @@ -1,21 +1,21 @@ connect('127.0.0.1', 6379); + if (extension_loaded('redis')) { + $redis = new Redis(); + $redis->connect('127.0.0.1', 6379); - if ($redis->exists($cacheKey)) $js = $redis->get($cacheKey); - } + if ($redis->exists($cacheKey)) { + $js = $redis->get($cacheKey); + } + } } catch (Exception $e) { - error_log("Redis unavailable: " . $e->getMessage()); + error_log('Redis unavailable: '.$e->getMessage()); } - if (!is_string($js)) { - $ch = curl_init($remoteUrl); + if (! is_string($js)) { + $ch = curl_init($remoteUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HEADER, false); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, false); - $js = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $js = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + curl_close($ch); - if ($redis && $code === 200 && $js) $redis->setex($cacheKey, $ttl, $js); + if ($redis && $code === 200 && $js) { + $redis->setex($cacheKey, $ttl, $js); + } } - if (!is_string($js) || trim($js) === '') { - $js = '// Failed to fetch remote script'; - $code = 502; + if (! is_string($js) || trim($js) === '') { + $js = '// Failed to fetch remote script'; + $code = 502; } http_response_code($code); header('Content-Type: application/javascript; charset=UTF-8'); echo $js; exit; - } +} - $headers = [ +$headers = [ 'Content-Type: application/json', 'Accept: application/json', - ]; +]; - if (isset($_SERVER['HTTP_USER_AGENT'])) $headers[] = 'User-Agent: ' . $_SERVER['HTTP_USER_AGENT']; +if (isset($_SERVER['HTTP_USER_AGENT'])) { + $headers[] = 'User-Agent: '.$_SERVER['HTTP_USER_AGENT']; +} - $ch = curl_init($targetUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); +$ch = curl_init($targetUrl); +curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - if ($method === 'POST') { +if ($method === 'POST') { $body = file_get_contents('php://input'); $data = json_decode($body, true); - if (strpos($forwardPath, '/api/send') === 0 && is_array($data)) $data['payload'] = array_merge($data['payload'] ?? [], [ - 'ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0', - ]); + if (strpos($forwardPath, '/api/send') === 0 && is_array($data)) { + $data['payload'] = array_merge($data['payload'] ?? [], [ + 'ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0', + ]); + } curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - } else { +} else { curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - } +} - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); +$response = curl_exec($ch); +$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); - curl_close($ch); - http_response_code($httpCode); +curl_close($ch); +http_response_code($httpCode); - if ($contentType) header("Content-Type: $contentType"); +if ($contentType) { + header("Content-Type: $contentType"); +} - echo $response ?: ''; +echo $response ?: ''; diff --git a/api/watching-import.php b/api/watching-import.php index 40ccb95..cc672dd 100644 --- a/api/watching-import.php +++ b/api/watching-import.php @@ -1,140 +1,151 @@ ensureCliAccess(); + private string $tmdbImportToken; - $this->tmdbApiKey = $_ENV["TMDB_API_KEY"] ?? getenv("TMDB_API_KEY"); - $this->tmdbImportToken = $_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_IMPORT_TOKEN"); - } + public function __construct() + { + parent::__construct(); + $this->ensureCliAccess(); - public function handleRequest(): void - { - $input = json_decode(file_get_contents("php://input"), true); - - if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400); - - $providedToken = $input["token"] ?? null; - $tmdbId = $input["tmdb_id"] ?? null; - $mediaType = $input["media_type"] ?? null; - - if ($providedToken !== $this->tmdbImportToken) { - $this->sendErrorResponse("Unauthorized access", 401); + $this->tmdbApiKey = $_ENV['TMDB_API_KEY'] ?? getenv('TMDB_API_KEY'); + $this->tmdbImportToken = $_ENV['WATCHING_IMPORT_TOKEN'] ?? getenv('WATCHING_IMPORT_TOKEN'); } - if (!$tmdbId || !$mediaType) { - $this->sendErrorResponse("tmdb_id and media_type are required", 400); + public function handleRequest(): void + { + $input = json_decode(file_get_contents('php://input'), true); + + if (! $input) { + $this->sendErrorResponse('Invalid or missing JSON body', 400); + } + + $providedToken = $input['token'] ?? null; + $tmdbId = $input['tmdb_id'] ?? null; + $mediaType = $input['media_type'] ?? null; + + if ($providedToken !== $this->tmdbImportToken) { + $this->sendErrorResponse('Unauthorized access', 401); + } + + if (! $tmdbId || ! $mediaType) { + $this->sendErrorResponse('tmdb_id and media_type are required', 400); + } + + try { + $mediaData = $this->fetchTMDBData($tmdbId, $mediaType); + $this->processMedia($mediaData, $mediaType); + $this->sendResponse(['message' => 'Media imported successfully'], 200); + } catch (\Exception $e) { + $this->sendErrorResponse('Error: '.$e->getMessage(), 500); + } } - try { - $mediaData = $this->fetchTMDBData($tmdbId, $mediaType); - $this->processMedia($mediaData, $mediaType); - $this->sendResponse(["message" => "Media imported successfully"], 200); - } catch (\Exception $e) { - $this->sendErrorResponse("Error: " . $e->getMessage(), 500); - } - } + private function fetchTMDBData(string $tmdbId, string $mediaType): array + { + $client = new Client(); + $url = "https://api.themoviedb.org/3/{$mediaType}/{$tmdbId}"; - private function fetchTMDBData(string $tmdbId, string $mediaType): array - { - $client = new Client(); - $url = "https://api.themoviedb.org/3/{$mediaType}/{$tmdbId}"; + $response = $client->get($url, [ + 'query' => ['api_key' => $this->tmdbApiKey], + 'headers' => ['Accept' => 'application/json'], + ]); - $response = $client->get($url, [ - "query" => ["api_key" => $this->tmdbApiKey], - "headers" => ["Accept" => "application/json"], - ]); + $data = json_decode($response->getBody(), true); + if (empty($data)) { + throw new \Exception("No data found for TMDB ID: {$tmdbId}"); + } - $data = json_decode($response->getBody(), true); - if (empty($data)) throw new \Exception("No data found for TMDB ID: {$tmdbId}"); - - return $data; - } - - private function processMedia(array $mediaData, string $mediaType): void - { - $tagline = $mediaData["tagline"] ?? null; - $overview = $mediaData["overview"] ?? null; - $description = ""; - - if (!empty($tagline)) $description .= "> " . trim($tagline) . "\n\n"; - if (!empty($overview)) $description .= trim($overview); - - $id = $mediaData["id"]; - $title = $mediaType === "movie" ? $mediaData["title"] : $mediaData["name"]; - $year = $mediaData["release_date"] ?? $mediaData["first_air_date"] ?? null; - $year = $year ? substr($year, 0, 4) : null; - $tags = array_map( - fn($genre) => strtolower(trim($genre["name"])), - $mediaData["genres"] ?? [] - ); - $slug = $mediaType === "movie" - ? "/watching/movies/{$id}" - : "/watching/shows/{$id}"; - $payload = [ - "title" => $title, - "year" => $year, - "description" => $description, - "tmdb_id" => $id, - "slug" => $slug, - ]; - $table = $mediaType === "movie" ? "movies" : "shows"; - - try { - $response = $this->makeRequest("POST", $table, [ - "json" => $payload, - "headers" => ["Prefer" => "return=representation"] - ]); - } catch (\Exception $e) { - $response = $this->fetchFromApi($table, "tmdb_id=eq.{$id}")[0] ?? []; + return $data; } - $record = $response[0] ?? []; + private function processMedia(array $mediaData, string $mediaType): void + { + $tagline = $mediaData['tagline'] ?? null; + $overview = $mediaData['overview'] ?? null; + $description = ''; - if (!empty($record["id"])) { - $mediaId = $record["id"]; - $tagIds = $this->getTagIds($tags); - if (!empty($tagIds)) $this->associateTagsWithMedia($mediaType, $mediaId, array_values($tagIds)); - } - } + if (! empty($tagline)) { + $description .= '> '.trim($tagline)."\n\n"; + } + if (! empty($overview)) { + $description .= trim($overview); + } - private function getTagIds(array $tags): array - { - $map = []; + $id = $mediaData['id']; + $title = $mediaType === 'movie' ? $mediaData['title'] : $mediaData['name']; + $year = $mediaData['release_date'] ?? $mediaData['first_air_date'] ?? null; + $year = $year ? substr($year, 0, 4) : null; + $tags = array_map( + fn ($genre) => strtolower(trim($genre['name'])), + $mediaData['genres'] ?? [] + ); + $slug = $mediaType === 'movie' + ? "/watching/movies/{$id}" + : "/watching/shows/{$id}"; + $payload = [ + 'title' => $title, + 'year' => $year, + 'description' => $description, + 'tmdb_id' => $id, + 'slug' => $slug, + ]; + $table = $mediaType === 'movie' ? 'movies' : 'shows'; - foreach ($tags as $tag) { - $response = $this->fetchFromApi("tags", "name=ilike." . urlencode($tag)); - if (!empty($response[0]["id"])) { - $map[strtolower($tag)] = $response[0]["id"]; - } + try { + $response = $this->makeRequest('POST', $table, [ + 'json' => $payload, + 'headers' => ['Prefer' => 'return=representation'], + ]); + } catch (\Exception $e) { + $response = $this->fetchFromApi($table, "tmdb_id=eq.{$id}")[0] ?? []; + } + + $record = $response[0] ?? []; + + if (! empty($record['id'])) { + $mediaId = $record['id']; + $tagIds = $this->getTagIds($tags); + if (! empty($tagIds)) { + $this->associateTagsWithMedia($mediaType, $mediaId, array_values($tagIds)); + } + } } - return $map; - } + private function getTagIds(array $tags): array + { + $map = []; - private function associateTagsWithMedia(string $mediaType, int $mediaId, array $tagIds): void - { - $junction = $mediaType === "movie" ? "movies_tags" : "shows_tags"; - $mediaColumn = $mediaType === "movie" ? "movies_id" : "shows_id"; + foreach ($tags as $tag) { + $response = $this->fetchFromApi('tags', 'name=ilike.'.urlencode($tag)); + if (! empty($response[0]['id'])) { + $map[strtolower($tag)] = $response[0]['id']; + } + } - foreach ($tagIds as $tagId) { - $this->makeRequest("POST", $junction, ["json" => [ - $mediaColumn => $mediaId, - "tags_id" => $tagId - ]]); + return $map; + } + + private function associateTagsWithMedia(string $mediaType, int $mediaId, array $tagIds): void + { + $junction = $mediaType === 'movie' ? 'movies_tags' : 'shows_tags'; + $mediaColumn = $mediaType === 'movie' ? 'movies_id' : 'shows_id'; + + foreach ($tagIds as $tagId) { + $this->makeRequest('POST', $junction, ['json' => [ + $mediaColumn => $mediaId, + 'tags_id' => $tagId, + ]]); + } } - } } $handler = new WatchingImportHandler(); diff --git a/app/Classes/ApiHandler.php b/app/Classes/ApiHandler.php index 732f154..8b3b34a 100644 --- a/app/Classes/ApiHandler.php +++ b/app/Classes/ApiHandler.php @@ -1,11 +1,13 @@ cacheGet($cacheKey); + $cacheKey = 'artist_'.md5($url); + $cached = $this->cacheGet($cacheKey); - if ($cached) return $cached; + if ($cached) { + return $cached; + } - $artist = $this->fetchSingleFromApi("optimized_artists", $url); + $artist = $this->fetchSingleFromApi('optimized_artists', $url); - if (!$artist) return null; + if (! $artist) { + return null; + } - $artist['globals'] = $this->getGlobals(); + $artist['globals'] = $this->getGlobals(); - $this->cacheSet($cacheKey, $artist); + $this->cacheSet($cacheKey, $artist); - return $artist; + return $artist; } - } +} diff --git a/app/Classes/BaseHandler.php b/app/Classes/BaseHandler.php index e6318fb..2fca2a4 100644 --- a/app/Classes/BaseHandler.php +++ b/app/Classes/BaseHandler.php @@ -1,100 +1,106 @@ loadEnvironment(); - $this->initializeCache(); + $this->loadEnvironment(); + $this->initializeCache(); } private function loadEnvironment(): void { - $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?? ""; - $this->postgrestApiKey = $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?? ""; + $this->postgrestUrl = $_ENV['POSTGREST_URL'] ?? getenv('POSTGREST_URL') ?? ''; + $this->postgrestApiKey = $_ENV['POSTGREST_API_KEY'] ?? getenv('POSTGREST_API_KEY') ?? ''; } protected function initializeCache(): void { - if (class_exists("Redis")) { - try { - $redis = new \Redis(); - $redis->connect("127.0.0.1", 6379); + if (class_exists('Redis')) { + try { + $redis = new \Redis(); + $redis->connect('127.0.0.1', 6379); - $this->cache = $redis; - } catch (\Exception $e) { - error_log("Redis connection failed: " . $e->getMessage()); + $this->cache = $redis; + } catch (\Exception $e) { + error_log('Redis connection failed: '.$e->getMessage()); - $this->cache = null; + $this->cache = null; + } + } else { + error_log('Redis extension not found — caching disabled.'); + + $this->cache = null; } - } else { - error_log("Redis extension not found — caching disabled."); - - $this->cache = null; - } } protected function makeRequest(string $method, string $endpoint, array $options = []): array { - $client = new Client(); - $url = rtrim($this->postgrestUrl, "/") . "/" . ltrim($endpoint, "/"); + $client = new Client(); + $url = rtrim($this->postgrestUrl, '/').'/'.ltrim($endpoint, '/'); - try { - $response = $client->request($method, $url, array_merge_recursive([ - "headers" => [ - "Authorization" => "Bearer {$this->postgrestApiKey}", - "Content-Type" => "application/json", - ] - ], $options)); + try { + $response = $client->request($method, $url, array_merge_recursive([ + 'headers' => [ + 'Authorization' => "Bearer {$this->postgrestApiKey}", + 'Content-Type' => 'application/json', + ], + ], $options)); - $responseBody = $response->getBody()->getContents(); + $responseBody = $response->getBody()->getContents(); - if (empty($responseBody)) return []; + if (empty($responseBody)) { + return []; + } - $data = json_decode($responseBody, true); + $data = json_decode($responseBody, true); - if (json_last_error() !== JSON_ERROR_NONE) throw new \Exception("Invalid JSON: " . json_last_error_msg()); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \Exception('Invalid JSON: '.json_last_error_msg()); + } - return $data; - } catch (RequestException $e) { - $response = $e->getResponse(); - $statusCode = $response ? $response->getStatusCode() : 'N/A'; - $responseBody = $response ? $response->getBody()->getContents() : 'No response'; + return $data; + } catch (RequestException $e) { + $response = $e->getResponse(); + $statusCode = $response ? $response->getStatusCode() : 'N/A'; + $responseBody = $response ? $response->getBody()->getContents() : 'No response'; - throw new \Exception("HTTP {$method} {$url} failed with status {$statusCode}: {$responseBody}"); - } catch (\Exception $e) { - throw new \Exception("Request error: " . $e->getMessage()); - } + throw new \Exception("HTTP {$method} {$url} failed with status {$statusCode}: {$responseBody}"); + } catch (\Exception $e) { + throw new \Exception('Request error: '.$e->getMessage()); + } } - protected function fetchFromApi(string $endpoint, string $query = ""): array + protected function fetchFromApi(string $endpoint, string $query = ''): array { - $url = $endpoint . ($query ? "?{$query}" : ""); + $url = $endpoint.($query ? "?{$query}" : ''); - return $this->makeRequest("GET", $url); + return $this->makeRequest('GET', $url); } protected function sendResponse(array $data, int $statusCode = 200): void { - http_response_code($statusCode); - header("Content-Type: application/json"); + http_response_code($statusCode); + header('Content-Type: application/json'); - echo json_encode($data); + echo json_encode($data); - exit(); + exit(); } protected function sendErrorResponse(string $message, int $statusCode = 500): void { - $this->sendResponse(["error" => $message], $statusCode); + $this->sendResponse(['error' => $message], $statusCode); } - } +} diff --git a/app/Classes/BookFetcher.php b/app/Classes/BookFetcher.php index e43a13e..6f499cb 100644 --- a/app/Classes/BookFetcher.php +++ b/app/Classes/BookFetcher.php @@ -1,24 +1,28 @@ cacheGet($cacheKey); + $cacheKey = 'book_'.md5($url); + $cached = $this->cacheGet($cacheKey); - if ($cached) return $cached; + if ($cached) { + return $cached; + } - $book = $this->fetchSingleFromApi("optimized_books", $url); + $book = $this->fetchSingleFromApi('optimized_books', $url); - if (!$book) return null; + if (! $book) { + return null; + } - $book['globals'] = $this->getGlobals(); + $book['globals'] = $this->getGlobals(); - $this->cacheSet($cacheKey, $book); + $this->cacheSet($cacheKey, $book); - return $book; + return $book; } - } +} diff --git a/app/Classes/GenreFetcher.php b/app/Classes/GenreFetcher.php index faf337d..7edcfe3 100644 --- a/app/Classes/GenreFetcher.php +++ b/app/Classes/GenreFetcher.php @@ -1,24 +1,28 @@ cacheGet($cacheKey); + $cacheKey = 'genre_'.md5($url); + $cached = $this->cacheGet($cacheKey); - if ($cached) return $cached; + if ($cached) { + return $cached; + } - $genre = $this->fetchSingleFromApi("optimized_genres", $url); + $genre = $this->fetchSingleFromApi('optimized_genres', $url); - if (!$genre) return null; + if (! $genre) { + return null; + } - $genre['globals'] = $this->getGlobals(); + $genre['globals'] = $this->getGlobals(); - $this->cacheSet($cacheKey, $genre); + $this->cacheSet($cacheKey, $genre); - return $genre; + return $genre; } - } +} diff --git a/app/Classes/GlobalsFetcher.php b/app/Classes/GlobalsFetcher.php index e189108..8e999b3 100644 --- a/app/Classes/GlobalsFetcher.php +++ b/app/Classes/GlobalsFetcher.php @@ -1,22 +1,26 @@ cacheGet($cacheKey); + $cacheKey = 'globals'; + $cached = $this->cacheGet($cacheKey); - if ($cached) return $cached; + if ($cached) { + return $cached; + } - $globals = $this->fetchFromApi("optimized_globals"); + $globals = $this->fetchFromApi('optimized_globals'); - if (empty($globals)) return null; + if (empty($globals)) { + return null; + } - $this->cacheSet($cacheKey, $globals[0]); + $this->cacheSet($cacheKey, $globals[0]); - return $globals[0]; + return $globals[0]; } - } +} diff --git a/app/Classes/LatestListenHandler.php b/app/Classes/LatestListenHandler.php index e903f8d..4de562d 100644 --- a/app/Classes/LatestListenHandler.php +++ b/app/Classes/LatestListenHandler.php @@ -2,68 +2,73 @@ namespace App\Classes; -use App\Classes\BaseHandler; - class LatestListenHandler extends BaseHandler { - protected int $cacheTTL = 60; + protected int $cacheTTL = 60; - public function __construct() - { - parent::__construct(); - $this->initializeCache(); - } - - public function handleRequest(): void - { - $data = $this->getLatestListen(); - - if (!$data) { - $this->sendResponse(["message" => "No recent tracks found"], 404); - - return; + public function __construct() + { + parent::__construct(); + $this->initializeCache(); } - $this->sendResponse($data); - } + public function handleRequest(): void + { + $data = $this->getLatestListen(); - public function getLatestListen(): ?array - { - try { - $cachedData = $this->cache ? $this->cache->get("latest_listen") : null; + if (! $data) { + $this->sendResponse(['message' => 'No recent tracks found'], 404); - if ($cachedData) return json_decode($cachedData, true); + return; + } - $data = $this->makeRequest("GET", "optimized_latest_listen?select=*"); - - if (!is_array($data) || empty($data[0])) return null; - - $latestListen = $this->formatLatestListen($data[0]); - - if ($this->cache) $this->cache->set("latest_listen", json_encode($latestListen), $this->cacheTTL); - - return $latestListen; - } catch (\Exception $e) { - error_log("LatestListenHandler::getLatestListen error: " . $e->getMessage()); - return null; + $this->sendResponse($data); } - } - private function formatLatestListen(array $latestListen): array - { - $emoji = $latestListen["artist_emoji"] ?? ($latestListen["genre_emoji"] ?? "🎧"); - $trackName = htmlspecialchars($latestListen["track_name"] ?? "Unknown Track", ENT_QUOTES, "UTF-8"); - $artistName = htmlspecialchars($latestListen["artist_name"] ?? "Unknown Artist", ENT_QUOTES, "UTF-8"); - $url = htmlspecialchars($latestListen["url"] ?? "/", ENT_QUOTES, "UTF-8"); + public function getLatestListen(): ?array + { + try { + $cachedData = $this->cache ? $this->cache->get('latest_listen') : null; - return [ - "content" => sprintf( - '%s %s by %s', - $emoji, - $trackName, - $url, - $artistName - ), - ]; - } + if ($cachedData) { + return json_decode($cachedData, true); + } + + $data = $this->makeRequest('GET', 'optimized_latest_listen?select=*'); + + if (! is_array($data) || empty($data[0])) { + return null; + } + + $latestListen = $this->formatLatestListen($data[0]); + + if ($this->cache) { + $this->cache->set('latest_listen', json_encode($latestListen), $this->cacheTTL); + } + + return $latestListen; + } catch (\Exception $e) { + error_log('LatestListenHandler::getLatestListen error: '.$e->getMessage()); + + return null; + } + } + + private function formatLatestListen(array $latestListen): array + { + $emoji = $latestListen['artist_emoji'] ?? ($latestListen['genre_emoji'] ?? '🎧'); + $trackName = htmlspecialchars($latestListen['track_name'] ?? 'Unknown Track', ENT_QUOTES, 'UTF-8'); + $artistName = htmlspecialchars($latestListen['artist_name'] ?? 'Unknown Artist', ENT_QUOTES, 'UTF-8'); + $url = htmlspecialchars($latestListen['url'] ?? '/', ENT_QUOTES, 'UTF-8'); + + return [ + 'content' => sprintf( + '%s %s by %s', + $emoji, + $trackName, + $url, + $artistName + ), + ]; + } } diff --git a/app/Classes/MovieFetcher.php b/app/Classes/MovieFetcher.php index 77fb861..e8a4d72 100644 --- a/app/Classes/MovieFetcher.php +++ b/app/Classes/MovieFetcher.php @@ -1,24 +1,28 @@ cacheGet($cacheKey); + $cacheKey = 'movie_'.md5($url); + $cached = $this->cacheGet($cacheKey); - if ($cached) return $cached; + if ($cached) { + return $cached; + } - $movie = $this->fetchSingleFromApi("optimized_movies", $url); + $movie = $this->fetchSingleFromApi('optimized_movies', $url); - if (!$movie) return null; + if (! $movie) { + return null; + } - $movie['globals'] = $this->getGlobals(); + $movie['globals'] = $this->getGlobals(); - $this->cacheSet($cacheKey, $movie); + $this->cacheSet($cacheKey, $movie); - return $movie; + return $movie; } - } +} diff --git a/app/Classes/MusicDataHandler.php b/app/Classes/MusicDataHandler.php index 7588f61..25dfc0e 100644 --- a/app/Classes/MusicDataHandler.php +++ b/app/Classes/MusicDataHandler.php @@ -4,23 +4,27 @@ namespace App\Classes; class MusicDataHandler extends BaseHandler { - protected int $cacheTTL = 300; + protected int $cacheTTL = 300; - public function getThisWeekData(): array - { - $cacheKey = 'music_week_data'; - $cached = $this->cache ? $this->cache->get($cacheKey) : null; + public function getThisWeekData(): array + { + $cacheKey = 'music_week_data'; + $cached = $this->cache ? $this->cache->get($cacheKey) : null; - if ($cached) return json_decode($cached, true); + if ($cached) { + return json_decode($cached, true); + } - $response = $this->makeRequest('GET', 'optimized_week_music?select=*'); - $music = $response[0]['week_music'] ?? []; - $music['total_tracks'] = $music['week_summary']['total_tracks'] ?? 0; - $music['total_artists'] = $music['week_summary']['total_artists'] ?? 0; - $music['total_albums'] = $music['week_summary']['total_albums'] ?? 0; + $response = $this->makeRequest('GET', 'optimized_week_music?select=*'); + $music = $response[0]['week_music'] ?? []; + $music['total_tracks'] = $music['week_summary']['total_tracks'] ?? 0; + $music['total_artists'] = $music['week_summary']['total_artists'] ?? 0; + $music['total_albums'] = $music['week_summary']['total_albums'] ?? 0; - if ($this->cache) $this->cache->set($cacheKey, json_encode($music), $this->cacheTTL); + if ($this->cache) { + $this->cache->set($cacheKey, json_encode($music), $this->cacheTTL); + } - return $music; - } + return $music; + } } diff --git a/app/Classes/PageFetcher.php b/app/Classes/PageFetcher.php index b57ac67..d97e13d 100644 --- a/app/Classes/PageFetcher.php +++ b/app/Classes/PageFetcher.php @@ -2,43 +2,44 @@ namespace App\Classes; -use App\Classes\BaseHandler; -use App\Classes\GlobalsFetcher; - abstract class PageFetcher extends BaseHandler { - protected ?array $globals = null; + protected ?array $globals = null; - protected function cacheGet(string $key): mixed - { - return $this->cache && $this->cache->exists($key) ? json_decode($this->cache->get($key), true) : null; - } + protected function cacheGet(string $key): mixed + { + return $this->cache && $this->cache->exists($key) ? json_decode($this->cache->get($key), true) : null; + } - protected function cacheSet(string $key, mixed $value, int $ttl = 300): void - { - if ($this->cache) $this->cache->setex($key, $ttl, json_encode($value)); - } + protected function cacheSet(string $key, mixed $value, int $ttl = 300): void + { + if ($this->cache) { + $this->cache->setex($key, $ttl, json_encode($value)); + } + } - protected function fetchSingleFromApi(string $endpoint, string $url): ?array - { - $data = $this->fetchFromApi($endpoint, "url=eq./{$url}"); + protected function fetchSingleFromApi(string $endpoint, string $url): ?array + { + $data = $this->fetchFromApi($endpoint, "url=eq./{$url}"); - return $data[0] ?? null; - } + return $data[0] ?? null; + } - protected function fetchPostRpc(string $endpoint, array $body): ?array - { - return $this->makeRequest("POST", $endpoint, ['json' => $body]); - } + protected function fetchPostRpc(string $endpoint, array $body): ?array + { + return $this->makeRequest('POST', $endpoint, ['json' => $body]); + } - public function getGlobals(): ?array - { - if ($this->globals !== null) return $this->globals; + public function getGlobals(): ?array + { + if ($this->globals !== null) { + return $this->globals; + } - $fetcher = new GlobalsFetcher(); + $fetcher = new GlobalsFetcher(); - $this->globals = $fetcher->fetch(); + $this->globals = $fetcher->fetch(); - return $this->globals; - } + return $this->globals; + } } diff --git a/app/Classes/RecentMediaHandler.php b/app/Classes/RecentMediaHandler.php index bb4ecad..ae04cc5 100644 --- a/app/Classes/RecentMediaHandler.php +++ b/app/Classes/RecentMediaHandler.php @@ -4,33 +4,37 @@ namespace App\Classes; class RecentMediaHandler extends BaseHandler { - protected int $cacheTTL = 300; + protected int $cacheTTL = 300; - public function getRecentMedia(): array - { - try { - $cacheKey = 'recent_media'; + public function getRecentMedia(): array + { + try { + $cacheKey = 'recent_media'; - if ($this->cache) { - $cached = $this->cache->get($cacheKey); + if ($this->cache) { + $cached = $this->cache->get($cacheKey); - if ($cached) return json_decode($cached, true); - } + if ($cached) { + return json_decode($cached, true); + } + } - $response = $this->makeRequest("GET", "optimized_recent_media?select=*"); - $activity = $response[0]['recent_activity'] ?? []; - $data = [ - 'recentMusic' => $activity['recentMusic'] ?? [], - 'recentWatchedRead' => $activity['recentWatchedRead'] ?? [], - ]; + $response = $this->makeRequest('GET', 'optimized_recent_media?select=*'); + $activity = $response[0]['recent_activity'] ?? []; + $data = [ + 'recentMusic' => $activity['recentMusic'] ?? [], + 'recentWatchedRead' => $activity['recentWatchedRead'] ?? [], + ]; - if ($this->cache) $this->cache->set($cacheKey, json_encode($data), $this->cacheTTL); + if ($this->cache) { + $this->cache->set($cacheKey, json_encode($data), $this->cacheTTL); + } - return $data; - } catch (\Exception $e) { - error_log("RecentMediaHandler error: " . $e->getMessage()); + return $data; + } catch (\Exception $e) { + error_log('RecentMediaHandler error: '.$e->getMessage()); - return ['recentMusic' => [], 'recentWatchedRead' => []]; + return ['recentMusic' => [], 'recentWatchedRead' => []]; + } } - } } diff --git a/app/Classes/ShowFetcher.php b/app/Classes/ShowFetcher.php index 4f5e3cb..9cbd4f5 100644 --- a/app/Classes/ShowFetcher.php +++ b/app/Classes/ShowFetcher.php @@ -1,24 +1,28 @@ cacheGet($cacheKey); + $cacheKey = 'show_'.md5($url); + $cached = $this->cacheGet($cacheKey); - if ($cached) return $cached; + if ($cached) { + return $cached; + } - $show = $this->fetchSingleFromApi("optimized_shows", $url); + $show = $this->fetchSingleFromApi('optimized_shows', $url); - if (!$show) return null; + if (! $show) { + return null; + } - $show['globals'] = $this->getGlobals(); + $show['globals'] = $this->getGlobals(); - $this->cacheSet($cacheKey, $show); + $this->cacheSet($cacheKey, $show); - return $show; + return $show; } - } +} diff --git a/app/Classes/TagFetcher.php b/app/Classes/TagFetcher.php index 73f90cb..4e7d133 100644 --- a/app/Classes/TagFetcher.php +++ b/app/Classes/TagFetcher.php @@ -4,26 +4,30 @@ namespace App\Classes; class TagFetcher extends PageFetcher { - public function fetch(string $tag, int $page = 1, int $pageSize = 20): ?array - { - $offset = ($page - 1) * $pageSize; - $cacheKey = "tag_" . md5("{$tag}_{$page}"); - $cached = $this->cacheGet($cacheKey); + public function fetch(string $tag, int $page = 1, int $pageSize = 20): ?array + { + $offset = ($page - 1) * $pageSize; + $cacheKey = 'tag_'.md5("{$tag}_{$page}"); + $cached = $this->cacheGet($cacheKey); - if ($cached) return $cached; + if ($cached) { + return $cached; + } - $results = $this->fetchPostRpc("rpc/get_tagged_content", [ - "tag_query" => $tag, - "page_size" => $pageSize, - "page_offset" => $offset - ]); + $results = $this->fetchPostRpc('rpc/get_tagged_content', [ + 'tag_query' => $tag, + 'page_size' => $pageSize, + 'page_offset' => $offset, + ]); - if (!$results || count($results) === 0) return null; + if (! $results || count($results) === 0) { + return null; + } - $results[0]['globals'] = $this->getGlobals(); + $results[0]['globals'] = $this->getGlobals(); - $this->cacheSet($cacheKey, $results); + $this->cacheSet($cacheKey, $results); - return $results; - } + return $results; + } } diff --git a/app/Utils/icons.php b/app/Utils/icons.php index bc859e7..135c4e1 100644 --- a/app/Utils/icons.php +++ b/app/Utils/icons.php @@ -1,20 +1,20 @@ '', - 'arrow-right' => '', - 'article' => '', - 'books' => '', - 'device-tv-old' => '', - 'headphones' => '', - 'movie' => '', - 'star' => '', - 'vinyl' => '' - ]; + $icons = [ + 'arrow-left' => '', + 'arrow-right' => '', + 'article' => '', + 'books' => '', + 'device-tv-old' => '', + 'headphones' => '', + 'movie' => '', + 'star' => '', + 'vinyl' => '', + ]; - return $icons[$iconName] ?? '[Missing: ' . htmlspecialchars($iconName) . ']'; + return $icons[$iconName] ?? '[Missing: '.htmlspecialchars($iconName).']'; } - } +} diff --git a/app/Utils/init.php b/app/Utils/init.php index ef16523..124109d 100644 --- a/app/Utils/init.php +++ b/app/Utils/init.php @@ -1,8 +1,9 @@ 0 ? $count : count($items); - $firstType = $items[0]['type'] ?? ($items[0]['grid']['type'] ?? ''); - $shapeClass = in_array($firstType, ['books', 'movies', 'tv']) ? 'vertical' : 'square'; + $limit = $count > 0 ? $count : count($items); + $firstType = $items[0]['type'] ?? ($items[0]['grid']['type'] ?? ''); + $shapeClass = in_array($firstType, ['books', 'movies', 'tv']) ? 'vertical' : 'square'; - echo '
'; + echo '
'; - foreach (array_slice($items, 0, $limit) as $item) { - $grid = $item['grid'] ?? $item; - $alt = htmlspecialchars($grid['alt'] ?? ''); - $image = htmlspecialchars($grid['image'] ?? ''); - $title = htmlspecialchars($grid['title'] ?? ''); - $subtext = htmlspecialchars($grid['subtext'] ?? ''); - $url = $grid['url'] ?? null; - $type = $item['type'] ?? ''; - $isVertical = in_array($type, ['books', 'movies', 'tv']); - $imageClass = $isVertical ? 'vertical' : 'square'; - $width = $isVertical ? 120 : 150; - $height = $isVertical ? 184 : 150; + foreach (array_slice($items, 0, $limit) as $item) { + $grid = $item['grid'] ?? $item; + $alt = htmlspecialchars($grid['alt'] ?? ''); + $image = htmlspecialchars($grid['image'] ?? ''); + $title = htmlspecialchars($grid['title'] ?? ''); + $subtext = htmlspecialchars($grid['subtext'] ?? ''); + $url = $grid['url'] ?? null; + $type = $item['type'] ?? ''; + $isVertical = in_array($type, ['books', 'movies', 'tv']); + $imageClass = $isVertical ? 'vertical' : 'square'; + $width = $isVertical ? 120 : 150; + $height = $isVertical ? 184 : 150; - $openLink = $url ? '' : ''; - $closeLink = $url ? '' : ''; + $openLink = $url ? '' : ''; + $closeLink = $url ? '' : ''; - echo $openLink; - echo '
'; + echo $openLink; + echo '
'; - if ($title || $subtext) { - echo '
'; - if ($title) echo '
' . $title . '
'; - if ($subtext) echo '
' . $subtext . '
'; - echo '
'; - } - - echo '' . $alt . ''; - echo '
'; - echo $closeLink; - } - - echo '
'; - } - } - - if (!function_exists('renderAssociatedMedia')) { - function renderAssociatedMedia( - array $artists = [], - array $books = [], - array $genres = [], - array $movies = [], - array $posts = [], - array $shows = [], - ) - { - $sections = [ - "artists" => ["icon" => "headphones", "css_class" => "music", "label" => "Related artist(s)", "hasGrid" => true], - "books" => ["icon" => "books", "css_class" => "books", "label" => "Related book(s)", "hasGrid" => true], - "genres" => ["icon" => "headphones", "css_class" => "music", "label" => "Related genre(s)", "hasGrid" => false], - "movies" => ["icon" => "movie", "css_class" => "movies", "label" => "Related movie(s)", "hasGrid" => true], - "posts" => ["icon" => "article", "css_class" => "article", "label" => "Related post(s)", "hasGrid" => false], - "shows" => ["icon" => "device-tv-old", "css_class" => "tv", "label" => "Related show(s)", "hasGrid" => true] - ]; - - $allMedia = compact('artists', 'books', 'genres', 'movies', 'posts', 'shows'); - - echo '
'; - - foreach ($sections as $key => $section) { - $items = $allMedia[$key]; - - if (empty($items)) continue; - - echo '

'; - echo getTablerIcon($section['icon']); - echo htmlspecialchars($section['label']); - echo '

'; - - if ($section['hasGrid']) { - renderMediaGrid($items); - } else { - echo '
    '; - foreach ($items as $item) { - echo '
  • '; - echo '' . htmlspecialchars($item['title'] ?? $item['name'] ?? '') . ''; - - if ($key === "artists" && isset($item['total_plays']) && $item['total_plays'] > 0) { - echo ' (' . htmlspecialchars($item['total_plays']) . ' play' . ($item['total_plays'] > 1 ? 's' : '') . ')'; - } elseif ($key === "books" && isset($item['author'])) { - echo ' by ' . htmlspecialchars($item['author']); - } elseif (($key === "movies" || $key === "shows") && isset($item['year'])) { - echo ' (' . htmlspecialchars($item['year']) . ')'; - } elseif ($key === "posts" && isset($item['date'])) { - echo ' (' . date("F j, Y", strtotime($item['date'])) . ')'; + if ($title || $subtext) { + echo '
    '; + if ($title) { + echo '
    '.$title.'
    '; + } + if ($subtext) { + echo '
    '.$subtext.'
    '; + } + echo '
    '; } - echo '
  • '; - } - echo '
'; + echo ''.$alt.''; + echo '
'; + echo $closeLink; } - } - echo '
'; + echo '
'; } - } +} - if (!function_exists('renderMediaLinks')) { - function renderMediaLinks(array $data, string $type, int $count = 10): string { - if (empty($data) || empty($type)) return ""; +if (! function_exists('renderAssociatedMedia')) { + function renderAssociatedMedia( + array $artists = [], + array $books = [], + array $genres = [], + array $movies = [], + array $posts = [], + array $shows = [], + ) { + $sections = [ + 'artists' => ['icon' => 'headphones', 'css_class' => 'music', 'label' => 'Related artist(s)', 'hasGrid' => true], + 'books' => ['icon' => 'books', 'css_class' => 'books', 'label' => 'Related book(s)', 'hasGrid' => true], + 'genres' => ['icon' => 'headphones', 'css_class' => 'music', 'label' => 'Related genre(s)', 'hasGrid' => false], + 'movies' => ['icon' => 'movie', 'css_class' => 'movies', 'label' => 'Related movie(s)', 'hasGrid' => true], + 'posts' => ['icon' => 'article', 'css_class' => 'article', 'label' => 'Related post(s)', 'hasGrid' => false], + 'shows' => ['icon' => 'device-tv-old', 'css_class' => 'tv', 'label' => 'Related show(s)', 'hasGrid' => true], + ]; - $slice = array_slice($data, 0, $count); + $allMedia = compact('artists', 'books', 'genres', 'movies', 'posts', 'shows'); - if (count($slice) === 0) return ""; + echo '
'; - $buildLink = function ($item) use ($type) { - switch ($type) { - case "genre": - return '' . htmlspecialchars($item['genre_name']) . ''; - case "artist": - return '' . htmlspecialchars($item['name']) . ''; - case "book": - return '' . htmlspecialchars($item['title']) . ''; - default: + foreach ($sections as $key => $section) { + $items = $allMedia[$key]; + + if (empty($items)) { + continue; + } + + echo '

'; + echo getTablerIcon($section['icon']); + echo htmlspecialchars($section['label']); + echo '

'; + + if ($section['hasGrid']) { + renderMediaGrid($items); + } else { + echo '
    '; + foreach ($items as $item) { + echo '
  • '; + echo ''.htmlspecialchars($item['title'] ?? $item['name'] ?? '').''; + + if ($key === 'artists' && isset($item['total_plays']) && $item['total_plays'] > 0) { + echo ' ('.htmlspecialchars($item['total_plays']).' play'.($item['total_plays'] > 1 ? 's' : '').')'; + } elseif ($key === 'books' && isset($item['author'])) { + echo ' by '.htmlspecialchars($item['author']); + } elseif (($key === 'movies' || $key === 'shows') && isset($item['year'])) { + echo ' ('.htmlspecialchars($item['year']).')'; + } elseif ($key === 'posts' && isset($item['date'])) { + echo ' ('.date('F j, Y', strtotime($item['date'])).')'; + } + + echo '
  • '; + } + echo '
'; + } + } + + echo '
'; + } +} + +if (! function_exists('renderMediaLinks')) { + function renderMediaLinks(array $data, string $type, int $count = 10): string + { + if (empty($data) || empty($type)) { return ''; } - }; - if (count($slice) === 1) return $buildLink($slice[0]); + $slice = array_slice($data, 0, $count); - $links = array_map($buildLink, $slice); - $last = array_pop($links); - return implode(', ', $links) . ' and ' . $last; + if (count($slice) === 0) { + return ''; + } + + $buildLink = function ($item) use ($type) { + switch ($type) { + case 'genre': + return ''.htmlspecialchars($item['genre_name']).''; + case 'artist': + return ''.htmlspecialchars($item['name']).''; + case 'book': + return ''.htmlspecialchars($item['title']).''; + default: + return ''; + } + }; + + if (count($slice) === 1) { + return $buildLink($slice[0]); + } + + $links = array_map($buildLink, $slice); + $last = array_pop($links); + + return implode(', ', $links).' and '.$last; } - } +} - if (!function_exists('sanitizeMediaString')) { +if (! function_exists('sanitizeMediaString')) { function sanitizeMediaString(string $str): string { - $sanitizedString = preg_replace("/[^a-zA-Z0-9\s-]/", "", iconv("UTF-8", "ASCII//TRANSLIT", $str)); + $sanitizedString = preg_replace("/[^a-zA-Z0-9\s-]/", '', iconv('UTF-8', 'ASCII//TRANSLIT', $str)); - return strtolower(trim(preg_replace("/[\s-]+/", "-", $sanitizedString), "-")); + return strtolower(trim(preg_replace("/[\s-]+/", '-', $sanitizedString), '-')); } - } +} diff --git a/app/Utils/metadata.php b/app/Utils/metadata.php index 0e2739f..d2d86bd 100644 --- a/app/Utils/metadata.php +++ b/app/Utils/metadata.php @@ -1,37 +1,38 @@ $title, - 'pageDescription' => $description, - 'ogImage' => $image, - 'fullUrl' => $fullUrl, - 'oembedUrl' => $oembedUrl, - 'globals' => $globals - ]; + return [ + 'pageTitle' => $title, + 'pageDescription' => $description, + 'ogImage' => $image, + 'fullUrl' => $fullUrl, + 'oembedUrl' => $oembedUrl, + 'globals' => $globals, + ]; } - } +} - if (!function_exists('cleanMeta')) { +if (! function_exists('cleanMeta')) { function cleanMeta($value) { - $value = trim($value ?? ''); - $value = str_replace(["\r", "\n"], ' ', $value); - $value = preg_replace('/\s+/', ' ', $value); - return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + $value = trim($value ?? ''); + $value = str_replace(["\r", "\n"], ' ', $value); + $value = preg_replace('/\s+/', ' ', $value); + + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); } - } +} diff --git a/app/Utils/paginator.php b/app/Utils/paginator.php index aa85661..b5c26ef 100644 --- a/app/Utils/paginator.php +++ b/app/Utils/paginator.php @@ -1,41 +1,43 @@ + if (! $pagination || $totalPages <= 1) { + return; + } + ?> true, - "linkify" => true, - ]); - - $md->plugin(new MarkdownItFootnote()); - - return $md->render($markdown); - } - } - - if (!function_exists('parseCountryField')) { - function parseCountryField($countryField) - { - if (empty($countryField)) return null; - - $delimiters = [',', '/', '&', ' and ']; - $countries = [$countryField]; - - foreach ($delimiters as $delimiter) { - $tempCountries = []; - - foreach ($countries as $country) { - $tempCountries = array_merge($tempCountries, explode($delimiter, $country)); + if (mb_strwidth($text, 'UTF-8') <= $limit) { + return $text; } - $countries = $tempCountries; - } + $truncated = mb_substr($text, 0, $limit, 'UTF-8'); + $lastSpace = mb_strrpos($truncated, ' ', 0, 'UTF-8'); - $countries = array_map('trim', $countries); - $countries = array_map('getCountryName', $countries); - $countries = array_filter($countries); + if ($lastSpace !== false) { + $truncated = mb_substr($truncated, 0, $lastSpace, 'UTF-8'); + } - return implode(', ', array_unique($countries)); + return $truncated.$ellipsis; } - } +} - if (!function_exists('getCountryName')) { +if (! function_exists('parseMarkdown')) { + function parseMarkdown($markdown) + { + if (empty($markdown)) { + return ''; + } + + $md = new MarkdownIt([ + 'html' => true, + 'linkify' => true, + ]); + + $md->plugin(new MarkdownItFootnote()); + + return $md->render($markdown); + } +} + +if (! function_exists('parseCountryField')) { + function parseCountryField($countryField) + { + if (empty($countryField)) { + return null; + } + + $delimiters = [',', '/', '&', ' and ']; + $countries = [$countryField]; + + foreach ($delimiters as $delimiter) { + $tempCountries = []; + + foreach ($countries as $country) { + $tempCountries = array_merge($tempCountries, explode($delimiter, $country)); + } + + $countries = $tempCountries; + } + + $countries = array_map('trim', $countries); + $countries = array_map('getCountryName', $countries); + $countries = array_filter($countries); + + return implode(', ', array_unique($countries)); + } +} + +if (! function_exists('getCountryName')) { function getCountryName($countryName) { - $isoCodes = new \Sokil\IsoCodes\IsoCodesFactory(); - $countries = $isoCodes->getCountries(); - $country = $countries->getByAlpha2($countryName); + $isoCodes = new \Sokil\IsoCodes\IsoCodesFactory(); + $countries = $isoCodes->getCountries(); + $country = $countries->getByAlpha2($countryName); - if ($country) return $country->getName(); + if ($country) { + return $country->getName(); + } - return ucfirst(strtolower($countryName)); + return ucfirst(strtolower($countryName)); } - } +} - if (!function_exists('pluralize')) { +if (! function_exists('pluralize')) { function pluralize($count, $string, $trailing = '') { - if ((int)$count === 1) return $string; + if ((int) $count === 1) { + return $string; + } - return $string . 's' . ($trailing ? $trailing : ''); + return $string.'s'.($trailing ? $trailing : ''); } - } +} diff --git a/app/Utils/tags.php b/app/Utils/tags.php index d3dd791..977bbff 100644 --- a/app/Utils/tags.php +++ b/app/Utils/tags.php @@ -1,17 +1,19 @@ '; + echo '
'; - foreach ($tags as $tag) { - $slug = strtolower(trim($tag)); - echo '#' . htmlspecialchars($slug) . ''; - } + foreach ($tags as $tag) { + $slug = strtolower(trim($tag)); + echo '#'.htmlspecialchars($slug).''; + } - echo '
'; + echo ''; } - } +} diff --git a/bootstrap.php b/bootstrap.php index 9a8ce9e..ca7bd5b 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -1,6 +1,6 @@