From 4bad005e5864db20165f5554cc300bf23b3f369a Mon Sep 17 00:00:00 2001 From: Cory Dransfeldt Date: Tue, 22 Apr 2025 12:39:42 -0700 Subject: [PATCH] feat(*.php, *.psql): deduplicate API code + performance improvements --- api/Classes/ApiHandler.php | 65 +-------- api/Classes/BaseHandler.php | 123 ++++++---------- api/artist-import.php | 79 ++++------- api/book-import.php | 55 +++----- api/contact.php | 26 ++-- api/mastodon.php | 171 ++++++++--------------- api/playing.php | 2 +- api/proxy.php | 90 ++++++++++++ api/scrobble.php | 124 +++++++--------- api/search.php | 8 +- api/seasons-import.php | 89 ++++-------- api/watching-import.php | 136 ++++++++---------- package-lock.json | 24 ++-- package.json | 2 +- queries/views/feeds/recent_activity.psql | 7 +- queries/views/media/music/concerts.psql | 2 - server/utils/strings.php | 2 - src/assets/scripts/index.js | 97 ++++++++----- src/assets/styles/base/fonts.css | 16 --- src/assets/styles/base/index.css | 2 +- src/assets/styles/base/vars.css | 3 +- src/assets/styles/components/dialog.css | 2 +- src/includes/blocks/dialog.liquid | 8 +- src/includes/home/recent-activity.liquid | 7 +- src/layouts/base.liquid | 2 - src/pages/dynamic/artist.php.liquid | 2 +- src/pages/dynamic/book.php.liquid | 2 +- src/pages/dynamic/genre.php.liquid | 4 +- src/pages/dynamic/show.php.liquid | 5 +- src/pages/dynamic/tags.php.liquid | 6 +- src/pages/media/music/concerts.html | 7 +- 31 files changed, 502 insertions(+), 666 deletions(-) create mode 100644 api/proxy.php diff --git a/api/Classes/ApiHandler.php b/api/Classes/ApiHandler.php index 9f42b30..5524427 100644 --- a/api/Classes/ApiHandler.php +++ b/api/Classes/ApiHandler.php @@ -1,72 +1,15 @@ loadEnvironment(); - } - - private function loadEnvironment(): void - { - $this->postgrestUrl = - $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?: ""; - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?: ""; - } - protected function ensureCliAccess(): void { - if (php_sapi_name() !== "cli" && $_SERVER["REQUEST_METHOD"] !== "POST") { - $this->redirectNotFound(); + if (php_sapi_name() !== 'cli' && $_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->sendErrorResponse("Not Found", 404); } } - - protected function redirectNotFound(): void - { - header("Location: /404", true, 302); - exit(); - } - - protected function fetchFromPostgREST( - string $endpoint, - string $query = "", - string $method = "GET", - ?array $body = null - ): array { - $url = "{$this->postgrestUrl}/{$endpoint}?{$query}"; - $options = [ - "headers" => [ - "Content-Type" => "application/json", - "Authorization" => "Bearer {$this->postgrestApiKey}", - ], - ]; - - if ($method === "POST" && $body) $options["json"] = $body; - - $response = (new Client())->request($method, $url, $options); - - return json_decode($response->getBody(), true) ?? []; - } - - protected function sendResponse(string $message, int $statusCode): void - { - http_response_code($statusCode); - header("Content-Type: application/json"); - echo json_encode(["message" => $message]); - exit(); - } - - protected function sendErrorResponse(string $message, int $statusCode): void - { - $this->sendResponse($message, $statusCode); - } } diff --git a/api/Classes/BaseHandler.php b/api/Classes/BaseHandler.php index d69989d..b526846 100644 --- a/api/Classes/BaseHandler.php +++ b/api/Classes/BaseHandler.php @@ -16,60 +16,71 @@ abstract class BaseHandler public function __construct() { $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 makeRequest( - string $method, - string $endpoint, - array $options = [] - ): array { + protected function initializeCache(): void + { + 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 = 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, "/"); try { - $response = $client->request( - $method, - $url, - array_merge($options, [ - "headers" => [ - "Authorization" => "Bearer {$this->postgrestApiKey}", - "Content-Type" => "application/json", - ], - ]) - ); + $response = $client->request($method, $url, array_merge_recursive([ + "headers" => [ + "Authorization" => "Bearer {$this->postgrestApiKey}", + "Content-Type" => "application/json", + ] + ], $options)); $responseBody = $response->getBody()->getContents(); - if (empty($responseBody)) return []; - $responseData = 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 response: {$responseBody}"); - - return $responseData; + return $data; } catch (RequestException $e) { $response = $e->getResponse(); - $statusCode = $response ? $response->getStatusCode() : "N/A"; - $responseBody = $response - ? $response->getBody()->getContents() - : "No response body"; + $statusCode = $response ? $response->getStatusCode() : 'N/A'; + $responseBody = $response ? $response->getBody()->getContents() : 'No response'; - throw new \Exception( - "Request to {$url} failed with status {$statusCode}. Response: {$responseBody}" - ); + throw new \Exception("HTTP {$method} {$url} failed with status {$statusCode}: {$responseBody}"); } catch (\Exception $e) { - throw new \Exception("Request to {$url} failed: " . $e->getMessage()); + throw new \Exception("Request error: " . $e->getMessage()); } } + protected function fetchFromApi(string $endpoint, string $query = ""): array + { + $url = $endpoint . ($query ? "?{$query}" : ""); + return $this->makeRequest("GET", $url); + } + protected function sendResponse(array $data, int $statusCode = 200): void { http_response_code($statusCode); @@ -78,52 +89,8 @@ abstract class BaseHandler exit(); } - protected function sendErrorResponse( - string $message, - int $statusCode = 500 - ): void { + protected function sendErrorResponse(string $message, int $statusCode = 500): void + { $this->sendResponse(["error" => $message], $statusCode); } - - protected function fetchFromApi(string $endpoint, string $query): array - { - $client = new Client(); - $url = - rtrim($this->postgrestUrl, "/") . - "/" . - ltrim($endpoint, "/") . - "?" . - $query; - - try { - $response = $client->request("GET", $url, [ - "headers" => [ - "Content-Type" => "application/json", - "Authorization" => "Bearer {$this->postgrestApiKey}", - ], - ]); - - if ($response->getStatusCode() !== 200) throw new Exception("API call to {$url} failed with status code " . $response->getStatusCode()); - - return json_decode($response->getBody(), true); - } catch (RequestException $e) { - throw new Exception("Error fetching from API: " . $e->getMessage()); - } - } - - protected function initializeCache(): void - { - if (class_exists("Redis")) { - $redis = new \Redis(); - try { - $redis->connect("127.0.0.1", 6379); - $this->cache = $redis; - } catch (Exception $e) { - error_log("Redis connection failed: " . $e->getMessage()); - $this->cache = null; - } - } else { - $this->cache = null; - } - } } diff --git a/api/artist-import.php b/api/artist-import.php index b3141a5..70a5934 100644 --- a/api/artist-import.php +++ b/api/artist-import.php @@ -8,9 +8,6 @@ use GuzzleHttp\Client; class ArtistImportHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $artistImportToken; private string $placeholderImageId = "4cef75db-831f-4f5d-9333-79eaa5bb55ee"; private string $navidromeApiUrl; @@ -20,13 +17,7 @@ class ArtistImportHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - } - private function loadEnvironment(): void - { - $this->postgrestUrl = getenv("POSTGREST_URL"); - $this->postgrestApiKey = getenv("POSTGREST_API_KEY"); $this->artistImportToken = getenv("ARTIST_IMPORT_TOKEN"); $this->navidromeApiUrl = getenv("NAVIDROME_API_URL"); $this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN"); @@ -41,11 +32,13 @@ class ArtistImportHandler extends ApiHandler $providedToken = $input["token"] ?? null; $artistId = $input["artistId"] ?? null; - if (!$providedToken || $providedToken !== $this->artistImportToken) { + if ($providedToken !== $this->artistImportToken) { $this->sendJsonResponse("error", "Unauthorized access", 401); } - if (!$artistId) $this->sendJsonResponse("error", "Artist ID is required", 400); + if (!$artistId) { + $this->sendJsonResponse("error", "Artist ID is required", 400); + } try { $artistData = $this->fetchNavidromeArtist($artistId); @@ -54,7 +47,7 @@ class ArtistImportHandler extends ApiHandler if ($artistExists) $this->processAlbums($artistId, $artistData->name); $this->sendJsonResponse("message", "Artist and albums synced successfully", 200); - } catch (Exception $e) { + } catch (\Exception $e) { $this->sendJsonResponse("error", "Error: " . $e->getMessage(), 500); } } @@ -67,7 +60,7 @@ class ArtistImportHandler extends ApiHandler exit(); } - private function fetchNavidromeArtist(string $artistId) + private function fetchNavidromeArtist(string $artistId): object { $client = new Client(); $response = $client->get("{$this->navidromeApiUrl}/api/artist/{$artistId}", [ @@ -77,7 +70,7 @@ class ArtistImportHandler extends ApiHandler ] ]); - return json_decode($response->getBody(), false); + return json_decode($response->getBody()); } private function fetchNavidromeAlbums(string $artistId): array @@ -103,16 +96,14 @@ class ArtistImportHandler extends ApiHandler private function processArtist(object $artistData): bool { $artistName = $artistData->name ?? ""; - - if (!$artistName) throw new Exception("Artist name is missing from Navidrome data."); + if (!$artistName) throw new \Exception("Artist name is missing."); $existingArtist = $this->getArtistByName($artistName); - if ($existingArtist) return true; $artistKey = sanitizeMediaString($artistName); $slug = "/music/artists/{$artistKey}"; - $description = strip_tags($artistData->biography) ?? ""; + $description = strip_tags($artistData->biography ?? ""); $genre = $this->resolveGenreId($artistData->genres[0]->name ?? ""); $starred = $artistData->starred ?? false; @@ -127,35 +118,34 @@ class ArtistImportHandler extends ApiHandler "genres" => $genre, ]; - $this->saveArtist($artistPayload); - + $this->makeRequest("POST", "artists", ["json" => $artistPayload]); return true; } private function processAlbums(string $artistId, string $artistName): void { $artist = $this->getArtistByName($artistName); - - if (!$artist) throw new Exception("Artist not found in the database."); + if (!$artist) throw new \Exception("Artist not found after insert."); $existingAlbums = $this->getExistingAlbums($artist["id"]); $existingAlbumKeys = array_column($existingAlbums, "key"); + $navidromeAlbums = $this->fetchNavidromeAlbums($artistId); foreach ($navidromeAlbums as $album) { - $albumName = $album["name"]; + $albumName = $album["name"] ?? ""; $releaseYearRaw = $album["date"] ?? null; $releaseYear = null; - if ($releaseYearRaw) { - if (preg_match('/^\d{4}/', $releaseYearRaw, $matches)) $releaseYear = (int)$matches[0]; + if ($releaseYearRaw && preg_match('/^\d{4}/', $releaseYearRaw, $matches)) { + $releaseYear = (int)$matches[0]; } $artistKey = sanitizeMediaString($artistName); - $albumKey = $artistKey . "-" . sanitizeMediaString($albumName); + $albumKey = "{$artistKey}-" . sanitizeMediaString($albumName); if (in_array($albumKey, $existingAlbumKeys)) { - error_log("Skipping existing album: " . $albumName); + error_log("Skipping existing album: {$albumName}"); continue; } @@ -170,8 +160,8 @@ class ArtistImportHandler extends ApiHandler "tentative" => true, ]; - $this->saveAlbum($albumPayload); - } catch (Exception $e) { + $this->makeRequest("POST", "albums", ["json" => $albumPayload]); + } catch (\Exception $e) { error_log("Error adding album '{$albumName}': " . $e->getMessage()); } } @@ -179,34 +169,19 @@ class ArtistImportHandler extends ApiHandler private function getArtistByName(string $nameString): ?array { - $query = "name_string=eq." . urlencode($nameString); - $response = $this->fetchFromPostgREST("artists", $query, "GET"); - + $response = $this->fetchFromApi("artists", "name_string=eq." . urlencode($nameString)); return $response[0] ?? null; } - private function saveArtist(array $artistPayload): void - { - $this->fetchFromPostgREST("artists", "", "POST", $artistPayload); - } - - private function saveAlbum(array $albumPayload): void - { - $this->fetchFromPostgREST("albums", "", "POST", $albumPayload); - } - - private function resolveGenreId(string $genreName): ?string - { - $genres = $this->fetchFromPostgREST("genres", "name=eq." . urlencode(strtolower($genreName)), "GET"); - - if (!empty($genres)) return $genres[0]["id"]; - - return null; - } - private function getExistingAlbums(string $artistId): array { - return $this->fetchFromPostgREST("albums", "artist=eq." . urlencode($artistId), "GET"); + 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; } } diff --git a/api/book-import.php b/api/book-import.php index 026c19e..4d52b0f 100644 --- a/api/book-import.php +++ b/api/book-import.php @@ -7,46 +7,39 @@ use GuzzleHttp\Client; class BookImportHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $bookImportToken; public function __construct() { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - } - - private function loadEnvironment(): void - { - $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL"); - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY"); - $this->bookImportToken = - $_ENV["BOOK_IMPORT_TOKEN"] ?? getenv("BOOK_IMPORT_TOKEN"); + $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); + if (!$input) { + $this->sendErrorResponse("Invalid or missing JSON body", 400); + } $providedToken = $input["token"] ?? null; $isbn = $input["isbn"] ?? null; - if (!$providedToken || $providedToken !== $this->bookImportToken) $this->sendErrorResponse("Unauthorized access", 401); + if ($providedToken !== $this->bookImportToken) { + $this->sendErrorResponse("Unauthorized access", 401); + } - if (!$isbn) $this->sendErrorResponse("isbn parameter is required", 400); + if (!$isbn) { + $this->sendErrorResponse("isbn parameter is required", 400); + } try { $bookData = $this->fetchBookData($isbn); $this->processBook($bookData); - - $this->sendResponse("Book imported successfully", 200); - } catch (Exception $e) { + $this->sendResponse(["message" => "Book imported successfully"], 200); + } catch (\Exception $e) { $this->sendErrorResponse("Error: " . $e->getMessage(), 500); } } @@ -66,7 +59,9 @@ class BookImportHandler extends ApiHandler $data = json_decode($response->getBody(), true); $bookKey = "ISBN:{$isbn}"; - if (empty($data[$bookKey])) throw new Exception("Book data not found for ISBN: {$isbn}"); + if (empty($data[$bookKey])) { + throw new \Exception("Book data not found for ISBN: {$isbn}"); + } return $data[$bookKey]; } @@ -80,11 +75,14 @@ class BookImportHandler extends ApiHandler $author = $bookData["authors"][0]["name"] ?? null; $description = $bookData["description"] ?? ($bookData["notes"] ?? ""); - if (!$isbn || !$title || !$author) throw new Exception("Missing essential book data (title, author, or ISBN)."); + if (!$isbn || !$title || !$author) { + throw new \Exception("Missing essential book data (title, author, or ISBN)."); + } $existingBook = $this->getBookByISBN($isbn); - - if ($existingBook) throw new Exception("Book with ISBN {$isbn} already exists."); + if ($existingBook) { + throw new \Exception("Book with ISBN {$isbn} already exists."); + } $bookPayload = [ "isbn" => $isbn, @@ -95,19 +93,12 @@ class BookImportHandler extends ApiHandler "slug" => "/books/" . $isbn, ]; - $this->saveBook($bookPayload); - } - - private function saveBook(array $bookPayload): void - { - $this->fetchFromPostgREST("books", "", "POST", $bookPayload); + $this->makeRequest("POST", "books", ["json" => $bookPayload]); } private function getBookByISBN(string $isbn): ?array { - $query = "isbn=eq." . urlencode($isbn); - $response = $this->fetchFromPostgREST("books", $query, "GET"); - + $response = $this->fetchFromApi("books", "isbn=eq." . urlencode($isbn)); return $response[0] ?? null; } } diff --git a/api/contact.php b/api/contact.php index dc0b682..b440ad0 100644 --- a/api/contact.php +++ b/api/contact.php @@ -15,17 +15,9 @@ class ContactHandler extends BaseHandler public function __construct(?Client $httpClient = null) { + parent::__construct(); $this->httpClient = $httpClient ?? new Client(); - $this->loadEnvironment(); - } - - private function loadEnvironment(): void - { - $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL"); - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY"); - $this->forwardEmailApiKey = - $_ENV["FORWARDEMAIL_API_KEY"] ?? getenv("FORWARDEMAIL_API_KEY"); + $this->forwardEmailApiKey = $_ENV["FORWARDEMAIL_API_KEY"] ?? getenv("FORWARDEMAIL_API_KEY"); } public function handleRequest(): void @@ -42,7 +34,7 @@ class ContactHandler extends BaseHandler $rawBody = file_get_contents("php://input"); $formData = json_decode($rawBody, true); if (!$formData || !isset($formData["data"])) { - throw new Exception("Invalid JSON payload."); + throw new \Exception("Invalid JSON payload."); } $formData = $formData["data"]; } elseif ( @@ -93,7 +85,7 @@ class ContactHandler extends BaseHandler $this->saveToDatabase($contactData); $this->sendNotificationEmail($contactData); $this->sendRedirect("/contact/success"); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("Error handling contact form submission: " . $e->getMessage()); $this->sendErrorResponse($e->getMessage(), 400); } @@ -103,7 +95,7 @@ class ContactHandler extends BaseHandler { $referer = $_SERVER["HTTP_REFERER"] ?? ""; $allowedDomain = "coryd.dev"; - if (!str_contains($referer, $allowedDomain)) throw new Exception("Invalid submission origin."); + if (!str_contains($referer, $allowedDomain)) throw new \Exception("Invalid submission origin."); } private function checkRateLimit(): void @@ -132,7 +124,7 @@ class ContactHandler extends BaseHandler private function enforceHttps(): void { - if (empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] !== "on") throw new Exception("Secure connection required. Use HTTPS."); + if (empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] !== "on") throw new \Exception("Secure connection required. Use HTTPS."); } private function isBlockedDomain(string $email): bool @@ -171,7 +163,7 @@ class ContactHandler extends BaseHandler if ($response->getStatusCode() >= 400) { $errorResponse = json_decode($response->getBody(), true); - throw new Exception( + throw new \Exception( "PostgREST error: " . ($errorResponse["message"] ?? "Unknown error") ); } @@ -204,7 +196,7 @@ class ContactHandler extends BaseHandler ] ); - if ($response->getStatusCode() >= 400) throw new Exception("Failed to send email notification."); + if ($response->getStatusCode() >= 400) throw new \Exception("Failed to send email notification."); } private function sendRedirect(string $path): void @@ -221,7 +213,7 @@ class ContactHandler extends BaseHandler try { $handler = new ContactHandler(); $handler->handleRequest(); -} catch (Exception $e) { +} catch (\Exception $e) { error_log("Contact form error: " . $e->getMessage()); echo json_encode(["error" => $e->getMessage()]); http_response_code(500); diff --git a/api/mastodon.php b/api/mastodon.php index b1ace34..6019dd7 100644 --- a/api/mastodon.php +++ b/api/mastodon.php @@ -7,12 +7,9 @@ use GuzzleHttp\Client; class MastodonPostHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $mastodonAccessToken; - private string $rssFeedUrl; - private string $baseUrl; + private string $rssFeedUrl = "https://www.coryd.dev/feeds/syndication.xml"; + private string $baseUrl = "https://www.coryd.dev"; private const MASTODON_API_STATUS = "https://follow.coryd.dev/api/v1/statuses"; @@ -22,21 +19,11 @@ class MastodonPostHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - $this->validateAuthorization(); - $this->httpClient = $httpClient ?: new Client(); - } - private function loadEnvironment(): void - { - $this->postgrestUrl = - getenv("POSTGREST_URL") ?: $_ENV["POSTGREST_URL"] ?? ""; - $this->postgrestApiKey = - getenv("POSTGREST_API_KEY") ?: $_ENV["POSTGREST_API_KEY"] ?? ""; - $this->mastodonAccessToken = - getenv("MASTODON_ACCESS_TOKEN") ?: $_ENV["MASTODON_ACCESS_TOKEN"] ?? ""; - $this->rssFeedUrl = "https://www.coryd.dev/feeds/syndication.xml"; - $this->baseUrl = "https://www.coryd.dev"; + $this->mastodonAccessToken = getenv("MASTODON_ACCESS_TOKEN") ?: $_ENV["MASTODON_ACCESS_TOKEN"] ?? ""; + $this->httpClient = $httpClient ?: new Client(); + + $this->validateAuthorization(); } private function validateAuthorization(): void @@ -46,7 +33,7 @@ class MastodonPostHandler extends ApiHandler if ($authHeader !== $expectedToken) { http_response_code(401); - echo json_encode(["error" => "Unauthorized."]); + echo json_encode(["error" => "Unauthorized"]); exit(); } } @@ -61,16 +48,16 @@ class MastodonPostHandler extends ApiHandler $latestItems = $this->fetchRSSFeed($this->rssFeedUrl); foreach (array_reverse($latestItems) as $item) { - $existingPost = $this->fetchFromPostgREST("mastodon_posts", "link=eq." . urlencode($item["link"])); - - if (!empty($existingPost)) continue; + $existing = $this->fetchFromApi("mastodon_posts", "link=eq." . urlencode($item["link"])); + if (!empty($existing)) continue; $content = $this->truncateContent( $item["title"], - str_replace(array("\n", "\r"), '', strip_tags($item["description"])), + strip_tags($item["description"]), $item["link"], 500 ); + $timestamp = date("Y-m-d H:i:s"); if (!$this->storeInDatabase($item["link"], $timestamp)) { @@ -78,15 +65,11 @@ class MastodonPostHandler extends ApiHandler continue; } - $mastodonPostUrl = $this->postToMastodon($content, $item["image"] ?? null); - - if ($mastodonPostUrl) { - if (strpos($item["link"], $this->baseUrl . "/posts") !== false) { - $slug = str_replace($this->baseUrl, "", $item["link"]); - echo "Posted and stored URL: {$item["link"]}\n"; - } + $postedUrl = $this->postToMastodon($content, $item["image"] ?? null); + if ($postedUrl) { + echo "Posted: {$postedUrl}\n"; } else { - echo "Failed to post to Mastodon. Skipping database update.\n"; + echo "Failed to post to Mastodon for: {$item["link"]}\n"; } } @@ -96,16 +79,16 @@ class MastodonPostHandler extends ApiHandler private function fetchRSSFeed(string $rssFeedUrl): array { $rssText = file_get_contents($rssFeedUrl); - - if (!$rssText) throw new Exception("Failed to fetch RSS feed."); + if (!$rssText) throw new \Exception("Failed to fetch RSS feed."); $rss = new \SimpleXMLElement($rssText); $items = []; foreach ($rss->channel->item as $item) { $imageUrl = null; - - if ($item->enclosure && isset($item->enclosure['url'])) $imageUrl = (string) $item->enclosure['url']; + if ($item->enclosure && isset($item->enclosure['url'])) { + $imageUrl = (string) $item->enclosure['url']; + } $items[] = [ "title" => (string) $item->title, @@ -120,15 +103,13 @@ class MastodonPostHandler extends ApiHandler private function uploadImageToMastodon(string $imageUrl): ?string { - $headers = [ - "Authorization" => "Bearer {$this->mastodonAccessToken}" - ]; - $tempFile = tempnam(sys_get_temp_dir(), "mastodon_img"); file_put_contents($tempFile, file_get_contents($imageUrl)); $response = $this->httpClient->request("POST", "https://follow.coryd.dev/api/v2/media", [ - "headers" => $headers, + "headers" => [ + "Authorization" => "Bearer {$this->mastodonAccessToken}" + ], "multipart" => [ [ "name" => "file", @@ -140,13 +121,12 @@ class MastodonPostHandler extends ApiHandler unlink($tempFile); - $statusCode = $response->getStatusCode(); + if ($response->getStatusCode() !== 200) { + throw new \Exception("Image upload failed with status {$response->getStatusCode()}"); + } - if ($statusCode !== 200) throw new Exception("Image upload failed with status $statusCode."); - - $responseBody = json_decode($response->getBody()->getContents(), true); - - return $responseBody["id"] ?? null; + $json = json_decode($response->getBody(), true); + return $json["id"] ?? null; } private function postToMastodon(string $content, ?string $imageUrl = null): ?string @@ -156,43 +136,42 @@ class MastodonPostHandler extends ApiHandler "Content-Type" => "application/json", ]; - $mediaIds = []; + $postData = ["status" => $content]; if ($imageUrl) { try { $mediaId = $this->uploadImageToMastodon($imageUrl); - if ($mediaId) $mediaIds[] = $mediaId; - } catch (Exception $e) { - echo "Image upload failed: " . $e->getMessage() . "\n"; + if ($mediaId) $postData["media_ids"] = [$mediaId]; + } catch (\Exception $e) { + echo "Image upload failed: {$e->getMessage()}\n"; } } - $postData = ["status" => $content]; + $response = $this->httpClient->request("POST", self::MASTODON_API_STATUS, [ + "headers" => $headers, + "json" => $postData + ]); - if (!empty($mediaIds)) $postData["media_ids"] = $mediaIds; + if ($response->getStatusCode() >= 400) { + throw new \Exception("Mastodon post failed: {$response->getBody()}"); + } - $response = $this->httpRequest( - self::MASTODON_API_STATUS, - "POST", - $headers, - $postData - ); - - return $response["url"] ?? null; + $body = json_decode($response->getBody()->getContents(), true); + return $body["url"] ?? null; } private function storeInDatabase(string $link, string $timestamp): bool { - $data = [ - "link" => $link, - "created_at" => $timestamp, - ]; - try { - $this->fetchFromPostgREST("mastodon_posts", "", "POST", $data); + $this->makeRequest("POST", "mastodon_posts", [ + "json" => [ + "link" => $link, + "created_at" => $timestamp + ] + ]); return true; - } catch (Exception $e) { - echo "Error storing post in database: " . $e->getMessage() . "\n"; + } catch (\Exception $e) { + echo "Error storing post in DB: " . $e->getMessage() . "\n"; return false; } } @@ -200,70 +179,32 @@ class MastodonPostHandler extends ApiHandler private function isDatabaseAvailable(): bool { try { - $response = $this->fetchFromPostgREST("mastodon_posts", "limit=1"); + $response = $this->fetchFromApi("mastodon_posts", "limit=1"); return is_array($response); - } catch (Exception $e) { + } catch (\Exception $e) { echo "Database check failed: " . $e->getMessage() . "\n"; return false; } } - private function truncateContent( - string $title, - string $description, - string $link, - int $maxLength - ): string { + private function truncateContent(string $title, string $description, string $link, int $maxLength): string + { $baseLength = strlen("$title\n\n$link"); - $availableSpace = $maxLength - $baseLength - 4; + $available = $maxLength - $baseLength - 4; - if (strlen($description) > $availableSpace) { - $description = substr($description, 0, $availableSpace); + if (strlen($description) > $available) { + $description = substr($description, 0, $available); $description = preg_replace('/\s+\S*$/', "", $description) . "..."; } return "$title\n\n$description\n\n$link"; } - - private function httpRequest( - string $url, - string $method = "GET", - array $headers = [], - ?array $data = null - ): array { - $options = ["headers" => $headers]; - - if ($data) $options["json"] = $data; - - $response = $this->httpClient->request($method, $url, $options); - $statusCode = $response->getStatusCode(); - - if ($statusCode >= 400) throw new Exception("HTTP error $statusCode: " . $response->getBody()); - - $responseBody = $response->getBody()->getContents(); - - if (empty($responseBody)) return []; - - $decodedResponse = json_decode($responseBody, true); - - if (!is_array($decodedResponse)) return []; - - return $decodedResponse; - } - - private function getPostgRESTHeaders(): array - { - return [ - "Authorization" => "Bearer {$this->postgrestApiKey}", - "Content-Type" => "application/json", - ]; - } } try { $handler = new MastodonPostHandler(); $handler->handlePost(); -} catch (Exception $e) { +} catch (\Exception $e) { http_response_code(500); echo json_encode(["error" => $e->getMessage()]); } diff --git a/api/playing.php b/api/playing.php index 2e41507..7cb8506 100644 --- a/api/playing.php +++ b/api/playing.php @@ -41,7 +41,7 @@ class LatestListenHandler extends BaseHandler ); $this->sendResponse($latestListen); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("LatestListenHandler Error: " . $e->getMessage()); $this->sendErrorResponse( "Internal Server Error: " . $e->getMessage(), diff --git a/api/proxy.php b/api/proxy.php new file mode 100644 index 0000000..090e65a --- /dev/null +++ b/api/proxy.php @@ -0,0 +1,90 @@ +ensureAllowedOrigin(); + } + + protected function ensureAllowedOrigin(): void + { + $allowedHosts = ['coryd.dev', 'www.coryd.dev']; + $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(); + } + } + + $query = $id ? "id=eq.$id" : ""; + + try { + $response = $this->fetchFromApi($data, $query); + $markdownFields = $_GET['markdown'] ?? []; + $markdownFields = is_array($markdownFields) + ? $markdownFields + : explode(',', $markdownFields); + $markdownFields = array_map('trim', array_filter($markdownFields)); + + if (!empty($response) && !empty($markdownFields)) { + foreach ($markdownFields as $field) { + if (!empty($response[0][$field])) $response[0]["{$field}_html"] = parseMarkdown($response[0][$field]); + } + } + + $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}" : ""); + } +} + +$handler = new ProxyHandler(); +$handler->handleRequest(); diff --git a/api/scrobble.php b/api/scrobble.php index 497fa0b..80353a2 100644 --- a/api/scrobble.php +++ b/api/scrobble.php @@ -10,13 +10,8 @@ use GuzzleHttp\Client; header("Content-Type: application/json"); -$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? ""; -$expectedToken = "Bearer " . getenv("NAVIDROME_SCROBBLE_TOKEN"); - class NavidromeScrobbleHandler extends ApiHandler { - private string $postgrestApiUrl; - private string $postgrestApiToken; private string $navidromeApiUrl; private string $navidromeAuthToken; private string $forwardEmailApiKey; @@ -28,14 +23,12 @@ class NavidromeScrobbleHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); + $this->loadExternalServiceKeys(); $this->validateAuthorization(); } - private function loadEnvironment(): void + private function loadExternalServiceKeys(): void { - $this->postgrestApiUrl = getenv("POSTGREST_URL"); - $this->postgrestApiToken = getenv("POSTGREST_API_KEY"); $this->navidromeApiUrl = getenv("NAVIDROME_API_URL"); $this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN"); $this->forwardEmailApiKey = getenv("FORWARDEMAIL_API_KEY"); @@ -95,7 +88,7 @@ class NavidromeScrobbleHandler extends ApiHandler private function isTrackAlreadyScrobbled(array $track): bool { $playDate = strtotime($track["playDate"]); - $existingListen = $this->fetchFromPostgREST("listens", "listened_at=eq.{$playDate}&limit=1"); + $existingListen = $this->fetchFromApi("listens", "listened_at=eq.{$playDate}&limit=1"); return !empty($existingListen); } @@ -121,61 +114,52 @@ class NavidromeScrobbleHandler extends ApiHandler private function getOrCreateArtist(string $artistName): array { - if (!$this->isDatabaseAvailable()) { - error_log("Skipping artist insert: database is unavailable."); - return []; - } + if (!$this->isDatabaseAvailable()) return []; if (isset($this->artistCache[$artistName])) return $this->artistCache[$artistName]; $encodedArtist = rawurlencode($artistName); - $existingArtist = $this->fetchFromPostgREST("artists", "name_string=eq.{$encodedArtist}&limit=1"); + $existingArtist = $this->fetchFromApi("artists", "name_string=eq.{$encodedArtist}&limit=1"); if (!empty($existingArtist)) { - $this->artistCache[$artistName] = $existingArtist[0]; - return $existingArtist[0]; + return $this->artistCache[$artistName] = $existingArtist[0]; } - $this->fetchFromPostgREST("artists", "", "POST", [ - "mbid" => "", - "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee", - "name_string" => $artistName, - "slug" => "/music", - "country" => "", - "description" => "", - "tentative" => true, - "favorite" => false, - "tattoo" => false, - "total_plays" => 0 + $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 + ] ]); - $this->sendFailureEmail("New tentative artist record", "A new tentative artist record was inserted for: $artistName"); - $artistData = $this->fetchFromPostgREST("artists", "name_string=eq.{$encodedArtist}&limit=1"); + $artistData = $this->fetchFromApi("artists", "name_string=eq.{$encodedArtist}&limit=1"); - $this->artistCache[$artistName] = $artistData[0] ?? []; - - return $this->artistCache[$artistName]; + return $this->artistCache[$artistName] = $artistData[0] ?? []; } private function getOrCreateAlbum(string $albumName, array $artistData): array { - if (!$this->isDatabaseAvailable()) { - error_log("Skipping album insert: database is unavailable."); - return []; - } + 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->fetchFromPostgREST("albums", "key=eq.{$encodedAlbumKey}&limit=1"); + $existingAlbum = $this->fetchFromApi("albums", "key=eq.{$encodedAlbumKey}&limit=1"); if (!empty($existingAlbum)) { - $this->albumCache[$albumKey] = $existingAlbum[0]; - return $existingAlbum[0]; + return $this->albumCache[$albumKey] = $existingAlbum[0]; } $artistId = $artistData["id"] ?? null; @@ -185,35 +169,37 @@ class NavidromeScrobbleHandler extends ApiHandler return []; } - $this->fetchFromPostgREST("albums", "", "POST", [ - "mbid" => null, - "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee", - "key" => $albumKey, - "name" => $albumName, - "tentative" => true, - "total_plays" => 0, - "artist" => $artistId + $this->makeRequest("POST", "albums", [ + "json" => [ + "mbid" => null, + "art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee", + "key" => $albumKey, + "name" => $albumName, + "tentative" => true, + "total_plays" => 0, + "artist" => $artistId + ] ]); $this->sendFailureEmail("New tentative album record", "A new tentative album record was inserted:\n\nAlbum: $albumName\nKey: $albumKey"); - $albumData = $this->fetchFromPostgREST("albums", "key=eq.{$encodedAlbumKey}&limit=1"); + $albumData = $this->fetchFromApi("albums", "key=eq.{$encodedAlbumKey}&limit=1"); - $this->albumCache[$albumKey] = $albumData[0] ?? []; - - return $this->albumCache[$albumKey]; + return $this->albumCache[$albumKey] = $albumData[0] ?? []; } private function insertListen(array $track, string $albumKey): void { $playDate = strtotime($track["playDate"]); - $this->fetchFromPostgREST("listens", "", "POST", [ - "artist_name" => $track["artist"], - "album_name" => $track["album"], - "track_name" => $track["title"], - "listened_at" => $playDate, - "album_key" => $albumKey + $this->makeRequest("POST", "listens", [ + "json" => [ + "artist_name" => $track["artist"], + "album_name" => $track["album"], + "track_name" => $track["title"], + "listened_at" => $playDate, + "album_key" => $albumKey + ] ]); } @@ -221,24 +207,18 @@ class NavidromeScrobbleHandler extends ApiHandler { $artistKey = sanitizeMediaString($artistName); $albumKey = sanitizeMediaString($albumName); - return "{$artistKey}-{$albumKey}"; } private function sendFailureEmail(string $subject, string $message): void { - if (!$this->isDatabaseAvailable()) { - error_log("Skipping email: database is unavailable."); - return; - } + if (!$this->isDatabaseAvailable()) return; $authHeader = "Basic " . base64_encode($this->forwardEmailApiKey . ":"); - $client = new Client([ - "base_uri" => "https://api.forwardemail.net/", - ]); + $client = new Client(["base_uri" => "https://api.forwardemail.net/"]); try { - $response = $client->post("v1/emails", [ + $client->post("v1/emails", [ "headers" => [ "Authorization" => $authHeader, "Content-Type" => "application/x-www-form-urlencoded", @@ -250,12 +230,10 @@ class NavidromeScrobbleHandler extends ApiHandler "text" => $message, ], ]); - } catch (\GuzzleHttp\Exception\RequestException $e) { error_log("Request Exception: " . $e->getMessage()); if ($e->hasResponse()) { - $errorResponse = (string) $e->getResponse()->getBody(); - error_log("Error Response: " . $errorResponse); + error_log("Error Response: " . (string) $e->getResponse()->getBody()); } } catch (\Exception $e) { error_log("General Exception: " . $e->getMessage()); @@ -265,9 +243,9 @@ class NavidromeScrobbleHandler extends ApiHandler private function isDatabaseAvailable(): bool { try { - $response = $this->fetchFromPostgREST("listens", "limit=1"); + $response = $this->fetchFromApi("listens", "limit=1"); return is_array($response); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("Database check failed: " . $e->getMessage()); return false; } @@ -277,7 +255,7 @@ class NavidromeScrobbleHandler extends ApiHandler try { $handler = new NavidromeScrobbleHandler(); $handler->runScrobbleCheck(); -} catch (Exception $e) { +} catch (\Exception $e) { http_response_code(500); echo json_encode(["error" => $e->getMessage()]); } diff --git a/api/search.php b/api/search.php index 42b77e6..c313955 100644 --- a/api/search.php +++ b/api/search.php @@ -47,7 +47,7 @@ class SearchHandler extends BaseHandler ], 200 ); - } catch (Exception $e) { + } catch (\Exception $e) { error_log("Search API Error: " . $e->getMessage()); $this->sendErrorResponse("Invalid request. Please check your query and try again.", 400); } @@ -55,15 +55,15 @@ class SearchHandler extends BaseHandler private function validateAndSanitizeQuery(?string $query): string { - if (empty($query) || !is_string($query)) throw new Exception("Invalid 'q' parameter. Must be a non-empty 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( + 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( + if (!preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) throw new \Exception( "Invalid 'q' parameter. Contains unsupported characters." ); diff --git a/api/seasons-import.php b/api/seasons-import.php index cb3b733..2e18bd4 100644 --- a/api/seasons-import.php +++ b/api/seasons-import.php @@ -7,9 +7,6 @@ use GuzzleHttp\Client; class SeasonImportHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $tmdbApiKey; private string $seasonsImportToken; @@ -17,62 +14,43 @@ class SeasonImportHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - $this->authenticateRequest(); - } - private function loadEnvironment(): void - { - $this->postgrestUrl = getenv("POSTGREST_URL") ?: $_ENV["POSTGREST_URL"]; - $this->postgrestApiKey = getenv("POSTGREST_API_KEY") ?: $_ENV["POSTGREST_API_KEY"]; $this->tmdbApiKey = getenv("TMDB_API_KEY") ?: $_ENV["TMDB_API_KEY"]; $this->seasonsImportToken = getenv("SEASONS_IMPORT_TOKEN") ?: $_ENV["SEASONS_IMPORT_TOKEN"]; + + $this->authenticateRequest(); } private function authenticateRequest(): void { if ($_SERVER["REQUEST_METHOD"] !== "POST") { - http_response_code(405); - echo json_encode(["error" => "Method Not Allowed"]); - exit(); + $this->sendErrorResponse("Method Not Allowed", 405); } $authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? ""; if (!preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) { - http_response_code(401); - echo json_encode(["error" => "Unauthorized"]); - exit(); + $this->sendErrorResponse("Unauthorized", 401); } $providedToken = trim($matches[1]); if ($providedToken !== $this->seasonsImportToken) { - http_response_code(403); - echo json_encode(["error" => "Forbidden"]); - exit(); + $this->sendErrorResponse("Forbidden", 403); } } public function importSeasons(): void { - $ongoingShows = $this->fetchOngoingShows(); + $ongoingShows = $this->fetchFromApi("optimized_shows", "ongoing=eq.true"); if (empty($ongoingShows)) { - http_response_code(200); - echo json_encode(["message" => "No ongoing shows to update"]); - return; + $this->sendResponse(["message" => "No ongoing shows to update"], 200); } foreach ($ongoingShows as $show) { $this->processShowSeasons($show); } - http_response_code(200); - echo json_encode(["message" => "Season import completed"]); - } - - private function fetchOngoingShows(): array - { - return $this->fetchFromPostgREST("optimized_shows", "ongoing=eq.true", "GET"); + $this->sendResponse(["message" => "Season import completed"], 200); } private function processShowSeasons(array $show): void @@ -98,8 +76,7 @@ class SeasonImportHandler extends ApiHandler private function shouldKeepOngoing(string $status): bool { - $validStatuses = ["Returning Series", "In Production"]; - return in_array($status, $validStatuses); + return in_array($status, ["Returning Series", "In Production"]); } private function fetchShowDetails(string $tmdbId): array @@ -117,49 +94,40 @@ class SeasonImportHandler extends ApiHandler private function fetchWatchedEpisodes(int $showId): array { - $watchedEpisodes = $this->fetchFromPostgREST( - "optimized_last_watched_episodes", - "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1", - "GET" - ); + $episodes = $this->fetchFromApi("optimized_last_watched_episodes", "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1"); - if (empty($watchedEpisodes)) return []; + if (empty($episodes)) return []; - $lastWatched = $watchedEpisodes[0] ?? null; - - if ($lastWatched) return [ - "season_number" => (int) $lastWatched["season_number"], - "episode_number" => (int) $lastWatched["episode_number"] - ]; - - 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; - $watchedEpisodes = $this->fetchWatchedEpisodes($showId); - $lastWatchedSeason = $watchedEpisodes["season_number"] ?? null; - $lastWatchedEpisode = $watchedEpisodes["episode_number"] ?? null; - $scheduledEpisodes = $this->fetchFromPostgREST( - "optimized_scheduled_episodes", - "show_id=eq.{$showId}&season_number=eq.{$seasonNumber}", - "GET" + $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($scheduledEpisodes, "episode_number"); + + $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; @@ -183,11 +151,10 @@ class SeasonImportHandler extends ApiHandler private function addEpisodeToSchedule(int $showId, int $seasonNumber, array $episode): void { $airDate = $episode["air_date"] ?? null; - if (!$airDate) return; - $currentDate = date("Y-m-d"); - $status = ($airDate && $airDate < $currentDate) ? "aired" : "upcoming"; + $today = date("Y-m-d"); + $status = ($airDate < $today) ? "aired" : "upcoming"; $payload = [ "show_id" => $showId, @@ -197,7 +164,7 @@ class SeasonImportHandler extends ApiHandler "status" => $status, ]; - $this->fetchFromPostgREST("scheduled_episodes", "", "POST", $payload); + $this->makeRequest("POST", "scheduled_episodes", ["json" => $payload]); } } diff --git a/api/watching-import.php b/api/watching-import.php index 1c3ea06..dc9283f 100644 --- a/api/watching-import.php +++ b/api/watching-import.php @@ -7,9 +7,6 @@ use GuzzleHttp\Client; class WatchingImportHandler extends ApiHandler { - protected string $postgrestUrl; - protected string $postgrestApiKey; - private string $tmdbApiKey; private string $tmdbImportToken; @@ -17,17 +14,9 @@ class WatchingImportHandler extends ApiHandler { parent::__construct(); $this->ensureCliAccess(); - $this->loadEnvironment(); - } - private function loadEnvironment(): void - { - $this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL"); - $this->postgrestApiKey = - $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY"); $this->tmdbApiKey = $_ENV["TMDB_API_KEY"] ?? getenv("TMDB_API_KEY"); - $this->tmdbImportToken = - $_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_IMPORT_TOKEN"); + $this->tmdbImportToken = $_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_IMPORT_TOKEN"); } public function handleRequest(): void @@ -37,19 +26,22 @@ class WatchingImportHandler extends ApiHandler if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400); $providedToken = $input["token"] ?? null; - - if (!$providedToken || $providedToken !== $this->tmdbImportToken) $this->sendErrorResponse("Unauthorized access", 401); - $tmdbId = $input["tmdb_id"] ?? null; $mediaType = $input["media_type"] ?? null; - if (!$tmdbId || !$mediaType) $this->sendErrorResponse("tmdb_id and media_type are required", 400); + 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("Media imported successfully", 200); - } catch (Exception $e) { + $this->sendResponse(["message" => "Media imported successfully"], 200); + } catch (\Exception $e) { $this->sendErrorResponse("Error: " . $e->getMessage(), 500); } } @@ -65,8 +57,7 @@ class WatchingImportHandler extends ApiHandler ]); $data = json_decode($response->getBody(), true); - - if (empty($data)) throw new Exception("No data found for TMDB ID: {$tmdbId}"); + if (empty($data)) throw new \Exception("No data found for TMDB ID: {$tmdbId}"); return $data; } @@ -75,18 +66,19 @@ class WatchingImportHandler extends ApiHandler { $id = $mediaData["id"]; $title = $mediaType === "movie" ? $mediaData["title"] : $mediaData["name"]; - $year = - $mediaData["release_date"] ?? ($mediaData["first_air_date"] ?? null); + $year = $mediaData["release_date"] ?? $mediaData["first_air_date"] ?? null; $year = $year ? substr($year, 0, 4) : null; $description = $mediaData["overview"] ?? ""; + $tags = array_map( fn($genre) => strtolower(trim($genre["name"])), - $mediaData["genres"] + $mediaData["genres"] ?? [] ); - $slug = - $mediaType === "movie" - ? "/watching/movies/{$id}" - : "/watching/shows/{$id}"; + + $slug = $mediaType === "movie" + ? "/watching/movies/{$id}" + : "/watching/shows/{$id}"; + $payload = [ "title" => $title, "year" => $year, @@ -94,80 +86,64 @@ class WatchingImportHandler extends ApiHandler "tmdb_id" => $id, "slug" => $slug, ]; - $response = $this->fetchFromPostgREST( - $mediaType === "movie" ? "movies" : "shows", - "", - "POST", - $payload - ); - if (empty($response["id"])) { - $queryResponse = $this->fetchFromPostgREST( - $mediaType === "movie" ? "movies" : "shows", - "tmdb_id=eq.{$id}", - "GET" - ); - $response = $queryResponse[0] ?? []; + $table = $mediaType === "movie" ? "movies" : "shows"; + + try { + $response = $this->makeRequest("POST", $table, ["json" => $payload]); + } catch (\Exception $e) { + $response = $this->fetchFromApi($table, "tmdb_id=eq.{$id}")[0] ?? []; } if (!empty($response["id"])) { $mediaId = $response["id"]; $existingTagMap = $this->getTagIds($tags); $updatedTagMap = $this->insertMissingTags($tags, $existingTagMap); - $this->associateTagsWithMedia( - $mediaType, - $mediaId, - array_values($updatedTagMap) - ); + $this->associateTagsWithMedia($mediaType, $mediaId, array_values($updatedTagMap)); } } private function getTagIds(array $tags): array { - $existingTagMap = []; + $map = []; + foreach ($tags as $tag) { - $query = "name=ilike." . urlencode($tag); - $existingTags = $this->fetchFromPostgREST("tags", $query, "GET"); - - if (!empty($existingTags[0]["id"])) $existingTagMap[strtolower($tag)] = $existingTags[0]["id"]; - } - return $existingTagMap; - } - - private function insertMissingTags(array $tags, array $existingTagMap): array - { - $newTags = array_diff($tags, array_keys($existingTagMap)); - foreach ($newTags as $newTag) { - try { - $response = $this->fetchFromPostgREST("tags", "", "POST", [ - "name" => $newTag, - ]); - if (!empty($response["id"])) $existingTagMap[$newTag] = $response["id"]; - } catch (Exception $e) { - $queryResponse = $this->fetchFromPostgREST( - "tags", - "name=eq.{$newTag}", - "GET" - ); - if (!empty($queryResponse[0]["id"])) $existingTagMap[$newTag] = $queryResponse[0]["id"]; + $response = $this->fetchFromApi("tags", "name=ilike." . urlencode($tag)); + if (!empty($response[0]["id"])) { + $map[strtolower($tag)] = $response[0]["id"]; } } - return $existingTagMap; + + return $map; } - private function associateTagsWithMedia( - string $mediaType, - int $mediaId, - array $tagIds - ): void { - $junctionTable = $mediaType === "movie" ? "movies_tags" : "shows_tags"; + private function insertMissingTags(array $tags, array $existingMap): array + { + $newTags = array_diff($tags, array_keys($existingMap)); + + foreach ($newTags as $tag) { + try { + $created = $this->makeRequest("POST", "tags", ["json" => ["name" => $tag]]); + if (!empty($created["id"])) $existingMap[$tag] = $created["id"]; + } catch (\Exception $e) { + $fallback = $this->fetchFromApi("tags", "name=eq." . urlencode($tag)); + if (!empty($fallback[0]["id"])) $existingMap[$tag] = $fallback[0]["id"]; + } + } + + return $existingMap; + } + + 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->fetchFromPostgREST($junctionTable, "", "POST", [ + $this->makeRequest("POST", $junction, ["json" => [ $mediaColumn => $mediaId, - "tags_id" => $tagId, - ]); + "tags_id" => $tagId + ]]); } } } diff --git a/package-lock.json b/package-lock.json index 3f581ad..33183a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coryd.dev", - "version": "2.1.4", + "version": "3.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coryd.dev", - "version": "2.1.4", + "version": "3.0.3", "license": "MIT", "dependencies": { "html-minifier-terser": "7.2.0", @@ -184,9 +184,9 @@ } }, "node_modules/@11ty/eleventy-plugin-bundle": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.4.tgz", - "integrity": "sha512-9Y9aLB5kwK7dkTC+Pfbt4EEs58TMQjuo1+EJ18dA/XKDxczHj2fAUZcETMgNQ17AmrMDj5HxJ0ezFNGpMcD7Vw==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.5.tgz", + "integrity": "sha512-LfcXr5pvvFjA6k1u8o0vqxbFVY8elpxIeICvdJti9FWUbHyJlS6ydRkyUnijpa+NTsj7DrlcrD1r1uBrANHYeA==", "dev": true, "license": "MIT", "dependencies": { @@ -3191,13 +3191,13 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -3234,9 +3234,9 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", "dev": true, "license": "BSD-2-Clause", "engines": { diff --git a/package.json b/package.json index 8538385..1dbb96f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coryd.dev", - "version": "2.1.4", + "version": "3.0.3", "description": "The source for my personal site. Built using 11ty (and other tools).", "type": "module", "engines": { diff --git a/queries/views/feeds/recent_activity.psql b/queries/views/feeds/recent_activity.psql index 2edfdbb..4475c61 100644 --- a/queries/views/feeds/recent_activity.psql +++ b/queries/views/feeds/recent_activity.psql @@ -1,6 +1,7 @@ CREATE OR REPLACE VIEW optimized_recent_activity AS WITH activity_data AS ( SELECT + NULL::bigint AS id, p.date AS content_date, p.title, p.content AS description, @@ -22,6 +23,7 @@ WITH activity_data AS ( UNION ALL SELECT + NULL::bigint AS id, l.date AS content_date, l.title, l.description, @@ -43,6 +45,7 @@ WITH activity_data AS ( UNION ALL SELECT + NULL::bigint AS id, b.date_finished AS content_date, CONCAT(b.title, CASE WHEN b.rating IS NOT NULL THEN CONCAT(' (', b.rating, ')') ELSE '' END @@ -67,6 +70,7 @@ WITH activity_data AS ( UNION ALL SELECT + NULL::bigint AS id, m.last_watched AS content_date, CONCAT(m.title, CASE WHEN m.rating IS NOT NULL THEN CONCAT(' (', m.rating, ')') ELSE '' END @@ -91,6 +95,7 @@ WITH activity_data AS ( UNION ALL SELECT + c.id, c.date AS content_date, CONCAT(c.artist->>'name', ' at ', c.venue->>'name_short') AS title, c.concert_notes AS description, @@ -104,7 +109,7 @@ WITH activity_data AS ( c.venue->>'latitude' AS venue_lat, c.venue->>'longitude' AS venue_lon, c.venue->>'name_short' AS venue_name, - c.notes AS notes, + c.concert_notes AS notes, 'concerts' AS type, 'Concert' AS label FROM optimized_concerts c diff --git a/queries/views/media/music/concerts.psql b/queries/views/media/music/concerts.psql index 4111299..c577821 100644 --- a/queries/views/media/music/concerts.psql +++ b/queries/views/media/music/concerts.psql @@ -2,7 +2,6 @@ CREATE OR REPLACE VIEW optimized_concerts AS SELECT c.id, c.date, - c.notes, CASE WHEN c.artist IS NOT NULL THEN json_build_object('name', a.name_string, 'url', a.slug) ELSE @@ -16,4 +15,3 @@ FROM LEFT JOIN venues v ON c.venue = v.id ORDER BY c.date DESC; - diff --git a/server/utils/strings.php b/server/utils/strings.php index 536306f..fc7f85c 100644 --- a/server/utils/strings.php +++ b/server/utils/strings.php @@ -1,7 +1,5 @@ { // dialog controls (() => { - if (document.querySelectorAll(".dialog-open").length) { - document.querySelectorAll(".dialog-open").forEach((button) => { - const dialogId = button.getAttribute("data-dialog-trigger"); - const dialog = document.getElementById(`dialog-${dialogId}`); + const dialogButtons = document.querySelectorAll(".dialog-open"); + if (!dialogButtons.length) return; - if (!dialog) return; + dialogButtons.forEach((button) => { + const dialogId = button.getAttribute("data-dialog-trigger"); + const dialog = document.getElementById(`dialog-${dialogId}`); + if (!dialog) return; - const closeButton = dialog.querySelector(".dialog-close"); + const closeButton = dialog.querySelector(".dialog-close"); - button.addEventListener("click", () => { - dialog.showModal(); - dialog.classList.remove("closing"); - }); + button.addEventListener("click", async () => { + const isDynamic = dialog.dataset.dynamic; + const isLoaded = dialog.dataset.loaded; - if (closeButton) - closeButton.addEventListener("click", () => { - dialog.classList.add("closing"); - setTimeout(() => dialog.close(), 200); - }); + if (isDynamic && !isLoaded) { + const markdownFields = dialog.dataset.markdown || ""; + try { + const res = await fetch(`/api/proxy.php?data=${isDynamic}&id=${dialogId}&markdown=${encodeURIComponent(markdownFields)}`); + const [data] = await res.json(); + const firstField = markdownFields.split(",")[0]?.trim(); + const html = data?.[`${firstField}_html`] || "

No notes available.

"; - dialog.addEventListener("click", (event) => { - const rect = dialog.getBoundingClientRect(); + dialog.querySelectorAll(".dialog-dynamic").forEach((el) => el.remove()); - if ( - event.clientX < rect.left || - event.clientX > rect.right || - event.clientY < rect.top || - event.clientY > rect.bottom - ) { - dialog.classList.add("closing"); - setTimeout(() => dialog.close(), 200); + const container = document.createElement("div"); + + container.classList.add("dialog-dynamic"); + container.innerHTML = html; + dialog.appendChild(container); + dialog.dataset.loaded = "true"; + } catch (err) { + dialog.querySelectorAll(".dialog-dynamic").forEach((el) => el.remove()); + + const errorNode = document.createElement("div"); + + errorNode.classList.add("dialog-dynamic"); + errorNode.textContent = "Failed to load content."; + dialog.appendChild(errorNode); + + console.warn("Dialog content load error:", err); } - }); + } + dialog.showModal(); + dialog.classList.remove("closing"); + }); - dialog.addEventListener("cancel", (event) => { - event.preventDefault(); + if (closeButton) { + closeButton.addEventListener("click", () => { dialog.classList.add("closing"); setTimeout(() => dialog.close(), 200); }); + } + + dialog.addEventListener("click", (event) => { + const rect = dialog.getBoundingClientRect(); + const outsideClick = + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom; + + if (outsideClick) { + dialog.classList.add("closing"); + setTimeout(() => dialog.close(), 200); + } }); - } + + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + dialog.classList.add("closing"); + setTimeout(() => dialog.close(), 200); + }); + }); })(); // text toggle for media pages @@ -51,12 +83,9 @@ window.addEventListener("load", () => { const content = document.querySelector("[data-toggle-content]"); const text = document.querySelectorAll("[data-toggle-content] p"); const minHeight = 500; // this needs to match the height set on [data-toggle-content].text-toggle-hidden in text-toggle.css - const interiorHeight = Array.from(text).reduce( - (acc, node) => acc + node.scrollHeight, - 0, - ); + const interiorHeight = Array.from(text).reduce((acc, node) => acc + node.scrollHeight, 0); - if (!button || !content || !text) return; + if (!button || !content || !text.length) return; if (interiorHeight < minHeight) { content.classList.remove("text-toggle-hidden"); diff --git a/src/assets/styles/base/fonts.css b/src/assets/styles/base/fonts.css index 7ddfa7f..7038e93 100644 --- a/src/assets/styles/base/fonts.css +++ b/src/assets/styles/base/fonts.css @@ -6,22 +6,6 @@ font-display: swap; } -@font-face { - font-family: 'Lexend'; - src: url('/assets/fonts/ll.woff2') format('woff2'); - font-weight: 300; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'Lexend'; - src: url('/assets/fonts/lb.woff2') format('woff2'); - font-weight: 700; - font-style: normal; - font-display: swap; -} - @font-face { font-family: "Space Grotesk"; src: url("/assets/fonts/sg.woff2") format("woff2"); diff --git a/src/assets/styles/base/index.css b/src/assets/styles/base/index.css index e240f97..8770e81 100644 --- a/src/assets/styles/base/index.css +++ b/src/assets/styles/base/index.css @@ -1,7 +1,7 @@ html, body { font-family: var(--font-body); - font-weight: var(--font-weight-light); + font-weight: var(--font-weight-regular); color: var(--text-color); background: var(--background-color); } diff --git a/src/assets/styles/base/vars.css b/src/assets/styles/base/vars.css index a13e31d..e10b7d8 100644 --- a/src/assets/styles/base/vars.css +++ b/src/assets/styles/base/vars.css @@ -71,7 +71,7 @@ --border-gray: 1px solid var(--gray-light); /* fonts */ - --font-body: "Lexend", -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, Cantarell, Ubuntu, roboto, noto, helvetica, arial, sans-serif; + --font-body: Helvetica Neue, Helvetica, Arial, sans-serif; --font-heading: "Space Grotesk", "Arial Black", "Arial Bold", Gadget, sans-serif; --font-code: "MonoLisa", SFMono-Regular, Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace; @@ -84,7 +84,6 @@ --font-size-2xl: 1.45rem; --font-size-3xl: 1.6rem; - --font-weight-light: 300; --font-weight-regular: 400; --font-weight-bold: 700; diff --git a/src/assets/styles/components/dialog.css b/src/assets/styles/components/dialog.css index 58df183..73dcf97 100644 --- a/src/assets/styles/components/dialog.css +++ b/src/assets/styles/components/dialog.css @@ -54,7 +54,7 @@ dialog { font-size: var(--font-size-lg); } - h1, h2, h3 { + * { margin-top: 0; } diff --git a/src/includes/blocks/dialog.liquid b/src/includes/blocks/dialog.liquid index cf649d4..8da70f1 100644 --- a/src/includes/blocks/dialog.liquid +++ b/src/includes/blocks/dialog.liquid @@ -9,9 +9,15 @@ - + + {%- unless dynamic -%} {{ content }} + {%- endunless -%} diff --git a/src/includes/home/recent-activity.liquid b/src/includes/home/recent-activity.liquid index d52078f..868854f 100644 --- a/src/includes/home/recent-activity.liquid +++ b/src/includes/home/recent-activity.liquid @@ -15,12 +15,13 @@ • {{ item.label }} {%- if item.notes -%} - {% assign notes = item.notes | prepend: "### Notes\n" | markdown %} + {% assign notes = item.notes | markdown %} {% render "blocks/dialog.liquid", icon:"info-circle", label:"View info about this concert" - content:notes, - id:item.content_date + dynamic:"optimized_concerts", + markdown:"concert_notes", + id:item.id %} {%- endif -%} diff --git a/src/layouts/base.liquid b/src/layouts/base.liquid index ea200a2..8149ab7 100644 --- a/src/layouts/base.liquid +++ b/src/layouts/base.liquid @@ -3,8 +3,6 @@ - -