diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1bd8905cbd8bf0920e0f0a8550ab0ffc34b887bd..44fa834e0305790c0deb871814f0b8071d4bb612 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,9 +1,5 @@ FROM php:latest -RUN apt-get update && apt-get install -y \ - libicu-dev \ - && docker-php-ext-install intl - COPY --from=composer /usr/bin/composer /usr/bin/composer RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 869ffed7898a66e5b8589d8232bbce46309c2632..1a241bf61b77b68a36ad450a7cc2e11043b8eea6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,15 @@ "name": "Existing Dockerfile", "dockerFile": "./Dockerfile", "settings": { - "terminal.integrated.shell.linux": null + "terminal.integrated.shell.linux": "/bin/bash", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + }, + "[php]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "color-highlight.markerType": "dot-before" }, "extensions": [ "mikestead.dotenv", diff --git a/Dockerfile b/Dockerfile index 4c81d5f65c569bfe702f2234a104785ac1eec8d3..279e67839a1b64d3898b40f290e130ebe2f77e2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,14 @@ WORKDIR /castopod # Install intl extension using https://github.com/mlocati/docker-php-extension-installer RUN apt-get update && apt-get install -y \ libicu-dev \ - && docker-php-ext-install intl + libpng-dev \ + zlib1g-dev \ + && docker-php-ext-install intl gd RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli RUN echo "file_uploads = On\n" \ "memory_limit = 100M\n" \ "upload_max_filesize = 100M\n" \ + "post_max_size = 120M\n" \ > /usr/local/etc/php/conf.d/uploads.ini diff --git a/app/Config/App.php b/app/Config/App.php index cc58ca46dd2585c64ac5e2cab7ed111d9414718d..6bb6cb457e851cb72f83f39bef7db7d0ebacfa08 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -266,4 +266,12 @@ class App extends BaseConfig | - http://www.w3.org/TR/CSP/ */ public $CSPEnabled = false; + + /* + |-------------------------------------------------------------------------- + | Media root folder + |-------------------------------------------------------------------------- + | Defines the root folder for media files storage + */ + public $mediaRoot = 'media'; } diff --git a/app/Config/Cache.php b/app/Config/Cache.php index 4ac1bf6d01e40e466808ef30e8bdad0da23f5fbc..d743c58a89517a6c7dd3fd3d7282ce8e57815f94 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -1,4 +1,6 @@ -<?php namespace Config; +<?php + +namespace Config; use CodeIgniter\Config\BaseConfig; diff --git a/app/Controllers/Episodes.php b/app/Controllers/Episodes.php index a3527ece2f304bfe2f998b7ba752708bae89570a..df0d1cb1d97202b2cec41fa57abea7d6328dab24 100644 --- a/app/Controllers/Episodes.php +++ b/app/Controllers/Episodes.php @@ -14,7 +14,7 @@ class Episodes extends BaseController { public function create($podcast_slug) { - helper(['form', 'database', 'file']); + helper(['form', 'database', 'media', 'id3']); $episode_model = new EpisodeModel(); $podcast_model = new PodcastModel(); @@ -28,6 +28,7 @@ class Episodes extends BaseController 'title' => 'required', 'slug' => 'required', 'description' => 'required', + 'type' => 'required', ]) ) { $data = [ @@ -43,27 +44,40 @@ class Episodes extends BaseController $episode_slug = $this->request->getVar('slug'); $episode_file = $this->request->getFile('episode_file'); - $episode_file_metadata = get_file_metadata($episode_file); - $episode_file_name = - $episode_slug . '.' . $episode_file->getExtension(); - $episode_path = save_podcast_media( - $episode_file, - $podcast_name, - $episode_file_name - ); + $episode_file_metadata = get_file_tags($episode_file); $image = $this->request->getFile('image'); - $image_path = ''; + + // By default, the episode's image path is set to the podcast's + $image_path = $podcast->image; + + // check whether the user has inputted an image and store it if ($image->isValid()) { - $image_name = $episode_slug . '.' . $image->getExtension(); $image_path = save_podcast_media( $image, $podcast_name, - $image_name + $episode_slug + ); + } elseif ($APICdata = $episode_file_metadata['attached_picture']) { + // if the user didn't input an image, + // check if the uploaded audio file has an attached cover and store it + $cover_image = new \CodeIgniter\Files\File('episode_cover'); + file_put_contents($cover_image, $APICdata); + + $image_path = save_podcast_media( + $cover_image, + $podcast_name, + $episode_slug ); } - $episode_model->save([ + $episode_path = save_podcast_media( + $episode_file, + $podcast_name, + $episode_slug + ); + + $episode = new \App\Entities\Episode([ 'podcast_id' => $podcast->id, 'title' => $this->request->getVar('title'), 'slug' => $episode_slug, @@ -76,14 +90,18 @@ class Episodes extends BaseController 'duration' => $episode_file_metadata['playtime_seconds'], 'image' => $image_path, 'explicit' => $this->request->getVar('explicit') or false, - 'episode_number' => - $this->request->getVar('episode_number') or null, - 'season_number' => - $this->request->getVar('season_number') or null, + 'number' => $this->request->getVar('episode_number'), + 'season_number' => $this->request->getVar('season_number') + ? $this->request->getVar('season_number') + : null, 'type' => $this->request->getVar('type'), 'block' => $this->request->getVar('block') or false, ]); + $episode_model->save($episode); + + $episode_file = write_file_tags($podcast, $episode); + return redirect()->to( base_url( route_to( diff --git a/app/Controllers/Podcasts.php b/app/Controllers/Podcasts.php index f118a163aac1a2911187b90a9a891bd40b8a405e..e190eb28bce8aa6b900346708c4fe03bc1fc98d3 100644 --- a/app/Controllers/Podcasts.php +++ b/app/Controllers/Podcasts.php @@ -7,6 +7,7 @@ namespace App\Controllers; +use App\Entities\Podcast; use App\Models\CategoryModel; use App\Models\EpisodeModel; use App\Models\LanguageModel; @@ -16,7 +17,7 @@ class Podcasts extends BaseController { public function create() { - helper(['form', 'database', 'file', 'misc']); + helper(['form', 'database', 'media', 'misc']); $podcast_model = new PodcastModel(); if ( @@ -48,14 +49,9 @@ class Podcasts extends BaseController } else { $image = $this->request->getFile('image'); $podcast_name = $this->request->getVar('name'); - $image_name = 'cover.' . $image->getExtension(); - $image_path = save_podcast_media( - $image, - $podcast_name, - $image_name - ); + $image_path = save_podcast_media($image, $podcast_name, 'cover'); - $podcast_model->save([ + $podcast = new Podcast([ 'title' => $this->request->getVar('title'), 'name' => $podcast_name, 'description' => $this->request->getVar('description'), @@ -78,6 +74,8 @@ class Podcasts extends BaseController ), ]); + $podcast_model->save($podcast); + return redirect()->to( base_url(route_to('podcasts_view', '@' . $podcast_name)) ); diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php index 35a9d7322de44cc8ca3cd742c13370b1baaef1cd..e6363dc529b1f342c244e3cf3758cbeb2e4f8c12 100644 --- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php +++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php @@ -97,11 +97,10 @@ class AddEpisodes extends Migration 'comment' => 'The episode parental advisory information. Where the explicit value can be one of the following: true. If you specify true, indicating the presence of explicit content, Apple Podcasts displays an Explicit parental advisory graphic for your episode. Episodes containing explicit material aren’t available in some Apple Podcasts territories. false. If you specify false, indicating that the episode does not contain explicit language or adult content, Apple Podcasts displays a Clean parental advisory graphic for your episode.', ], - 'episode_number' => [ + 'number' => [ 'type' => 'INT', 'constraint' => 10, 'unsigned' => true, - 'null' => true, 'comment' => 'An episode number. If all your episodes have numbers and you would like them to be ordered based on them use this tag for each one. Episode numbers are optional for <itunes:type> episodic shows, but are mandatory for serial shows. Where episode is a non-zero integer (1, 2, 3, etc.) representing your episode number.', ], @@ -136,6 +135,11 @@ class AddEpisodes extends Migration ]); $this->forge->addKey('id', true); $this->forge->addUniqueKey(['podcast_id', 'slug']); + + // FIXME: as season_number can be null, the unique key constraint is useless when it is + // the majority of RDBMS behave that way + // possible solution: remove the null constraint on the season_number and set a default + $this->forge->addUniqueKey(['podcast_id', 'season_number', 'number']); $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); $this->forge->createTable('episodes'); } diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 1593da5e43d75bb3742f038737e768fc13f4674b..48ac268d6f24692ea4b9ee498f96f8552685c9cf 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -23,7 +23,7 @@ class Episode extends Entity 'duration' => 'integer', 'image' => 'string', 'explicit' => 'boolean', - 'episode_number' => 'integer', + 'number' => 'integer', 'season_number' => '?integer', 'type' => 'string', 'block' => 'boolean', diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php index 28034b9fd26c8e3ba2ff6155a90be201d0442342..4bbd6de60e5c6a9ccd67b3b7a9761fe973af9307 100644 --- a/app/Entities/Podcast.php +++ b/app/Entities/Podcast.php @@ -12,13 +12,13 @@ use CodeIgniter\Entity; class Podcast extends Entity { protected $casts = [ + 'id' => 'integer', 'title' => 'string', 'name' => 'string', 'description' => 'string', - 'episode_description_footer' => '?string', 'image' => 'string', 'language' => 'string', - 'category' => 'array', + 'category' => 'string', 'explicit' => 'boolean', 'author' => '?string', 'owner_name' => '?string', @@ -27,6 +27,7 @@ class Podcast extends Entity 'copyright' => '?string', 'block' => 'boolean', 'complete' => 'boolean', + 'episode_description_footer' => '?string', 'custom_html_head' => '?string', ]; } diff --git a/app/Helpers/file_helper.php b/app/Helpers/file_helper.php deleted file mode 100644 index 6b9e3133548d6e44c6b46db6bd07a92fa4c7d49b..0000000000000000000000000000000000000000 --- a/app/Helpers/file_helper.php +++ /dev/null @@ -1,53 +0,0 @@ -<?php -/** - * @copyright 2020 Podlibre - * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 - * @link https://castopod.org/ - */ - -use JamesHeinrich\GetID3\GetID3; - -/** - * Saves a file to the corresponding podcast folder in `public/media` - * - * @param UploadedFile $file - * @param string $podcast_name - * @param string $file_name - * - * @return string The absolute path of the file - */ -function save_podcast_media($file, $podcast_name, $file_name) -{ - $image_storage_folder = 'media/' . $podcast_name . '/'; - - // overwrite file if already existing - $file->move($image_storage_folder, $file_name, true); - - return $image_storage_folder . $file_name; -} - -/** - * Gets audio file metadata and ID3 info - * - * @param UploadedFile $file - * - * @return array - */ -function get_file_metadata($file) -{ - if (!$file->isValid()) { - throw new RuntimeException( - $file->getErrorString() . '(' . $file->getError() . ')' - ); - } - - $getID3 = new GetID3(); - $FileInfo = $getID3->analyze($file); - - return [ - 'cover_picture' => $FileInfo['comments']['picture'][0]['data'], - 'filesize' => $FileInfo['filesize'], - 'mime_type' => $FileInfo['mime_type'], - 'playtime_seconds' => $FileInfo['playtime_seconds'], - ]; -} diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php new file mode 100644 index 0000000000000000000000000000000000000000..0c5ade85f3b26618b7af744c9c28fd91a9a87939 --- /dev/null +++ b/app/Helpers/id3_helper.php @@ -0,0 +1,103 @@ +<?php +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +use JamesHeinrich\GetID3\GetID3; +use JamesHeinrich\GetID3\WriteTags; + +/** + * Gets audio file metadata and ID3 info + * + * @param UploadedFile $file + * + * @return array + */ +function get_file_tags($file) +{ + $getID3 = new GetID3(); + $FileInfo = $getID3->analyze($file); + + return [ + 'filesize' => $FileInfo['filesize'], + 'mime_type' => $FileInfo['mime_type'], + 'playtime_seconds' => $FileInfo['playtime_seconds'], + 'attached_picture' => array_key_exists('comments', $FileInfo) + ? $FileInfo['comments']['picture'][0]['data'] + : null, + ]; +} + +/** + * Write audio file metadata / ID3 tags + * + * @param App\Entities\Podcast $podcast + * @param App\Entities\Episode $episode + * + * @return UploadedFile + */ +function write_file_tags($podcast, $episode) +{ + $TextEncoding = 'UTF-8'; + + // Initialize getID3 tag-writing module + $tagwriter = new WriteTags(); + $tagwriter->filename = media_path($episode->enclosure_url); + + // set various options (optional) + $tagwriter->tagformats = ['id3v2.4']; + $tagwriter->tag_encoding = $TextEncoding; + + $cover = new \CodeIgniter\Files\File(media_path($episode->image)); + + $APICdata = file_get_contents($cover->getRealPath()); + + // TODO: variables used for podcast specific tags + // $podcast_url = base_url('@' . $podcast->name); + // $podcast_feed_url = base_url('@' . $podcast->name . '/feed.xml'); + // $episode_media_url = media_url($podcast->name . '/' . $episode->slug); + + // populate data array + $TagData = [ + 'title' => [$episode->title], + 'artist' => [$podcast->author], + 'album' => [$podcast->title], + 'year' => [$episode->pub_date->format('Y')], + 'genre' => ['Podcast'], + 'comment' => [$episode->description], + 'track_number' => [strval($episode->number)], + 'copyright_message' => [$podcast->copyright], + 'publisher' => ['Podlibre'], + 'encoded_by' => ['Castopod'], + + // TODO: find a way to add the remaining tags for podcasts as the library doesn't seem to allow it + // 'website' => [$podcast_url], + // 'podcast' => [], + // 'podcast_identifier' => [$episode_media_url], + // 'podcast_feed' => [$podcast_feed_url], + // 'podcast_description' => [$podcast->description], + ]; + + $TagData['attached_picture'][] = [ + 'picturetypeid' => 2, // Cover. More: module.tag.id3v2.php + 'data' => $APICdata, + 'description' => 'cover', + 'mime' => $cover->getMimeType(), + ]; + + $tagwriter->tag_data = $TagData; + + // write tags + if ($tagwriter->WriteTags()) { + echo 'Successfully wrote tags<br>'; + if (!empty($tagwriter->warnings)) { + echo 'There were some warnings:<br>' . + implode('<br><br>', $tagwriter->warnings); + } + } else { + echo 'Failed to write tags!<br>' . + implode('<br><br>', $tagwriter->errors); + } +} diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php new file mode 100644 index 0000000000000000000000000000000000000000..32845057ee56e4c8177303cf00858d9bac1208fa --- /dev/null +++ b/app/Helpers/media_helper.php @@ -0,0 +1,46 @@ +<?php +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +/** + * Saves a file to the corresponding podcast folder in `public/media` + * + * @param UploadedFile $file + * @param string $podcast_name + * @param string $file_name + * + * @return string The absolute path of the file in media root + */ +function save_podcast_media($file, $podcast_name, $media_name) +{ + $file_name = $media_name . '.' . $file->guessExtension(); + + // move to media folder and overwrite file if already existing + $file->move( + config('App')->mediaRoot . '/' . $podcast_name . '/', + $file_name, + true + ); + + return $podcast_name . '/' . $file_name; +} + +/** + * Prefixes the root media path to a given uri + * + * @param mixed $uri URI string or array of URI segments + * @return string + */ +function media_path($uri = ''): string +{ + // convert segment array to string + if (is_array($uri)) { + $uri = implode('/', $uri); + } + $uri = trim($uri, '/'); + + return config('App')->mediaRoot . '/' . $uri; +} diff --git a/app/Helpers/url_helper.php b/app/Helpers/url_helper.php new file mode 100644 index 0000000000000000000000000000000000000000..5b3ca930e47f8a2adf963debb3fe07952786b022 --- /dev/null +++ b/app/Helpers/url_helper.php @@ -0,0 +1,18 @@ +<?php +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +/** + * Return the media base URL to use in views + * + * @param mixed $uri URI string or array of URI segments + * @param string $protocol + * @return string + */ +function media_url($uri = '', string $protocol = null): string +{ + return base_url(config('App')->mediaRoot . '/' . $uri, $protocol); +} diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 22c9b4dc8c9bdcba45f93abb86eaa170aed83a3e..268d7c4bb42e8391c634babe94f139fee1408882 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -27,7 +27,7 @@ class EpisodeModel extends Model 'duration', 'image', 'explicit', - 'episode_number', + 'number', 'season_number', 'type', 'block', diff --git a/app/Views/episodes/create.php b/app/Views/episodes/create.php index 4015d28c8987804e5cd6b5a1f4856fc23ff7e83f..1f23d23490e545e26334664066e3bf1a9b2bd6ad 100644 --- a/app/Views/episodes/create.php +++ b/app/Views/episodes/create.php @@ -50,8 +50,7 @@ <label for="episode_number"><?= lang( 'Episodes.form.episode_number' ) ?></label> - <input type="number" class="form-input" id="episode_number" name="episode_number" - <?= $podcast->type == 'serial' ? 'required' : '' ?> /> + <input type="number" class="form-input" id="episode_number" name="episode_number" required /> </div> <div class="flex flex-col mb-4"> diff --git a/app/Views/episodes/view.php b/app/Views/episodes/view.php index 345bd29363e3928d9c59dc9e650bc1b19b5718f3..3f9f8073ee26b8a3e137837ece0bede591d4ad0f 100644 --- a/app/Views/episodes/view.php +++ b/app/Views/episodes/view.php @@ -2,12 +2,12 @@ <?= $this->section('content') ?> -<h1 class="text-xl"><?= $episode->title ?></h1> -<img src="<?= base_url( +<h1 class="text-2xl font-semibold"><?= $episode->title ?></h1> +<img src="<?= media_url( $episode->image ? $episode->image : $podcast->image ) ?>" alt="Episode cover" class="object-cover w-40 h-40 mb-6" /> <audio controls> - <source src="<?= base_url( + <source src="<?= media_url( $episode->enclosure_url ) ?>" type="<?= $episode->enclosure_type ?>"> Your browser does not support the audio tag. diff --git a/app/Views/home.php b/app/Views/home.php index a0587247dae0969a39e4fe76911eb9a57ea89f2c..c9e242a76292050474e273aacc769d6c1ccf5bf3 100644 --- a/app/Views/home.php +++ b/app/Views/home.php @@ -10,7 +10,11 @@ <?php foreach ($podcasts as $podcast): ?> <a href="<?= route_to('podcasts_view', '@' . $podcast->name) ?>"> <article class="w-48 p-2 mb-4 mr-4 border shadow-sm hover:bg-gray-100 hover:shadow"> - <img alt="<?= $podcast->title ?>" src="<?= $podcast->image ?>" class="object-cover w-full h-40 mb-2" /> + <img alt="<?= $podcast->title ?>" + src="<?= media_url( + $podcast->image + ) ?>" class="object-cover w-full h-40 mb-2" + /> <h2 class="font-semibold leading-tight"><?= $podcast->title ?></h2> <p class="text-gray-600">@<?= $podcast->name ?></p> </article> diff --git a/app/Views/podcasts/view.php b/app/Views/podcasts/view.php index ae35991cee3b6aec1ecbdb03e5e71615e3bacbdc..e97939d089cf5ab3dceb2856c4a2a252b310181b 100644 --- a/app/Views/podcasts/view.php +++ b/app/Views/podcasts/view.php @@ -3,7 +3,7 @@ <?= $this->section('content') ?> <header class="py-4 border-b"> <h1 class="text-2xl"><?= $podcast->title ?></h1> - <img src="<?= base_url( + <img src="<?= media_url( $podcast->image ) ?>" alt="Podcast cover" class="w-40 h-40 mb-6" /> <a class="inline-flex px-4 py-2 border hover:bg-gray-100" href="<?= route_to( @@ -19,19 +19,25 @@ <?php if ($episodes): ?> <?php foreach ($episodes as $episode): ?> <article class="flex w-full max-w-lg p-4 mb-4 border shadow"> - <img src="<?= base_url( + <img src="<?= media_url( $episode->image ? $episode->image : $podcast->image - ) ?>" alt="<?= $episode->title ?>" class="w-32 h-32 mr-4" /> + ) ?>" alt="<?= $episode->title ?>" class="object-cover w-32 h-32 mr-4" /> <div class="flex flex-col flex-1"> <a href="<?= route_to( 'episodes_view', '@' . $podcast->name, $episode->slug ) ?>"> - <h3 class="text-xl font-semibold underline hover:no-underline"><?= $episode->title ?></h3> - </a> + <h3 class="text-xl font-semibold"> + <span class="mr-1 underline hover:no-underline"><?= $episode->title ?></span> + <span class="text-base font-bold text-gray-600">#<?= $episode->number ?></span> + </h3> + <p><?= $episode->description ?></p> + </a> <audio controls class="mt-auto"> - <source src="<?= $episode->enclosure_url ?>" type="<?= $episode->enclosure_type ?>"> + <source src="<?= media_url( + $episode->enclosure_url + ) ?>" type="<?= $episode->enclosure_type ?>"> Your browser does not support the audio tag. </audio> </div>