From 4651d01a84ff3ea8433a8ae26cfd750a1ec9e88d Mon Sep 17 00:00:00 2001 From: Yassine Doghri <yassine@doghri.fr> Date: Fri, 12 Jun 2020 19:31:10 +0000 Subject: [PATCH] feat: write id3v2 tags to episode's audio file - add $mediaRoot parameter in app config - add and refactor helpers : id3, media and url - add basic vscode settings for code formatting in devcontainer.json - set post_max_size to Dockerfile --- .devcontainer/Dockerfile | 4 - .devcontainer/devcontainer.json | 10 +- Dockerfile | 5 +- app/Config/App.php | 8 ++ app/Config/Cache.php | 4 +- app/Controllers/Episodes.php | 52 ++++++--- app/Controllers/Podcasts.php | 14 +-- .../2020-06-05-170000_add_episodes.php | 8 +- app/Entities/Episode.php | 2 +- app/Entities/Podcast.php | 5 +- app/Helpers/file_helper.php | 53 --------- app/Helpers/id3_helper.php | 103 ++++++++++++++++++ app/Helpers/media_helper.php | 46 ++++++++ app/Helpers/url_helper.php | 18 +++ app/Models/EpisodeModel.php | 2 +- app/Views/episodes/create.php | 3 +- app/Views/episodes/view.php | 6 +- app/Views/home.php | 6 +- app/Views/podcasts/view.php | 18 ++- 19 files changed, 264 insertions(+), 103 deletions(-) delete mode 100644 app/Helpers/file_helper.php create mode 100644 app/Helpers/id3_helper.php create mode 100644 app/Helpers/media_helper.php create mode 100644 app/Helpers/url_helper.php diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1bd8905cbd..44fa834e03 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 869ffed789..1a241bf61b 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 4c81d5f65c..279e67839a 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 cc58ca46dd..6bb6cb457e 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 4ac1bf6d01..d743c58a89 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 a3527ece2f..df0d1cb1d9 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 f118a163aa..e190eb28bc 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 35a9d7322d..e6363dc529 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 1593da5e43..48ac268d6f 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 28034b9fd2..4bbd6de60e 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 6b9e313354..0000000000 --- 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 0000000000..0c5ade85f3 --- /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 0000000000..32845057ee --- /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 0000000000..5b3ca930e4 --- /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 22c9b4dc8c..268d7c4bb4 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 4015d28c89..1f23d23490 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 345bd29363..3f9f8073ee 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 a0587247da..c9e242a762 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 ae35991cee..e97939d089 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> -- GitLab