feat: initial commit

This commit is contained in:
Cory Dransfeldt 2025-03-27 16:46:02 -07:00
commit e214116e40
No known key found for this signature in database
253 changed files with 17406 additions and 0 deletions

View file

@ -0,0 +1,72 @@
<?php
namespace App\Classes;
use GuzzleHttp\Client;
require __DIR__ . "/../../vendor/autoload.php";
abstract class ApiHandler
{
protected string $postgrestUrl;
protected string $postgrestApiKey;
public function __construct()
{
$this->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();
}
}
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);
}
}

129
api/Classes/BaseHandler.php Normal file
View file

@ -0,0 +1,129 @@
<?php
namespace App\Classes;
require __DIR__ . "/../../vendor/autoload.php";
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
abstract class BaseHandler
{
protected string $postgrestUrl;
protected string $postgrestApiKey;
protected ?\Redis $cache = null;
public function __construct()
{
$this->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 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",
],
])
);
$responseBody = $response->getBody()->getContents();
if (empty($responseBody)) return [];
$responseData = json_decode($responseBody, true);
if (json_last_error() !== JSON_ERROR_NONE) throw new \Exception("Invalid JSON response: {$responseBody}");
return $responseData;
} catch (RequestException $e) {
$response = $e->getResponse();
$statusCode = $response ? $response->getStatusCode() : "N/A";
$responseBody = $response
? $response->getBody()->getContents()
: "No response body";
throw new \Exception(
"Request to {$url} failed with status {$statusCode}. Response: {$responseBody}"
);
} catch (\Exception $e) {
throw new \Exception("Request to {$url} failed: " . $e->getMessage());
}
}
protected function sendResponse(array $data, int $statusCode = 200): void
{
http_response_code($statusCode);
header("Content-Type: application/json");
echo json_encode($data);
exit();
}
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;
}
}
}

3
api/Utils/init.php Normal file
View file

@ -0,0 +1,3 @@
<?php
require_once "media.php";
?>

14
api/Utils/media.php Normal file
View file

@ -0,0 +1,14 @@
<?php
function sanitizeMediaString(string $str): string
{
$sanitizedString = preg_replace(
"/[^a-zA-Z0-9\s-]/",
"",
iconv("UTF-8", "ASCII//TRANSLIT", $str)
);
return strtolower(
trim(preg_replace("/[\s-]+/", "-", $sanitizedString), "-")
);
}
?>

207
api/artist-import.php Normal file
View file

@ -0,0 +1,207 @@
<?php
require __DIR__ . "/Classes/ApiHandler.php";
require __DIR__ . "/Utils/init.php";
use App\Classes\ApiHandler;
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;
private string $navidromeAuthToken;
public function __construct()
{
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");
}
public function handleRequest(): void
{
$input = json_decode(file_get_contents("php://input"), true);
if (!$input) $this->sendJsonResponse("error", "Invalid or missing JSON body", 400);
$providedToken = $input["token"] ?? null;
$artistId = $input["artistId"] ?? null;
if (!$providedToken || $providedToken !== $this->artistImportToken) {
$this->sendJsonResponse("error", "Unauthorized access", 401);
}
if (!$artistId) $this->sendJsonResponse("error", "Artist ID is required", 400);
try {
$artistData = $this->fetchNavidromeArtist($artistId);
$artistExists = $this->processArtist($artistData);
if ($artistExists) $this->processAlbums($artistId, $artistData->name);
$this->sendJsonResponse("message", "Artist and albums synced successfully", 200);
} catch (Exception $e) {
$this->sendJsonResponse("error", "Error: " . $e->getMessage(), 500);
}
}
private function sendJsonResponse(string $key, string $message, int $statusCode): void
{
http_response_code($statusCode);
header("Content-Type: application/json");
echo json_encode([$key => $message]);
exit();
}
private function fetchNavidromeArtist(string $artistId)
{
$client = new Client();
$response = $client->get("{$this->navidromeApiUrl}/api/artist/{$artistId}", [
"headers" => [
"x-nd-authorization" => "Bearer {$this->navidromeAuthToken}",
"Accept" => "application/json"
]
]);
return json_decode($response->getBody(), false);
}
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 json_decode($response->getBody(), true);
}
private function processArtist(object $artistData): bool
{
$artistName = $artistData->name ?? "";
if (!$artistName) throw new Exception("Artist name is missing from Navidrome data.");
$existingArtist = $this->getArtistByName($artistName);
if ($existingArtist) return true;
$artistKey = sanitizeMediaString($artistName);
$slug = "/music/artists/{$artistKey}";
$description = strip_tags($artistData->biography) ?? "";
$genre = $this->resolveGenreId($artistData->genres[0]->name ?? "");
$starred = $artistData->starred ?? false;
$artistPayload = [
"name_string" => $artistName,
"slug" => $slug,
"description" => $description,
"tentative" => true,
"art" => $this->placeholderImageId,
"favorite" => $starred,
"genres" => $genre,
];
$this->saveArtist($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.");
$existingAlbums = $this->getExistingAlbums($artist["id"]);
$existingAlbumKeys = array_column($existingAlbums, "key");
$navidromeAlbums = $this->fetchNavidromeAlbums($artistId);
foreach ($navidromeAlbums as $album) {
$albumName = $album["name"];
$releaseYear = $album["date"];
$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->saveAlbum($albumPayload);
} catch (Exception $e) {
error_log("Error adding album '{$albumName}': " . $e->getMessage());
}
}
}
private function getArtistByName(string $nameString): ?array
{
$query = "name_string=eq." . urlencode($nameString);
$response = $this->fetchFromPostgREST("artists", $query, "GET");
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");
}
}
$handler = new ArtistImportHandler();
$handler->handleRequest();

116
api/book-import.php Normal file
View file

@ -0,0 +1,116 @@
<?php
require __DIR__ . "/Classes/ApiHandler.php";
use App\Classes\ApiHandler;
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");
}
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 || $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("Book imported successfully", 200);
} catch (Exception $e) {
$this->sendErrorResponse("Error: " . $e->getMessage(), 500);
}
}
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"],
]);
$data = json_decode($response->getBody(), true);
$bookKey = "ISBN:{$isbn}";
if (empty($data[$bookKey])) throw new Exception("Book data not found for ISBN: {$isbn}");
return $data[$bookKey];
}
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"] ?? "");
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.");
$bookPayload = [
"isbn" => $isbn,
"title" => $title,
"author" => $author,
"description" => $description,
"read_status" => "want to read",
"slug" => "/books/" . $isbn,
];
$this->saveBook($bookPayload);
}
private function saveBook(array $bookPayload): void
{
$this->fetchFromPostgREST("books", "", "POST", $bookPayload);
}
private function getBookByISBN(string $isbn): ?array
{
$query = "isbn=eq." . urlencode($isbn);
$response = $this->fetchFromPostgREST("books", $query, "GET");
return $response[0] ?? null;
}
}
$handler = new BookImportHandler();
$handler->handleRequest();

228
api/contact.php Normal file
View file

@ -0,0 +1,228 @@
<?php
require __DIR__ . "/Classes/BaseHandler.php";
use App\Classes\BaseHandler;
use GuzzleHttp\Client;
class ContactHandler extends BaseHandler
{
protected string $postgrestUrl;
protected string $postgrestApiKey;
private string $forwardEmailApiKey;
private Client $httpClient;
public function __construct(?Client $httpClient = null)
{
$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");
}
public function handleRequest(): void
{
try {
$this->validateReferer();
$this->checkRateLimit();
$this->enforceHttps();
$contentType = $_SERVER["CONTENT_TYPE"] ?? "";
$formData = null;
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);
}
}
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 (
$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 <hi@admin.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();
} catch (Exception $e) {
error_log("Contact form error: " . $e->getMessage());
echo json_encode(["error" => $e->getMessage()]);
http_response_code(500);
}

279
api/mastodon.php Normal file
View file

@ -0,0 +1,279 @@
<?php
require __DIR__ . "/Classes/ApiHandler.php";
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
class MastodonPostHandler extends ApiHandler
{
protected string $postgrestUrl;
protected string $postgrestApiKey;
private string $mastodonAccessToken;
private string $rssFeedUrl;
private string $baseUrl;
private const MASTODON_API_STATUS = "https://follow.coryd.dev/api/v1/statuses";
private Client $httpClient;
public function __construct(?Client $httpClient = null)
{
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";
}
private function validateAuthorization(): void
{
$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
$expectedToken = "Bearer " . getenv("MASTODON_SYNDICATION_TOKEN");
if ($authHeader !== $expectedToken) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized."]);
exit();
}
}
public function handlePost(): void
{
if (!$this->isDatabaseAvailable()) {
echo "Database is unavailable. Exiting.\n";
return;
}
$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;
$content = $this->truncateContent(
$item["title"],
str_replace(array("\n", "\r"), '', 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;
}
$mastodonPostUrl = $this->postToMastodon($content, $item["image"] ?? null);
if ($mastodonPostUrl) {
if (strpos($item["link"], $this->baseUrl . "/posts") !== false) {
$slug = str_replace($this->baseUrl, "", $item["link"]);
$this->updatePostWithMastodonUrl($slug, $mastodonPostUrl);
echo "Posted and stored URL: {$item["link"]}\n";
}
} else {
echo "Failed to post to Mastodon. Skipping database update.\n";
}
}
echo "RSS processed successfully.\n";
}
private function fetchRSSFeed(string $rssFeedUrl): array
{
$rssText = file_get_contents($rssFeedUrl);
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'];
$items[] = [
"title" => (string) $item->title,
"link" => (string) $item->link,
"description" => (string) $item->description,
"image" => $imageUrl,
];
}
return $items;
}
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,
"multipart" => [
[
"name" => "file",
"contents" => fopen($tempFile, "r"),
"filename" => basename($imageUrl)
]
]
]);
unlink($tempFile);
$statusCode = $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;
}
private function postToMastodon(string $content, ?string $imageUrl = null): ?string
{
$headers = [
"Authorization" => "Bearer {$this->mastodonAccessToken}",
"Content-Type" => "application/json",
];
$mediaIds = [];
if ($imageUrl) {
try {
$mediaId = $this->uploadImageToMastodon($imageUrl);
if ($mediaId) $mediaIds[] = $mediaId;
} catch (Exception $e) {
echo "Image upload failed: " . $e->getMessage() . "\n";
}
}
$postData = ["status" => $content];
if (!empty($mediaIds)) $postData["media_ids"] = $mediaIds;
$response = $this->httpRequest(
self::MASTODON_API_STATUS,
"POST",
$headers,
$postData
);
return $response["url"] ?? null;
}
private function storeInDatabase(string $link, string $timestamp): bool
{
$data = [
"link" => $link,
"created_at" => $timestamp,
];
try {
$this->fetchFromPostgREST("mastodon_posts", "", "POST", $data);
return true;
} catch (Exception $e) {
echo "Error storing post in database: " . $e->getMessage() . "\n";
return false;
}
}
private function isDatabaseAvailable(): bool
{
try {
$response = $this->fetchFromPostgREST("mastodon_posts", "limit=1");
return is_array($response);
} catch (Exception $e) {
echo "Database check failed: " . $e->getMessage() . "\n";
return false;
}
}
private function updatePostWithMastodonUrl(
string $slug,
string $mastodonPostUrl
): void {
$data = ["mastodon_url" => $mastodonPostUrl];
$this->fetchFromPostgREST("posts", "slug=eq.{$slug}&mastodon_url=is.null", "PATCH", $data);
}
private function truncateContent(
string $title,
string $description,
string $link,
int $maxLength
): string {
$baseLength = strlen("$title\n\n$link");
$availableSpace = $maxLength - $baseLength - 4;
if (strlen($description) > $availableSpace) {
$description = substr($description, 0, $availableSpace);
$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) {
http_response_code(500);
echo json_encode(["error" => $e->getMessage()]);
}

82
api/playing.php Normal file
View file

@ -0,0 +1,82 @@
<?php
namespace App\Handlers;
require __DIR__ . "/Classes/BaseHandler.php";
use App\Classes\BaseHandler;
class LatestListenHandler extends BaseHandler
{
protected int $cacheTTL = 60;
public function __construct()
{
parent::__construct();
$this->initializeCache();
}
public function handleRequest(): void
{
try {
$cachedData = $this->cache ? $this->cache->get("latest_listen") : null;
if ($cachedData) {
$this->sendResponse(json_decode($cachedData, true));
return;
}
$data = $this->makeRequest("GET", "optimized_latest_listen?select=*");
if (!is_array($data) || empty($data[0])) {
$this->sendResponse(["message" => "No recent tracks found"], 404);
return;
}
$latestListen = $this->formatLatestListen($data[0]);
if ($this->cache) $this->cache->set(
"latest_listen",
json_encode($latestListen),
$this->cacheTTL
);
$this->sendResponse($latestListen);
} catch (Exception $e) {
error_log("LatestListenHandler Error: " . $e->getMessage());
$this->sendErrorResponse(
"Internal Server Error: " . $e->getMessage(),
500
);
}
}
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 <a href="https://www.coryd.dev%s">%s</a>',
$emoji,
$trackName,
$url,
$artistName
),
];
}
}
$handler = new LatestListenHandler();
$handler->handleRequest();

279
api/scrobble.php Normal file
View file

@ -0,0 +1,279 @@
<?php
namespace App\Handlers;
require __DIR__ . "/Classes/ApiHandler.php";
require __DIR__ . "/Utils/init.php";
use App\Classes\ApiHandler;
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;
private array $artistCache = [];
private array $albumCache = [];
public function __construct()
{
parent::__construct();
$this->ensureCliAccess();
$this->loadEnvironment();
$this->validateAuthorization();
}
private function loadEnvironment(): 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");
}
private function validateAuthorization(): void
{
$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
$expectedToken = "Bearer " . getenv("NAVIDROME_SCROBBLE_TOKEN");
if ($authHeader !== $expectedToken) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized."]);
exit();
}
}
public function runScrobbleCheck(): void
{
$recentTracks = $this->fetchRecentlyPlayed();
if (empty($recentTracks)) return;
foreach ($recentTracks as $track) {
if ($this->isTrackAlreadyScrobbled($track)) continue;
$this->handleTrackScrobble($track);
}
}
private function fetchRecentlyPlayed(): array
{
$client = new Client();
try {
$response = $client->request("GET", "{$this->navidromeApiUrl}/api/song", [
"query" => [
"_end" => 20,
"_order" => "DESC",
"_sort" => "play_date",
"_start" => 0,
"recently_played" => "true"
],
"headers" => [
"x-nd-authorization" => "Bearer {$this->navidromeAuthToken}",
"Accept" => "application/json"
]
]);
$data = json_decode($response->getBody()->getContents(), true);
return $data ?? [];
} catch (\Exception $e) {
error_log("Error fetching tracks: " . $e->getMessage());
return [];
}
}
private function isTrackAlreadyScrobbled(array $track): bool
{
$playDate = strtotime($track["playDate"]);
$existingListen = $this->fetchFromPostgREST("listens", "listened_at=eq.{$playDate}&limit=1");
return !empty($existingListen);
}
private function handleTrackScrobble(array $track): void
{
$artistData = $this->getOrCreateArtist($track["artist"]);
if (empty($artistData)) {
error_log("Failed to retrieve or create artist: " . $track["artist"]);
return;
}
$albumData = $this->getOrCreateAlbum($track["album"], $artistData);
if (empty($albumData)) {
error_log("Failed to retrieve or create album: " . $track["album"]);
return;
}
$this->insertListen($track, $albumData["key"]);
}
private function getOrCreateArtist(string $artistName): array
{
if (!$this->isDatabaseAvailable()) {
error_log("Skipping artist insert: database is unavailable.");
return [];
}
if (isset($this->artistCache[$artistName])) return $this->artistCache[$artistName];
$encodedArtist = rawurlencode($artistName);
$existingArtist = $this->fetchFromPostgREST("artists", "name_string=eq.{$encodedArtist}&limit=1");
if (!empty($existingArtist)) {
$this->artistCache[$artistName] = $existingArtist[0];
return $existingArtist[0];
}
$this->fetchFromPostgREST("artists", "", "POST", [
"mbid" => null,
"art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
"name" => $artistName,
"slug" => "/music",
"tentative" => true,
"total_plays" => 0
]);
$this->sendFailureEmail("New tentative artist record", "A new tentative artist record was inserted:\n\nArtist: $artistName");
$artistData = $this->fetchFromPostgREST("artists", "name_string=eq.{$encodedArtist}&limit=1");
$this->artistCache[$artistName] = $artistData[0] ?? [];
return $this->artistCache[$artistName];
}
private function getOrCreateAlbum(string $albumName, array $artistData): array
{
if (!$this->isDatabaseAvailable()) {
error_log("Skipping album insert: database is unavailable.");
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");
if (!empty($existingAlbum)) {
$this->albumCache[$albumKey] = $existingAlbum[0];
return $existingAlbum[0];
}
$artistId = $artistData["id"] ?? null;
if (!$artistId) {
error_log("Artist ID missing for album creation: " . $albumName);
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->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");
$this->albumCache[$albumKey] = $albumData[0] ?? [];
return $this->albumCache[$albumKey];
}
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
]);
}
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()) {
error_log("Skipping email: database is unavailable.");
return;
}
$authHeader = "Basic " . base64_encode($this->forwardEmailApiKey . ":");
$client = new Client([
"base_uri" => "https://api.forwardemail.net/",
]);
try {
$response = $client->post("v1/emails", [
"headers" => [
"Authorization" => $authHeader,
"Content-Type" => "application/x-www-form-urlencoded",
],
"form_params" => [
"from" => "coryd.dev <hi@admin.coryd.dev>",
"to" => "hi@coryd.dev",
"subject" => $subject,
"text" => $message,
],
]);
} catch (\GuzzleHttp\Exception\RequestException $e) {
error_log("Request Exception: " . $e->getMessage());
if ($e->hasResponse()) {
$errorResponse = (string) $e->getResponse()->getBody();
error_log("Error Response: " . $errorResponse);
}
} catch (\Exception $e) {
error_log("General Exception: " . $e->getMessage());
}
}
private function isDatabaseAvailable(): bool
{
try {
$response = $this->fetchFromPostgREST("listens", "limit=1");
return is_array($response);
} catch (Exception $e) {
error_log("Database check failed: " . $e->getMessage());
return false;
}
}
}
try {
$handler = new NavidromeScrobbleHandler();
$handler->runScrobbleCheck();
} catch (Exception $e) {
http_response_code(500);
echo json_encode(["error" => $e->getMessage()]);
}

162
api/search.php Normal file
View file

@ -0,0 +1,162 @@
<?php
namespace App\Handlers;
require __DIR__ . "/Classes/BaseHandler.php";
use App\Classes\BaseHandler;
class SearchHandler extends BaseHandler
{
protected int $cacheTTL = 300;
public function __construct()
{
parent::__construct();
$this->initializeCache();
}
public function handleRequest(): void
{
try {
$query = $this->validateAndSanitizeQuery($_GET["q"] ?? null);
$types = $this->validateAndSanitizeTypes($_GET["type"] ?? "");
$page = isset($_GET["page"]) ? intval($_GET["page"]) : 1;
$pageSize = isset($_GET["pageSize"]) ? intval($_GET["pageSize"]) : 10;
$offset = ($page - 1) * $pageSize;
$cacheKey = $this->generateCacheKey($query, $types, $page, $pageSize);
$results = [];
$results =
$this->getCachedResults($cacheKey) ??
$this->fetchSearchResults($query, $types, $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);
}
}
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 validateAndSanitizeTypes(string $rawTypes): ?array
{
$allowedTypes = ["post", "artist", "genre", "book", "movie", "show"];
if (empty($rawTypes)) return null;
$types = array_map(
fn($type) => strtolower(
trim(htmlspecialchars($type, ENT_QUOTES, "UTF-8"))
),
explode(",", $rawTypes)
);
$invalidTypes = array_diff($types, $allowedTypes);
if (!empty($invalidTypes)) throw new Exception(
"Invalid 'type' parameter. Unsupported types: " .
implode(", ", $invalidTypes)
);
return $types;
}
private function fetchSearchResults(
string $query,
?array $types,
int $pageSize,
int $offset
): array {
$typesParam =
$types && count($types) > 0 ? "%7B" . implode(",", $types) . "%7D" : "";
$endpoint = "rpc/search_optimized_index";
$queryString =
"search_query=" .
urlencode($query) .
"&page_size={$pageSize}&page_offset={$offset}" .
($typesParam ? "&types={$typesParam}" : "");
$data = $this->makeRequest("GET", "{$endpoint}?{$queryString}");
$total = count($data) > 0 ? $data[0]["total_count"] : 0;
$results = array_map(function ($item) {
unset($item["total_count"]);
return $item;
}, $data);
return ["data" => $results, "total" => $total];
}
private function generateCacheKey(
string $query,
?array $types,
int $page,
int $pageSize
): string {
$typesKey = $types ? implode(",", $types) : "all";
return sprintf(
"search:%s:types:%s:page:%d:pageSize:%d",
md5($query),
$typesKey,
$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();
$handler->handleRequest();

205
api/seasons-import.php Normal file
View file

@ -0,0 +1,205 @@
<?php
require __DIR__ . "/Classes/ApiHandler.php";
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
class SeasonImportHandler extends ApiHandler
{
protected string $postgrestUrl;
protected string $postgrestApiKey;
private string $tmdbApiKey;
private string $seasonsImportToken;
public function __construct()
{
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"];
}
private function authenticateRequest(): void
{
if ($_SERVER["REQUEST_METHOD"] !== "POST") {
http_response_code(405);
echo json_encode(["error" => "Method Not Allowed"]);
exit();
}
$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
if (!preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit();
}
$providedToken = trim($matches[1]);
if ($providedToken !== $this->seasonsImportToken) {
http_response_code(403);
echo json_encode(["error" => "Forbidden"]);
exit();
}
}
public function importSeasons(): void
{
$ongoingShows = $this->fetchOngoingShows();
if (empty($ongoingShows)) {
http_response_code(200);
echo json_encode(["message" => "No ongoing shows to update"]);
return;
}
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");
}
private function processShowSeasons(array $show): void
{
$tmdbId = $show["tmdb_id"] ?? null;
$showId = $show["id"] ?? null;
if (!$tmdbId || !$showId) return;
$tmdbShowData = $this->fetchShowDetails($tmdbId);
$seasons = $tmdbShowData["seasons"] ?? [];
$status = $tmdbShowData["status"] ?? "Unknown";
if (empty($seasons) && !$this->shouldKeepOngoing($status)) {
$this->disableOngoingStatus($showId);
return;
}
foreach ($seasons as $season) {
$this->processSeasonEpisodes($showId, $tmdbId, $season);
}
}
private function shouldKeepOngoing(string $status): bool
{
$validStatuses = ["Returning Series", "In Production"];
return in_array($status, $validStatuses);
}
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"]]);
return json_decode($response->getBody(), true) ?? [];
} catch (\Exception $e) {
return [];
}
}
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"
);
if (empty($watchedEpisodes)) return [];
$lastWatched = $watchedEpisodes[0] ?? null;
if ($lastWatched) return [
"season_number" => (int) $lastWatched["season_number"],
"episode_number" => (int) $lastWatched["episode_number"]
];
return [];
}
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"
);
$scheduledEpisodeNumbers = array_column($scheduledEpisodes, "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;
$currentDate = date("Y-m-d");
$status = ($airDate && $airDate < $currentDate) ? "aired" : "upcoming";
$payload = [
"show_id" => $showId,
"season_number" => $seasonNumber,
"episode_number" => $episode["episode_number"],
"air_date" => $airDate,
"status" => $status,
];
$this->fetchFromPostgREST("scheduled_episodes", "", "POST", $payload);
}
}
$handler = new SeasonImportHandler();
$handler->importSeasons();

176
api/watching-import.php Normal file
View file

@ -0,0 +1,176 @@
<?php
require __DIR__ . "/Classes/ApiHandler.php";
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
class WatchingImportHandler extends ApiHandler
{
protected string $postgrestUrl;
protected string $postgrestApiKey;
private string $tmdbApiKey;
private string $tmdbImportToken;
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->tmdbApiKey = $_ENV["TMDB_API_KEY"] ?? getenv("TMDB_API_KEY");
$this->tmdbImportToken =
$_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_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;
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);
try {
$mediaData = $this->fetchTMDBData($tmdbId, $mediaType);
$this->processMedia($mediaData, $mediaType);
$this->sendResponse("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}";
$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}");
return $data;
}
private function processMedia(array $mediaData, string $mediaType): void
{
$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;
$description = $mediaData["overview"] ?? "";
$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,
];
$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] ?? [];
}
if (!empty($response["id"])) {
$mediaId = $response["id"];
$existingTagMap = $this->getTagIds($tags);
$updatedTagMap = $this->insertMissingTags($tags, $existingTagMap);
$this->associateTagsWithMedia(
$mediaType,
$mediaId,
array_values($updatedTagMap)
);
}
}
private function getTagIds(array $tags): array
{
$existingTagMap = [];
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"];
}
}
return $existingTagMap;
}
private function associateTagsWithMedia(
string $mediaType,
int $mediaId,
array $tagIds
): void {
$junctionTable = $mediaType === "movie" ? "movies_tags" : "shows_tags";
$mediaColumn = $mediaType === "movie" ? "movies_id" : "shows_id";
foreach ($tagIds as $tagId) {
$this->fetchFromPostgREST($junctionTable, "", "POST", [
$mediaColumn => $mediaId,
"tags_id" => $tagId,
]);
}
}
}
$handler = new WatchingImportHandler();
$handler->handleRequest();