Added image proxying/caching

This commit is contained in:
Kalle Fagerberg 2022-09-17 21:03:14 +02:00 committed by thrillfall
parent 5d59cfde56
commit a5a69fa3f7
6 changed files with 111 additions and 43 deletions

View File

@ -16,5 +16,6 @@ return [
['name' => 'subscription_change#create', 'url' => '/subscription_change/create', 'verb' => 'POST'], ['name' => 'subscription_change#create', 'url' => '/subscription_change/create', 'verb' => 'POST'],
['name' => 'personal_settings#metrics', 'url' => '/personal_settings/metrics', 'verb' => 'GET'], ['name' => 'personal_settings#metrics', 'url' => '/personal_settings/metrics', 'verb' => 'GET'],
['name' => 'personal_settings#podcastData', 'url' => '/personal_settings/podcast_data', 'verb' => 'GET'], ['name' => 'personal_settings#podcastData', 'url' => '/personal_settings/podcast_data', 'verb' => 'GET'],
['name' => 'personal_settings#imageProxy', 'url' => '/personal_settings/image_proxy', 'verb' => 'GET'],
] ]
]; ];

View File

@ -3,12 +3,18 @@ declare(strict_types=1);
namespace OCA\GPodderSync\Controller; namespace OCA\GPodderSync\Controller;
use GuzzleHttp\Psr7\BufferStream;
use GuzzleHttp\Psr7\StreamWrapper;
use OCA\GPodderSync\Core\PodcastData\PodcastDataReader; use OCA\GPodderSync\Core\PodcastData\PodcastDataReader;
use OCA\GPodderSync\Core\PodcastData\PodcastMetricsReader; use OCA\GPodderSync\Core\PodcastData\PodcastMetricsReader;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse; 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; use OCP\IRequest;
class PersonalSettingsController extends Controller { class PersonalSettingsController extends Controller {
@ -17,17 +23,22 @@ class PersonalSettingsController extends Controller {
private PodcastMetricsReader $metricsReader; private PodcastMetricsReader $metricsReader;
private PodcastDataReader $dataReader; private PodcastDataReader $dataReader;
// TODO: Use httpClient via PodcastDataReader instead
private IClient $httpClient;
public function __construct( public function __construct(
string $AppName, string $AppName,
IRequest $request, IRequest $request,
string $UserId, string $UserId,
PodcastMetricsReader $metricsReader, PodcastMetricsReader $metricsReader,
PodcastDataReader $dataReader, PodcastDataReader $dataReader,
IClientService $httpClientService,
) { ) {
parent::__construct($AppName, $request); parent::__construct($AppName, $request);
$this->userId = $UserId ?? ''; $this->userId = $UserId ?? '';
$this->metricsReader = $metricsReader; $this->metricsReader = $metricsReader;
$this->dataReader = $dataReader; $this->dataReader = $dataReader;
$this->httpClient = $httpClientService->newClient();
} }
/** /**

View File

@ -8,27 +8,30 @@ use JsonSerializable;
use SimpleXMLElement; use SimpleXMLElement;
class PodcastData implements JsonSerializable { class PodcastData implements JsonSerializable {
private string $title; private ?string $title;
private string $author; private ?string $author;
private string $link; private ?string $link;
private string $description; private ?string $description;
private string $image; private ?string $imageUrl;
private int $fetchedAtUnix; private int $fetchedAtUnix;
private ?string $imageBlob;
public function __construct( public function __construct(
string $title, ?string $title,
string $author, ?string $author,
string $link, ?string $link,
string $description, ?string $description,
string $image, ?string $imageUrl,
int $fetchedAtUnix, int $fetchedAtUnix,
?string $imageBlob = null,
) { ) {
$this->title = $title; $this->title = $title;
$this->author = $author; $this->author = $author;
$this->link = $link; $this->link = $link;
$this->description = $description; $this->description = $description;
$this->image = $image; $this->imageUrl = $imageUrl;
$this->fetchedAtUnix = $fetchedAtUnix; $this->fetchedAtUnix = $fetchedAtUnix;
$this->imageBlob = $imageBlob;
} }
/** /**
@ -39,17 +42,24 @@ class PodcastData implements JsonSerializable {
$xml = new SimpleXMLElement($xmlString); $xml = new SimpleXMLElement($xmlString);
$channel = $xml->channel; $channel = $xml->channel;
return new PodcastData( return new PodcastData(
title: (string)$channel->title, title: self::stringOrNull($channel->title),
author: (string)self::getXPathContent($xml, '/rss/channel/itunes:author'), author: self::getXPathContent($xml, '/rss/channel/itunes:author'),
link: (string)$channel->link, link: self::stringOrNull($channel->link),
description: (string)$channel->description, description: self::stringOrNull($channel->description),
image: imageUrl:
(string)(self::getXPathContent($xml, '/rss/channel/image/url') self::getXPathContent($xml, '/rss/channel/image/url')
?? self::getXPathAttribute($xml, '/rss/channel/itunes:image/@href')), ?? self::getXPathAttribute($xml, '/rss/channel/itunes:image/@href'),
fetchedAtUnix: $fetchedAtUnix ?? (new DateTime())->getTimestamp(), 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 { private static function getXPathContent(SimpleXMLElement $xml, string $xpath): ?string {
$match = $xml->xpath($xpath); $match = $xml->xpath($xpath);
if ($match) { 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 $this->title;
} }
/** /**
* @return string * @return string|null
*/ */
public function getAuthor(): string { public function getAuthor(): ?string {
return $this->author; return $this->author;
} }
/** /**
* @return string * @return string|null
*/ */
public function getLink(): string { public function getLink(): ?string {
return $this->link; return $this->link;
} }
/** /**
* @return string * @return string|null
*/ */
public function getDescription(): string { public function getDescription(): ?string {
return $this->description; return $this->description;
} }
/** /**
* @return string * @return string|null
*/ */
public function getImage(): string { public function getImageUrl(): ?string {
return $this->image; return $this->imageUrl;
} }
/** /**
* @return int * @return int|null
*/ */
public function getFetchedAtUnix(): int { public function getFetchedAtUnix(): ?int {
return $this->fetchedAtUnix; 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 * @return string
*/ */
public function __toString() : String { public function __toString() : string {
return $this->title; return $this->title ?? '/no title/';
} }
/** /**
@ -125,7 +150,8 @@ class PodcastData implements JsonSerializable {
'author' => $this->author, 'author' => $this->author,
'link' => $this->link, 'link' => $this->link,
'description' => $this->description, 'description' => $this->description,
'image' => $this->image, 'imageUrl' => $this->imageUrl,
'imageBlob' => $this->imageBlob,
'fetchedAtUnix' => $this->fetchedAtUnix, 'fetchedAtUnix' => $this->fetchedAtUnix,
]; ];
} }
@ -146,8 +172,9 @@ class PodcastData implements JsonSerializable {
author: $data['author'], author: $data['author'],
link: $data['link'], link: $data['link'],
description: $data['description'], description: $data['description'],
image: $data['image'], imageUrl: $data['imageUrl'],
fetchedAtUnix: $data['fetchedAtUnix'], fetchedAtUnix: $data['fetchedAtUnix'],
imageBlob: $data['imageBlob'],
); );
} }
} }

View File

@ -3,9 +3,11 @@ declare(strict_types=1);
namespace OCA\GPodderSync\Core\PodcastData; namespace OCA\GPodderSync\Core\PodcastData;
use Exception;
use OCA\GPodderSync\Db\SubscriptionChange\SubscriptionChangeRepository; use OCA\GPodderSync\Db\SubscriptionChange\SubscriptionChangeRepository;
use OCP\Http\Client\IClient; use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService; use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\ICache; use OCP\ICache;
use OCP\ICacheFactory; use OCP\ICacheFactory;
@ -48,13 +50,37 @@ class PodcastDataReader {
if (!$this->userHasPodcast($url, $userId)) { if (!$this->userHasPodcast($url, $userId)) {
return null; 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); $resp = $this->httpClient->get($url);
$statusCode = $resp->getStatusCode(); $statusCode = $resp->getStatusCode();
if ($statusCode < 200 || $statusCode >= 300) { 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 $resp;
return PodcastData::parseRssXml($body);
} }
public function tryGetCachedPodcastData(string $url): ?PodcastData { public function tryGetCachedPodcastData(string $url): ?PodcastData {

View File

@ -3,7 +3,7 @@
:details="formatSubscriptionDetails(sub)"> :details="formatSubscriptionDetails(sub)">
<template #icon> <template #icon>
<Avatar :size="44" <Avatar :size="44"
:url="podcastData?.image" :url="podcastData?.imageBlob ?? podcastData?.imageUrl"
:display-name="podcastData?.author" /> :display-name="podcastData?.author" />
</template> </template>
<template #subtitle> <template #subtitle>

View File

@ -14,7 +14,8 @@ class EpisodeActionTest extends TestCase {
'author' => 'author1', 'author' => 'author1',
'link' => 'http://example.com/', 'link' => 'http://example.com/',
'description' => 'description1', 'description' => 'description1',
'image' => 'http://example.com/image.jpg', 'imageUrl' => 'http://example.com/image.jpg',
'imageBlob' => null,
'fetchedAtUnix' => 1337, 'fetchedAtUnix' => 1337,
]; ];
$this->assertSame($expected, $podcastData->toArray()); $this->assertSame($expected, $podcastData->toArray());
@ -69,7 +70,8 @@ class EpisodeActionTest extends TestCase {
'author' => 'The Podcast Author', 'author' => 'The Podcast Author',
'link' => 'http://example.com', 'link' => 'http://example.com',
'description' => 'Some long description', 'description' => 'Some long description',
'image' => 'https://example.com/image.jpg', 'imageUrl' => 'https://example.com/image.jpg',
'imageBlob' => null,
'fetchedAtUnix' => 1337, 'fetchedAtUnix' => 1337,
]; ];
$this->assertSame($expected, $podcastData->toArray()); $this->assertSame($expected, $podcastData->toArray());
@ -102,8 +104,9 @@ class EpisodeActionTest extends TestCase {
'title' => 'The title of this Podcast', 'title' => 'The title of this Podcast',
'author' => 'The Podcast Author', 'author' => 'The Podcast Author',
'link' => 'http://example.com', 'link' => 'http://example.com',
'description' => '', 'description' => null,
'image' => '', 'imageUrl' => null,
'imageBlob' => null,
'fetchedAtUnix' => 1337, 'fetchedAtUnix' => 1337,
]; ];
$this->assertSame($expected, $podcastData->toArray()); $this->assertSame($expected, $podcastData->toArray());