Add an episode from Youtube over the command-line

This commit is contained in:
dece 2023-05-13 00:23:08 +02:00
parent 852b5f3e17
commit 40f91c8d1d
10 changed files with 249 additions and 13 deletions

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230512222044 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20230512222100 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE episode ALTER description DROP NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE episode ALTER description SET NOT NULL');
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Command;
use App\Constants;
use App\Entity\Episode;
use App\Repository\PodcastRepository;
use App\Service\DownloadService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
#[AsCommand(
name: 'app:download',
description: 'Add a short description for your command',
)]
class DownloadCommand extends Command
{
public function __construct(
protected DownloadService $downloadService,
protected EntityManagerInterface $entityManager,
protected PodcastRepository $podcastRepository,
protected ParameterBagInterface $parameterBag,
)
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('url', InputArgument::REQUIRED, 'URL to download')
->addArgument('podcast', InputArgument::REQUIRED, 'Podcast slug')
->addOption('title', '-t', InputOption::VALUE_REQUIRED, 'Episode title')
->addOption('description', '-d', InputOption::VALUE_REQUIRED, 'Episode description')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$url = $input->getArgument('url');
$io->info(sprintf('Downloading: %s', $url));
$slug = $input->getArgument('podcast');
$podcast = $this->podcastRepository->findOneBy(['slug' => $slug]);
if ($podcast === null) {
$io->error("No podcast with slug $slug.");
return Command::FAILURE;
}
$filepath = $this->downloadService->download($url);
if ($filepath === false) {
$io->error('Download failed.');
return Command::FAILURE;
}
$filename = basename($filepath);
$publicFilepath =
$this->parameterBag->get('kernel.project_dir')
. '/'
. Constants::BASE_PUBLIC_DIR
. Constants::FILES_BASE_PATH
. $filename;
rename($filepath, $publicFilepath);
$episode = new Episode();
$episode->setTitle($input->getOption('title') ?: $filename);
$episode->setDescription($input->getOption('description'));
$episode->setAudioFilename($filename);
$episode->setPodcast($podcast);
$episode->setPublicationDate(date_create());
$this->entityManager->persist($episode);
$this->entityManager->flush();
$io->success("New Episode '{$episode->getTitle()}' added.");
return Command::SUCCESS;
}
}

View file

@ -22,6 +22,7 @@ class PodcastCrudController extends AbstractCrudController
{ {
return [ return [
TextField::new('name'), TextField::new('name'),
TextField::new('slug'),
UrlField::new('website'), UrlField::new('website'),
TextEditorField::new('description'), TextEditorField::new('description'),
TextField::new('author'), TextField::new('author'),

View file

@ -9,7 +9,6 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EpisodeRepository::class)] #[ORM\Entity(repositoryClass: EpisodeRepository::class)]
##[Vich\Uploadable]
class Episode class Episode
{ {
#[ORM\Id] #[ORM\Id]
@ -20,8 +19,8 @@ class Episode
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
private string $title; private string $title;
#[ORM\Column(type: Types::TEXT)] #[ORM\Column(type: Types::TEXT, nullable: true)]
private string $description; private ?string $description;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
private ?string $audioFilename; private ?string $audioFilename;
@ -50,12 +49,12 @@ class Episode
return $this; return $this;
} }
public function getDescription(): string public function getDescription(): ?string
{ {
return $this->description; return $this->description;
} }
public function setDescription(string $description): self public function setDescription(?string $description): self
{ {
$this->description = $description; $this->description = $description;

View file

@ -7,10 +7,11 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\String\Slugger\SluggerInterface;
#[ORM\Entity(repositoryClass: PodcastRepository::class)] #[ORM\Entity(repositoryClass: PodcastRepository::class)]
#[ORM\HasLifecycleCallbacks] #[UniqueEntity('slug')]
class Podcast class Podcast
{ {
#[ORM\Id] #[ORM\Id]
@ -81,7 +82,6 @@ class Podcast
return $this; return $this;
} }
#[ORM\PrePersist]
public function generateSlug(SluggerInterface $slugger): void public function generateSlug(SluggerInterface $slugger): void
{ {
$this->slug = $this->id . '-' . $slugger->slug($this->name)->lower(); $this->slug = $this->id . '-' . $slugger->slug($this->name)->lower();

View file

@ -0,0 +1,26 @@
<?php
namespace App\EntityListener;
use App\Entity\Podcast;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;
#[AsEntityListener(event: Events::prePersist, entity: Podcast::class)]
#[AsEntityListener(event: Events::preUpdate, entity: Podcast::class)]
class PodcastEntityListener
{
public function __construct(private SluggerInterface $slugger) {}
public function prePersist(Podcast $podcast, LifecycleEventArgs $event)
{
$podcast->generateSlug($this->slugger);
}
public function preUpdate(Podcast $podcast, LifecycleEventArgs $event)
{
$podcast->generateSlug($this->slugger);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Service;
use App\Entity\Episode;
use App\Entity\Podcast;
use Psr\Log\LoggerInterface;
class DownloadService
{
public function __construct(
protected LoggerInterface $logger,
protected YoutubeService $youtubeService,
) {}
/**
* Download the audio from this URL and return its filepath.
*/
public function download(string $url): string|false
{
// For now assume every URL is a Youtube URL.
return $this->youtubeService->download($url);
}
}

View file

@ -13,18 +13,14 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class FeedService class FeedService
{ {
protected EntityManagerInterface $entityManager;
protected ParameterBagInterface $parameterBag;
protected string $baseFeedUrl; protected string $baseFeedUrl;
public function __construct( public function __construct(
EntityManagerInterface $entityManager, protected EntityManagerInterface $entityManager,
ParameterBagInterface $parameterBag, protected ParameterBagInterface $parameterBag,
UrlGeneratorInterface $urlGenerator, UrlGeneratorInterface $urlGenerator,
) )
{ {
$this->entityManager = $entityManager;
$this->parameterBag = $parameterBag;
$this->baseFeedUrl = $urlGenerator->generate( $this->baseFeedUrl = $urlGenerator->generate(
'app_index', 'app_index',
[], [],

View file

@ -0,0 +1,41 @@
<?php
namespace App\Service;
use App\Constants;
use Psr\Log\LoggerInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class YoutubeService
{
public function __construct(protected LoggerInterface $logger) {}
public function download(string $url): string|false
{
$process = new Process([
'yt-dlp',
'-x',
'-O', 'after_move:filepath', '--restrict-filenames',
'-P', sys_get_temp_dir(),
$url
]);
try {
$process->mustRun();
}
catch (ProcessFailedException $exception) {
$this->logger->error(
'yt-dlp process failed: {error}',
['error' => $exception->getMessage()]
);
return false;
}
$filepath = trim($process->getOutput());
$this->logger->info(
'Success for URL "{url}": {filepath}',
['url' => $url, 'filepath' => $filepath]
);
return $filepath;
}
}