feat(rest-api): add endpoints for episodes and full text search for podcasts and episodes

closes #296
This commit is contained in:
Krzysztof Domańczy 2023-06-21 10:07:31 +00:00 committed by Yassine Doghri
parent 2b516fee14
commit 85505d4b31
12 changed files with 453 additions and 8 deletions

View File

@ -63,3 +63,7 @@ cache.handler="file"
# REST API configuration
#--------------------------------------------------------------------
# restapi.enabled=true
# restapi.basicAuthUsername=castopod
# restapi.basicAuthPassword=password
# restapi.basicAuth=true

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
class AddFullTextSearchIndexes extends BaseMigration
{
public function up(): void
{
$prefix = $this->db->getPrefix();
$createQuery = <<<CODE_SAMPLE
ALTER TABLE {$prefix}episodes DROP INDEX IF EXISTS title;
CODE_SAMPLE;
$this->db->query($createQuery);
$createQuery = <<<CODE_SAMPLE
ALTER TABLE {$prefix}episodes
ADD FULLTEXT episodes_search (title, description_markdown, slug, location_name);
CODE_SAMPLE;
$this->db->query($createQuery);
$createQuery = <<<CODE_SAMPLE
ALTER TABLE {$prefix}podcasts
ADD FULLTEXT podcasts_search (title, description_markdown, handle, location_name);
CODE_SAMPLE;
$this->db->query($createQuery);
}
public function down(): void
{
$prefix = $this->db->getPrefix();
$createQuery = <<<CODE_SAMPLE
ALTER TABLE {$prefix}episodes
DROP INDEX IF EXISTS episodes_search;
CODE_SAMPLE;
$this->db->query($createQuery);
$createQuery = <<<CODE_SAMPLE
ALTER TABLE {$prefix}podcasts
DROP INDEX IF EXISTS podcasts_search;
CODE_SAMPLE;
$this->db->query($createQuery);
}
}

View File

@ -8,6 +8,27 @@ use CodeIgniter\Database\Seeder;
class FakeSinglePodcastApiSeeder extends Seeder
{
/**
* @return array{id: int, file_key: string, file_size: string, file_mimetype: string, file_metadata: string, type: string, description: null, language_code: string, uploaded_by: string, updated_by: string, uploaded_at: string, updated_at: string}
*/
public static function audio(): array
{
return [
'id' => 3,
'file_key' => 'podcasts/test/1685531765_84fb3309111ece22ca37.mp3',
'file_size' => '2737773',
'file_mimetype' => 'audio/mpeg',
'file_metadata' => '{"GETID3_VERSION":"2.0.x-202207161647","filesize":2737773,"filepath":"\\/tmp","filename":"php76vXQR","filenamepath":"\\/tmp\\/php76vXQR","avdataoffset":45,"avdataend":2737773,"fileformat":"mp3","audio":{"dataformat":"mp3","channels":2,"sample_rate":48000,"bitrate":128008.9774161874,"channelmode":"stereo","bitrate_mode":"cbr","lossless":false,"encoder_options":"CBR128","compression_ratio":0.08333917800533033,"streams":[{"dataformat":"mp3","channels":2,"sample_rate":48000,"bitrate":128008.9774161874,"channelmode":"stereo","bitrate_mode":"cbr","lossless":false,"encoder_options":"CBR128","compression_ratio":0.08333917800533033}]},"tags":{"id3v2":{"encoder_settings":["Lavf58.29.100"]}},"encoding":"UTF-8","id3v2":{"header":true,"flags":{"unsynch":false,"exthead":false,"experim":false,"isfooter":false},"majorversion":4,"minorversion":0,"headerlength":45,"tag_offset_start":0,"tag_offset_end":45,"encoding":"UTF-8","comments":{"encoder_settings":["Lavf58.29.100"]},"TSSE":[{"frame_name":"TSSE","frame_flags_raw":0,"data":"Lavf58.29.100","datalength":15,"dataoffset":10,"framenamelong":"Software\\/Hardware and settings used for encoding","framenameshort":"encoder_settings","flags":{"TagAlterPreservation":false,"FileAlterPreservation":false,"ReadOnly":false,"GroupingIdentity":false,"compression":false,"Encryption":false,"Unsynchronisation":false,"DataLengthIndicator":false},"encodingid":3,"encoding":"UTF-8"}],"padding":{"start":35,"length":10,"valid":true}},"mime_type":"audio\\/mpeg","mpeg":{"audio":{"raw":{"synch":4094,"version":3,"layer":1,"protection":1,"bitrate":5,"sample_rate":1,"padding":0,"private":0,"channelmode":0,"modeextension":0,"copyright":0,"original":0,"emphasis":0},"version":"1","layer":3,"channelmode":"stereo","channels":2,"sample_rate":48000,"protection":false,"private":false,"modeextension":"","copyright":false,"original":false,"emphasis":"none","padding":false,"bitrate":128008.9774161874,"framelength":384,"bitrate_mode":"cbr","VBR_method":"Xing","xing_flags_raw":15,"xing_flags":{"frames":true,"bytes":true,"toc":true,"vbr_scale":true},"VBR_frames":7129,"VBR_bytes":2737728,"toc":[0,3,5,8,10,13,16,18,20,22,26,28,31,33,36,39,41,43,45,49,51,54,56,59,62,64,66,68,72,74,77,79,82,85,87,89,91,95,97,99,102,105,108,110,112,114,118,120,122,125,128,131,133,135,137,141,143,145,148,150,153,156,158,160,164,166,168,171,173,176,179,181,183,187,189,191,194,196,199,202,204,206,210,212,214,217,219,222,225,227,229,233,235,237,240,242,245,248,250,252],"VBR_scale":0,"VBR_bitrate":128008.9774161874}},"playtime_seconds":171.0720016831475,"tags_html":{"id3v2":{"encoder_settings":["Lavf58.29.100"]}},"bitrate":128008.9774161874,"playtime_string":"2:51"}',
'type' => 'audio',
'description' => null,
'language_code' => 'pl',
'uploaded_by' => '1',
'updated_by' => '1',
'uploaded_at' => '2023-05-31 11:16:05',
'updated_at' => '2023-05-31 11:16:05',
];
}
/**
* @return array{id: int, file_key: string, file_size: int, file_mimetype: string, file_metadata: string, type: string, description: null, language_code: null, uploaded_by: int, updated_by: int, uploaded_at: string, updated_at: string}
*/
@ -125,6 +146,46 @@ class FakeSinglePodcastApiSeeder extends Seeder
];
}
/**
* @return array{id: int, podcast_id: int, guid: string, title: string, slug: string, audio_id: int, description_markdown: string, description_html: string, cover_id: int, transcript_id: null, transcript_remote_url: null, chapters_id: null, chapters_remote_url: null, parental_advisory: null, number: int, season_number: null, type: string, is_blocked: false, location_name: null, location_geo: null, location_osm: null, custom_rss: null, is_published_on_hubs: false, posts_count: int, comments_count: int, is_premium: false, created_by: int, updated_by: int, published_at: null, created_at: string, updated_at: string}
*/
public static function episode(): array
{
return [
'id' => 1,
'podcast_id' => 1,
'guid' => 'http://localhost:8080/@test/episodes/muzyka-marzen',
'title' => 'Episode title',
'slug' => 'episode-slug',
'audio_id' => 3,
'description_markdown' => '123',
'description_html' => '<p>123</p>',
'cover_id' => 1,
'transcript_id' => null,
'transcript_remote_url' => null,
'chapters_id' => null,
'chapters_remote_url' => null,
'parental_advisory' => null,
'number' => 1,
'season_number' => null,
'type' => 'full',
'is_blocked' => false,
'location_name' => null,
'location_geo' => null,
'location_osm' => null,
'custom_rss' => null,
'is_published_on_hubs' => false,
'posts_count' => 0,
'comments_count' => 0,
'is_premium' => false,
'created_by' => 1,
'updated_by' => 1,
'published_at' => null,
'created_at' => '2023-05-31 11:16:06',
'updated_at' => '2023-05-31 11:16:06',
];
}
public function run(): void
{
$this->call(AppSeeder::class);
@ -133,9 +194,13 @@ class FakeSinglePodcastApiSeeder extends Seeder
->insert(self::cover());
$this->db->table('media')
->insert(self::banner());
$this->db->table('media')
->insert(self::audio());
$this->db->table('fediverse_actors')
->insert(self::actor());
$this->db->table('podcasts')
->insert(self::podcast());
$this->db->table('episodes')
->insert(self::episode());
}
}

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Entities\Episode;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Database\BaseResult;
use CodeIgniter\I18n\Time;
use CodeIgniter\Model;
@ -434,6 +435,37 @@ class EpisodeModel extends Model
])->countAllResults() > 0;
}
public function fullTextSearch(string $query): ?BaseBuilder
{
$prefix = $this->db->getPrefix();
$episodeTable = $prefix . $this->builder()->getTable();
$podcastModel = (new PodcastModel());
$podcastTable = $podcastModel->db->getPrefix() . $podcastModel->builder()->getTable();
$this->builder()
->select('' . $episodeTable . '.*')
->select('
' . $this->getFullTextMatchClauseForEpisodes($episodeTable, $query) . ' as episodes_score,
' . $podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query) . ' as podcasts_score,
')
->select("{$podcastTable}.created_at AS podcast_created_at")
->select(
"{$podcastTable}.title as podcast_title, {$podcastTable}.handle as podcast_handle, {$podcastTable}.description_markdown as podcast_description_markdown"
)
->join($podcastTable, "{$podcastTable} on {$podcastTable}.id = {$episodeTable}.podcast_id")
->where('
(' .
$this->getFullTextMatchClauseForEpisodes($episodeTable, $query)
. 'OR' .
$podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query)
. ')
');
return $this->builder;
}
/**
* @param mixed[] $data
*
@ -462,4 +494,17 @@ class EpisodeModel extends Model
return $data;
}
private function getFullTextMatchClauseForEpisodes(string $table, string $value): string
{
return '
MATCH (
' . $table . '.title,
' . $table . '.description_markdown,
' . $table . '.slug,
' . $table . '.location_name
)
AGAINST("*' . preg_replace('/[^\p{L}\p{N}_]+/u', ' ', $value) . '*" IN BOOLEAN MODE)
';
}
}

View File

@ -384,6 +384,19 @@ class PodcastModel extends Model
return $data;
}
public function getFullTextMatchClauseForPodcasts(string $table, string $value): string
{
return '
MATCH (
' . $table . '.title ,
' . $table . '.description_markdown,
' . $table . '.handle,
' . $table . '.location_name
)
AGAINST("*' . preg_replace('/[^\p{L}\p{N}_]+/u', ' ', $value) . '*" IN BOOLEAN MODE)
';
}
/**
* Creates an actor linked to the podcast (Triggered before insert)
*

View File

@ -86,7 +86,7 @@ class EpisodeController extends BaseController
->select('episodes.*, IFNULL(SUM(ape.hits),0) as downloads')
->join('analytics_podcasts_by_episode ape', 'episodes.id=ape.episode_id', 'left')
->where('episodes.podcast_id', $this->podcast->id)
->where("MATCH (title, description_markdown) AGAINST ('{$query}')")
->where("MATCH (title, description_markdown, slug, location_name) AGAINST ('{$query}')")
->groupBy('episodes.id');
}
} else {

View File

@ -15,6 +15,17 @@ class RestApi extends BaseConfig
*/
public bool $enabled = false;
public bool $basicAuth = false;
public ?string $basicAuthUsername = null;
public ?string $basicAuthPassword = null;
/**
* Default results limit.
*/
public int $limit = 10;
/**
* --------------------------------------------------------------------------
* Rest API gateway

View File

@ -19,3 +19,17 @@ $routes->group(
$routes->get('(:any)', 'ExceptionController::notFound');
}
);
$routes->group(
config('RestApi')
->gateway . 'episodes',
[
'namespace' => 'Modules\Api\Rest\V1\Controllers',
'filter' => 'rest-api',
],
static function ($routes): void {
$routes->get('/', 'EpisodeController::list');
$routes->get('(:num)', 'EpisodeController::view/$1');
$routes->get('(:any)', 'ExceptionController::notFound');
}
);

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Modules\Api\Rest\V1\Controllers;
use App\Entities\Episode;
use App\Models\EpisodeModel;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\Response;
use Modules\Api\Rest\V1\Config\Services;
class EpisodeController extends Controller
{
use ResponseTrait;
public function __construct()
{
Services::restApiExceptions()->initialize();
}
public function list(): Response
{
$query = $this->request->getGet('query');
$order = $this->request->getGet('order') ?? 'newest';
$podcastIds = $this->request->getGet('podcastIds');
$builder = (new EpisodeModel());
if ($podcastIds !== null) {
$builder->whereIn('podcast_id', explode(',', (string) $podcastIds));
}
if ($query !== null) {
$builder->fullTextSearch($query);
if ($order === 'query') {
$builder->orderBy('(episodes_score + podcasts_score)', 'desc');
}
}
if ($order === 'newest') {
$builder->orderBy($builder->db->getPrefix() . $builder->getTable() . '.created_at', 'desc');
}
$data = $builder->findAll(
(int) ($this->request->getGet('limit') ?? config('RestApi')->limit),
(int) $this->request->getGet('offset')
);
array_map(static function ($episode): void {
self::mapEpisode($episode);
}, $data);
return $this->respond($data);
}
public function view(int $id): Response
{
$episode = (new EpisodeModel())->getEpisodeById($id);
if (! $episode instanceof Episode) {
return $this->failNotFound('Episode not found');
}
return $this->respond($this->mapEpisode($episode));
}
protected static function mapEpisode(Episode $episode): Episode
{
$episode->cover_url = $episode->getCover()
->file_url;
$episode->audio_url = $episode->getAudioUrl();
$episode->duration = round($episode->audio->duration);
return $episode;
}
}

View File

@ -24,19 +24,37 @@ class PodcastController extends Controller
{
$data = (new PodcastModel())->findAll();
array_map(static function ($podcast): void {
$podcast->feed_url = $podcast->getFeedUrl();
self::mapPodcast($podcast);
}, $data);
return $this->respond($data);
}
public function view(int $id): Response
{
$data = (new PodcastModel())->getPodcastById($id);
if (! $data instanceof Podcast) {
$podcast = (new PodcastModel())->getPodcastById($id);
if (! $podcast instanceof Podcast) {
return $this->failNotFound('Podcast not found');
}
$data->feed_url = $data->getFeedUrl();
return $this->respond($data);
return $this->respond(self::mapPodcast($podcast));
}
protected static function mapPodcast(Podcast $podcast): Podcast
{
$podcast->feed_url = $podcast->getFeedUrl();
$podcast->actor_display_name = $podcast->getActor()
->display_name;
$podcast->cover_url = $podcast->getCover()
->file_url;
$categories = [$podcast->getCategory(), ...$podcast->getOtherCategories()];
foreach ($categories as $category) {
$category->translated = lang('Podcast.category_options.' . $category->code, [], null, false);
}
$podcast->categories = $categories;
return $podcast;
}
}

View File

@ -6,16 +6,52 @@ namespace Modules\Api\Rest\V1\Filters;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\Request;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\ResponseInterface;
use Modules\Api\Rest\V1\Config\RestApi;
class ApiFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null): void
/**
* @param Request $request
*/
public function before(RequestInterface $request, $arguments = null)
{
if (! config('RestApi')->enabled) {
/** @var RestApi $restApiConfig */
$restApiConfig = config('RestApi');
if (! $restApiConfig->enabled) {
throw PageNotFoundException::forPageNotFound();
}
if ($restApiConfig->basicAuth) {
/** @var Response $response */
$response = service('response');
if (! $request->hasHeader('Authorization')) {
$response->setStatusCode(401);
return $response;
}
$authHeader = $request->getHeaderLine('Authorization');
if (substr($authHeader, 0, 6) !== 'Basic ') {
$response->setStatusCode(401);
return $response;
}
$auth_token = base64_decode(substr($authHeader, 6), true);
list($username, $password) = explode(':', (string) $auth_token);
if (! ($username === $restApiConfig->basicAuthUsername && $password === $restApiConfig->basicAuthPassword)) {
$response->setStatusCode(401);
return $response;
}
}
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace modules\Api\Rest\V1;
use App\Database\Seeds\FakeSinglePodcastApiSeeder;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
class EpisodeTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
/**
* @var bool
*/
protected $migrate = true;
/**
* @var bool
*/
protected $migrateOnce = false;
/**
* @var string|null
*/
protected $namespace;
/**
* @var string
*/
protected $seed = 'FakeSinglePodcastApiSeeder';
/**
* @var string
*/
protected $basePath = 'app/Database';
/**
* @var array<string, mixed>
*/
private array $episode = [];
private readonly string $apiUrl;
public function __construct(?string $name = null)
{
parent::__construct($name);
$this->episode = FakeSinglePodcastApiSeeder::episode();
$this->episode['created_at'] = [];
$this->episode['updated_at'] = [];
$this->apiUrl = config('RestApi')
->gateway;
}
public function testList(): void
{
$result = $this->call('get', $this->apiUrl . 'episodes');
$result->assertStatus(200);
$result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
$result->assertJSONFragment([
0 => $this->episode,
]);
}
public function testView(): void
{
$result = $this->call('get', $this->apiUrl . 'episodes/1');
$result->assertStatus(200);
$result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
$result->assertJSONFragment($this->episode);
}
public function testViewNotFound(): void
{
$result = $this->call('get', $this->apiUrl . 'episodes/2');
$result->assertStatus(404);
$result->assertJSONExact(
[
'status' => 404,
'error' => 404,
'messages' => [
'error' => 'Episode not found',
],
]
);
$result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
}
/*
* Refreshing database to fetch empty array of episodes
*/
public function testListEmpty(): void
{
$this->regressDatabase();
$this->migrateDatabase();
$result = $this->call('get', $this->apiUrl . 'episodes');
$result->assertStatus(200);
$result->assertHeader('Content-Type', 'application/json; charset=UTF-8');
$result->assertJSONExact([]);
$this->seed($this->seed);
}
}