Add an episode from Youtube over the command-line
This commit is contained in:
parent
852b5f3e17
commit
40f91c8d1d
31
migrations/Version20230512222044.php
Normal file
31
migrations/Version20230512222044.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
32
migrations/Version20230512222100.php
Normal file
32
migrations/Version20230512222100.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
86
src/Command/DownloadCommand.php
Normal file
86
src/Command/DownloadCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'),
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
26
src/EntityListener/PodcastEntityListener.php
Normal file
26
src/EntityListener/PodcastEntityListener.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
24
src/Service/DownloadService.php
Normal file
24
src/Service/DownloadService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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',
|
||||||
[],
|
[],
|
||||||
|
|
41
src/Service/YoutubeService.php
Normal file
41
src/Service/YoutubeService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue