Commit 4651d01a authored by Yassine Doghri's avatar Yassine Doghri
Browse files

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
parent d2dc6e64
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 -
......
......@@ -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",
......
......@@ -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
......@@ -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';
}
<?php namespace Config;
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
......
......@@ -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(
......
......@@ -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))
);
......
......@@ -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');
}
......
......@@ -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',
......
......@@ -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',
];
}
<?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'],
];
}
<?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);
}
}
<?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;
}
<?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);
}
......@@ -27,7 +27,7 @@ class EpisodeModel extends Model
'duration',
'image',
'explicit',
'episode_number',
'number',
'season_number',
'type',
'block',
......
......@@ -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">
......
......@@ -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.
......
......@@ -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>
......
......@@ -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>
......