diff --git a/appinfo/routes.php b/appinfo/routes.php index 1e49c26..a33e08d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -16,5 +16,6 @@ return [ ['name' => 'subscription_change#create', 'url' => '/subscription_change/create', 'verb' => 'POST'], ['name' => 'personal_settings#metrics', 'url' => '/personal_settings/metrics', 'verb' => 'GET'], ['name' => 'personal_settings#podcastData', 'url' => '/personal_settings/podcast_data', 'verb' => 'GET'], + ['name' => 'personal_settings#imageProxy', 'url' => '/personal_settings/image_proxy', 'verb' => 'GET'], ] ]; diff --git a/lib/Controller/PersonalSettingsController.php b/lib/Controller/PersonalSettingsController.php index ae676dc..2031405 100644 --- a/lib/Controller/PersonalSettingsController.php +++ b/lib/Controller/PersonalSettingsController.php @@ -3,12 +3,18 @@ declare(strict_types=1); namespace OCA\GPodderSync\Controller; +use GuzzleHttp\Psr7\BufferStream; +use GuzzleHttp\Psr7\StreamWrapper; use OCA\GPodderSync\Core\PodcastData\PodcastDataReader; use OCA\GPodderSync\Core\PodcastData\PodcastMetricsReader; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\StreamResponse; +use OCP\AppFramework\OCS\OCSException; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; use OCP\IRequest; class PersonalSettingsController extends Controller { @@ -17,17 +23,22 @@ class PersonalSettingsController extends Controller { private PodcastMetricsReader $metricsReader; private PodcastDataReader $dataReader; + // TODO: Use httpClient via PodcastDataReader instead + private IClient $httpClient; + public function __construct( string $AppName, IRequest $request, string $UserId, PodcastMetricsReader $metricsReader, PodcastDataReader $dataReader, + IClientService $httpClientService, ) { parent::__construct($AppName, $request); $this->userId = $UserId ?? ''; $this->metricsReader = $metricsReader; $this->dataReader = $dataReader; + $this->httpClient = $httpClientService->newClient(); } /** diff --git a/lib/Core/PodcastData/PodcastData.php b/lib/Core/PodcastData/PodcastData.php index e23b40f..648379e 100644 --- a/lib/Core/PodcastData/PodcastData.php +++ b/lib/Core/PodcastData/PodcastData.php @@ -8,27 +8,30 @@ use JsonSerializable; use SimpleXMLElement; class PodcastData implements JsonSerializable { - private string $title; - private string $author; - private string $link; - private string $description; - private string $image; + private ?string $title; + private ?string $author; + private ?string $link; + private ?string $description; + private ?string $imageUrl; private int $fetchedAtUnix; + private ?string $imageBlob; public function __construct( - string $title, - string $author, - string $link, - string $description, - string $image, + ?string $title, + ?string $author, + ?string $link, + ?string $description, + ?string $imageUrl, int $fetchedAtUnix, + ?string $imageBlob = null, ) { $this->title = $title; $this->author = $author; $this->link = $link; $this->description = $description; - $this->image = $image; + $this->imageUrl = $imageUrl; $this->fetchedAtUnix = $fetchedAtUnix; + $this->imageBlob = $imageBlob; } /** @@ -39,17 +42,24 @@ class PodcastData implements JsonSerializable { $xml = new SimpleXMLElement($xmlString); $channel = $xml->channel; return new PodcastData( - title: (string)$channel->title, - author: (string)self::getXPathContent($xml, '/rss/channel/itunes:author'), - link: (string)$channel->link, - description: (string)$channel->description, - image: - (string)(self::getXPathContent($xml, '/rss/channel/image/url') - ?? self::getXPathAttribute($xml, '/rss/channel/itunes:image/@href')), + title: self::stringOrNull($channel->title), + author: self::getXPathContent($xml, '/rss/channel/itunes:author'), + link: self::stringOrNull($channel->link), + description: self::stringOrNull($channel->description), + imageUrl: + self::getXPathContent($xml, '/rss/channel/image/url') + ?? self::getXPathAttribute($xml, '/rss/channel/itunes:image/@href'), fetchedAtUnix: $fetchedAtUnix ?? (new DateTime())->getTimestamp(), ); } + private static function stringOrNull(mixed $value): ?string { + if ($value) { + return (string)$value; + } + return null; + } + private static function getXPathContent(SimpleXMLElement $xml, string $xpath): ?string { $match = $xml->xpath($xpath); if ($match) { @@ -67,52 +77,67 @@ class PodcastData implements JsonSerializable { } /** - * @return string + * @return string|null */ - public function getTitle(): string { + public function getTitle(): ?string { return $this->title; } /** - * @return string + * @return string|null */ - public function getAuthor(): string { + public function getAuthor(): ?string { return $this->author; } /** - * @return string + * @return string|null */ - public function getLink(): string { + public function getLink(): ?string { return $this->link; } /** - * @return string + * @return string|null */ - public function getDescription(): string { + public function getDescription(): ?string { return $this->description; } /** - * @return string + * @return string|null */ - public function getImage(): string { - return $this->image; + public function getImageUrl(): ?string { + return $this->imageUrl; } /** - * @return int + * @return int|null */ - public function getFetchedAtUnix(): int { + public function getFetchedAtUnix(): ?int { return $this->fetchedAtUnix; } + /** + * @return string|null + */ + public function getImageBlob(): ?string { + return $this->imageBlob; + } + + /** + * @param string $blob + * @return void + */ + public function setImageBlob(?string $blob): void { + $this->imageBlob = $blob; + } + /** * @return string */ - public function __toString() : String { - return $this->title; + public function __toString() : string { + return $this->title ?? '/no title/'; } /** @@ -125,7 +150,8 @@ class PodcastData implements JsonSerializable { 'author' => $this->author, 'link' => $this->link, 'description' => $this->description, - 'image' => $this->image, + 'imageUrl' => $this->imageUrl, + 'imageBlob' => $this->imageBlob, 'fetchedAtUnix' => $this->fetchedAtUnix, ]; } @@ -146,8 +172,9 @@ class PodcastData implements JsonSerializable { author: $data['author'], link: $data['link'], description: $data['description'], - image: $data['image'], + imageUrl: $data['imageUrl'], fetchedAtUnix: $data['fetchedAtUnix'], + imageBlob: $data['imageBlob'], ); } } diff --git a/lib/Core/PodcastData/PodcastDataReader.php b/lib/Core/PodcastData/PodcastDataReader.php index 0dbdbb0..80a6049 100644 --- a/lib/Core/PodcastData/PodcastDataReader.php +++ b/lib/Core/PodcastData/PodcastDataReader.php @@ -3,9 +3,11 @@ declare(strict_types=1); namespace OCA\GPodderSync\Core\PodcastData; +use Exception; use OCA\GPodderSync\Db\SubscriptionChange\SubscriptionChangeRepository; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; use OCP\ICache; use OCP\ICacheFactory; @@ -48,13 +50,37 @@ class PodcastDataReader { if (!$this->userHasPodcast($url, $userId)) { return null; } + $resp = $this->fetchUrl($url); + $data = PodcastData::parseRssXml($resp->getBody()); + $blob = $this->tryFetchImageBlob($data); + if ($blob) { + $data->setImageBlob($blob); + } + return $data; + } + + private function tryFetchImageBlob(PodcastData $data): ?string { + if (!$data->getImageUrl()) { + return null; + } + try { + $resp = $this->fetchUrl($data->getImageUrl()); + $contentType = $resp->getHeader('Content-Type'); + $body = $resp->getBody(); + $bodyBase64 = base64_encode($body); + return "data:$contentType;base64,$bodyBase64"; + } catch (Exception) { + return null; + } + } + + private function fetchUrl(string $url): IResponse { $resp = $this->httpClient->get($url); $statusCode = $resp->getStatusCode(); if ($statusCode < 200 || $statusCode >= 300) { - throw new \ErrorException("Podcast RSS URL returned non-2xx status code: $statusCode"); + throw new \ErrorException("Web request returned non-2xx status code: $statusCode"); } - $body = $resp->getBody(); - return PodcastData::parseRssXml($body); + return $resp; } public function tryGetCachedPodcastData(string $url): ?PodcastData { diff --git a/src/components/SubscriptionListItem.vue b/src/components/SubscriptionListItem.vue index 5a15cb2..797044c 100644 --- a/src/components/SubscriptionListItem.vue +++ b/src/components/SubscriptionListItem.vue @@ -3,7 +3,7 @@ :details="formatSubscriptionDetails(sub)">