Commit 85505d4b authored by Krzysztof Domańczy's avatar Krzysztof Domańczy Committed by Yassine Doghri
Browse files

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

closes #296
parent 2b516fee
Loading
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -63,3 +63,7 @@ cache.handler="file"
# REST API configuration
#--------------------------------------------------------------------
# restapi.enabled=true
# restapi.basicAuthUsername=castopod
# restapi.basicAuthPassword=password
# restapi.basicAuth=true
+52 −0
Original line number Diff line number Diff line
<?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);
    }
}
+65 −0
Original line number Diff line number Diff line
@@ -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());
    }
}
+45 −0
Original line number Diff line number Diff line
@@ -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)
            ';
    }
}
+13 −0
Original line number Diff line number Diff line
@@ -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)
     *
Loading