diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1bd8905c..44fa834e 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 869ffed7..1a241bf6 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 4c81d5f6..279e6783 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 cc58ca46..6bb6cb45 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 4ac1bf6d..d743c58a 100644 --- a/app/Config/Cache.php +++ b/app/Config/Cache.php @@ -1,4 +1,6 @@ - '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 f118a163..e190eb28 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 35a9d732..e6363dc5 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 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 1593da5e..48ac268d 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 28034b9f..4bbd6de6 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 6b9e3133..00000000 --- a/app/Helpers/file_helper.php +++ /dev/null @@ -1,53 +0,0 @@ -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 00000000..0c5ade85 --- /dev/null +++ b/app/Helpers/id3_helper.php @@ -0,0 +1,103 @@ +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
'; + if (!empty($tagwriter->warnings)) { + echo 'There were some warnings:
' . + implode('

', $tagwriter->warnings); + } + } else { + echo 'Failed to write tags!
' . + implode('

', $tagwriter->errors); + } +} diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php new file mode 100644 index 00000000..32845057 --- /dev/null +++ b/app/Helpers/media_helper.php @@ -0,0 +1,46 @@ +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 00000000..5b3ca930 --- /dev/null +++ b/app/Helpers/url_helper.php @@ -0,0 +1,18 @@ +mediaRoot . '/' . $uri, $protocol); +} diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 22c9b4dc..268d7c4b 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 4015d28c..1f23d234 100644 --- a/app/Views/episodes/create.php +++ b/app/Views/episodes/create.php @@ -50,8 +50,7 @@ - type == 'serial' ? 'required' : '' ?> /> +
diff --git a/app/Views/episodes/view.php b/app/Views/episodes/view.php index 345bd293..3f9f8073 100644 --- a/app/Views/episodes/view.php +++ b/app/Views/episodes/view.php @@ -2,12 +2,12 @@ section('content') ?> -

title ?>

-title ?> +Episode cover