Compare commits

...

7 Commits

10 changed files with 307 additions and 78 deletions

View File

@@ -6,6 +6,7 @@ import './bootstrap.js';
* which should already be in your base.html.twig. * which should already be in your base.html.twig.
*/ */
import './styles/app.css'; import './styles/app.css';
import PullToRefresh from 'pulltorefreshjs';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
@@ -18,3 +19,10 @@ var observer = new MutationObserver(function(mutations) {
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true}); observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});
const ptr = PullToRefresh.init({
mainElement: 'body',
onRefresh() {
window.location.reload();
}
});

View File

@@ -24,23 +24,15 @@ export default class extends Controller {
async connect() { async connect() {
this.resultCountEl = document.querySelector('#movie_results_count'); this.resultCountEl = document.querySelector('#movie_results_count');
await this.setOptions();
} }
async setOptions() { async listTargetConnected() {
if (false === this.optionsLoaded) {
this.optionsLoaded = true; this.optionsLoaded = true;
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
.then(res => res.text())
.then(response => {
this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr'); this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue); this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.dispatch('optionsLoaded', {detail: {options: this.options}}) this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.toggleIcon(); this.loadingIconOutlet.toggleIcon();
this.resultCountEl.innerText = this.options.length; this.resultCountEl.innerText = this.options.length;
});
}
} }
// Keeps compatible with Filter & TV Shows // Keeps compatible with Filter & TV Shows

View File

@@ -25,37 +25,18 @@ export default class extends Controller {
optionsLoaded = false optionsLoaded = false
isOpen = false isOpen = false
async connect() { async listTargetConnected() {
await this.setOptions();
}
async setOptions() {
if (this.optionsLoaded === false) {
this.optionsLoaded = true;
let response;
try {
response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
} catch (error) {
console.log('There was an error', error);
}
if (response?.ok) {
response = await response.text()
this.listContainerTarget.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr'); this.options = this.element.querySelectorAll('tbody tr');
if (this.options.length > 0) { if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue); this.options.forEach((option) =>
option.querySelector('.download-btn').dataset['title'] = this.titleValue
);
this.options[0].querySelector('input[type="checkbox"]').checked = true; this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
}
this.dispatch('optionsLoaded', {detail: {options: this.options}}) this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.increaseCount(); this.loadingIconOutlet.increaseCount();
} else { } else {
console.log(`HTTP Response Code: ${response?.status}`) this.countTarget.innerText = 0;
} this.episodeSelectorTarget.disabled = true;
} }
} }
@@ -134,6 +115,7 @@ export default class extends Controller {
"codec": option.querySelector('#codec').textContent.trim(), "codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(), "provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']), "languages": JSON.parse(option.dataset['languages']),
"quality": option.dataset['quality'],
} }
let include = true; let include = true;

View File

@@ -64,4 +64,7 @@ return [
'version' => '2.4.3', 'version' => '2.4.3',
'type' => 'css', 'type' => 'css',
], ],
'pulltorefreshjs' => [
'version' => '0.1.22',
],
]; ];

View File

@@ -0,0 +1,213 @@
<?php
namespace App\Search\Framework\Command;
use Aimeos\Map;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Handler\DownloadMediaHandler;
use App\Download\Framework\Repository\DownloadRepository;
use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Command\SearchCommand as CommandInput;
use App\Search\Action\Result\SearchResult;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Result\TorrentioResult;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
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\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand('search')]
class SearchCommand extends Command
{
private SearchHandler $searchHandler;
private GetTvShowOptionsHandler $getTvShowOptionsHandler;
private GetMovieOptionsHandler $getMovieOptionsHandler;
private UserRepository $userRepository;
private DownloadRepository $downloadRepository;
private DownloadMediaHandler $downloadMediaHandler;
private MessageBusInterface $bus;
public function __construct(SearchHandler $searchHandler, GetTvShowOptionsHandler $getTvShowOptionsHandler,
GetMovieOptionsHandler $getMovieOptionsHandler,
UserRepository $userRepository,
DownloadRepository $downloadRepository,
DownloadMediaHandler $downloadMediaHandler,
MessageBusInterface $bus,
?string $name = null
) {
parent::__construct($name);
$this->searchHandler = $searchHandler;
$this->getTvShowOptionsHandler = $getTvShowOptionsHandler;
$this->getMovieOptionsHandler = $getMovieOptionsHandler;
$this->userRepository = $userRepository;
$this->downloadRepository = $downloadRepository;
$this->downloadMediaHandler = $downloadMediaHandler;
$this->bus = $bus;
}
protected function configure()
{
$this->addArgument('term', InputArgument::REQUIRED);
$this->addOption('local', 'l', InputOption::VALUE_NONE, 'Perform the download locally instead of submitting it to the queue.');
}
public function run(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$command = new CommandInput($input->getArgument('term'));
// Perform search
$mediaOptions = $this->searchHandler->handle($command);
// Render results and ask the User to pick one
$mediaChoice = $this->askToChooseMediaOption($io, $output, $mediaOptions);
// Find download options based on the User's choice
$downloadOptions = $this->fetchDownloadOptions($mediaChoice);
// Render results and ask the User to pick one
$downloadChoice = $this->askToChooseDownloadOption($io, $output, $downloadOptions);
// Have user confirm download action
$confirmation = $this->askToConfirmDownload($io, $output, $downloadChoice);
// Begin download or submit to the queue
if (true === $confirmation) {
$downloadLocally = $input->getOption('local');
$this->submitDownload($io, $mediaChoice, $downloadChoice, $downloadLocally);
} else {
$io->success('No results found.');
}
$io->success('Success!');
return Command::SUCCESS;
}
private function askToChooseMediaOption(SymfonyStyle $io, OutputInterface $output, SearchResult $result): TmdbResult
{
$table = new Table($output)
->setHeaders(['ID', 'Title', 'Year', 'IMDb ID', 'Description'])
->setRows(
Map::from($result->results)
->map(fn ($result, $index) => [$index, $result->title, $result->year, $result->imdbId, substr($result->description, 0, 80) . '...'])
->toArray()
);
$table->render();
$question = new Question('Enter the ID of the correct result: ');
$choiceId = $io->askQuestion($question);
$choice = $result->results[$choiceId];
$io->info('You chose: ' . $choice->title);
$io->table(
['ID', 'Title', 'Year', 'IMDb ID', 'Description'],
[
[$choiceId, $choice->title, $choice->year, $choice->imdbId, substr($choice->description, 0, 80) . '...'],
]
);
return $choice;
}
private function fetchDownloadOptions(TmdbResult $result): array
{
$handlers = [
'movies' => $this->getMovieOptionsHandler,
'tvshows' => $this->getTvShowOptionsHandler,
];
$handler = $handlers[$result->mediaType];
if ("movies" === $result->mediaType) {
$command = new GetMovieOptionsCommand(
$result->tmdbId,
$result->imdbId
);
} else {
$command = new GetTvShowOptionsCommand(
$result->tmdbId,
$result->imdbId,
1,
1
);
}
$result = $handler->handle($command);
return $result->results;
}
private function askToChooseDownloadOption(SymfonyStyle $io, OutputInterface $output, array $options): TorrentioResult
{
$table = new Table($output)
->setHeaders(['ID', 'Size', 'Resolution', 'Codec', 'Seeders', 'Provider', 'Language'])
->setRows(
Map::from($options)
->map(fn (TorrentioResult $result, $index) => [$index, $result->size, $result->resolution, $result->codec, $result->seeders, $result->provider, implode(', ', $result->languages)])
->toArray()
);
$table->render();
$question = new Question('Enter the ID of the item to download: ');
$choiceId = $io->askQuestion($question);
$choice = $options[$choiceId];
$io->info('You chose: ' . $choice->title);
return $choice;
}
private function askToConfirmDownload(SymfonyStyle $io, OutputInterface $output, TorrentioResult $downloadOption): bool
{
$question = new ChoiceQuestion('Are you sure you want to download the above file?', ['yes', 'no']);
$choice = $io->askQuestion($question);
return $choice === 'yes';
}
private function submitDownload(SymfonyStyle $io, TmdbResult $mediaChoice, TorrentioResult $downloadOption, bool $downloadLocally = false): void
{
$io->writeln('> Adding download record');
$user = $this->userRepository->find(1);
$download = $this->downloadRepository->insert(
$user,
$downloadOption->url,
$downloadOption->title,
$downloadOption->filename,
$mediaChoice->imdbId,
$mediaChoice->mediaType,
);
$io->writeln('> Download record added: ' . $download->getId());
$downloadCommand = new DownloadMediaCommand(
$download->getUrl(),
$download->getTitle(),
$download->getFilename(),
$download->getMEdiaType(),
$download->getImdbId(),
$download->getUser()->getId(),
$download->getId()
);
if (true === $downloadLocally) {
$io->writeln('> Beginning local download');
$this->downloadMediaHandler->handle($downloadCommand);
} else {
$io->writeln('> Submitting download to queue');
$this->bus->dispatch($downloadCommand);
}
$io->writeln('> Download added to queue');
}
}

View File

@@ -9,12 +9,14 @@ use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput; use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Exception\TorrentioRateLimitException; use App\Torrentio\Exception\TorrentioRateLimitException;
use Carbon\Carbon; use Carbon\Carbon;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\Turbo\TurboBundle;
final class WebController extends AbstractController final class WebController extends AbstractController
{ {
@@ -25,7 +27,7 @@ final class WebController extends AbstractController
) {} ) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')] #[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache, Request $request): Response
{ {
$cacheId = sprintf( $cacheId = sprintf(
"page.torrentio.movies.%s.%s", "page.torrentio.movies.%s.%s",
@@ -33,9 +35,14 @@ final class WebController extends AbstractController
$input->imdbId $input->imdbId
); );
return $cache->get($cacheId, function (ItemInterface $item) use ($input) { return $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand()); $results = $this->getMovieOptionsHandler->handle($input->toCommand());
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/movies.html.twig', [ return $this->render('torrentio/movies.html.twig', [
'results' => $results, 'results' => $results,
]); ]);
@@ -43,7 +50,7 @@ final class WebController extends AbstractController
} }
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')] #[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{ {
$cacheId = sprintf( $cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s", "page.torrentio.tvshows.%s.%s.%s.%s",
@@ -54,13 +61,18 @@ final class WebController extends AbstractController
); );
try { try {
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) { return $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand()); $results = $this->getTvShowOptionsHandler->handle($input->toCommand());
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/tvshows.html.twig', [ return $this->render('torrentio/tvshows.html.twig', [
'results' => $results, 'results' => $results,
]); ]);
// }); });
} catch (TorrentioRateLimitException $exception) { } catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning'); $this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig', return $this->render('bare.html.twig',
@@ -73,29 +85,16 @@ final class WebController extends AbstractController
} }
} }
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')] private function sendFragmentResponse(ResultInterface $result, Request $request): Response
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{ {
$cacheId = sprintf( $request->setRequestFormat(TurboBundle::STREAM_FORMAT);
"page.torrentio.tvshows.%s.%s.%s.%s", return $this->renderBlock(
$input->tmdbId, 'torrentio/fragments.html.twig',
$input->imdbId, $request->query->get('block'),
$input->season, [
$input->episode, 'results' => $result,
'target' => $request->query->get('target')
]
); );
$cache->delete($cacheId);
$this->broadcaster->alert(
title: 'Success',
message: 'Torrentio cache Cleared.'
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
} }
} }

View File

@@ -3,12 +3,12 @@
mediaType: mediaType, mediaType: mediaType,
imdbId: imdbId imdbId: imdbId
}) }}"> }) }}">
<img src="{{ image }}" class="w-full md:w-40 rounded-md" /> <img src="{{ preload(image) }}" class="w-full md:w-40 rounded-md" />
</a> </a>
<a href="{{ path('app_search_result', { <a href="{{ path('app_search_result', {
mediaType: mediaType, mediaType: mediaType,
imdbId: imdbId imdbId: imdbId
}) }}"> }) }}">
<h3 class="text-center text-white md:text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3> <h3 class="text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
</a> </a>
</div> </div>

View File

@@ -74,8 +74,15 @@
</button> </button>
</div> </div>
</div> </div>
<div {{ stimulus_target('tv-results', 'listContainer') }} class="inline-block overflow-hidden rounded-lg"> <div class="inline-block overflow-hidden rounded-lg">
<twig:Turbo:Frame id="results_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('app_torrentio_tvshows', {
tmdbId: this.tmdbId,
imdbId: this.imdbId,
season: episode['season_number'],
episode: episode['episode_number'],
target: 'results_' ~ episode_id(episode['season_number'], episode['episode_number']),
block: 'tvshow_results'
}) }}" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -85,6 +85,12 @@
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }} {{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
data-movie-results-loading-icon-outlet=".loading-icon" data-movie-results-loading-icon-outlet=".loading-icon"
> >
<twig:Turbo:Frame id="movie_results_frame" src="{{ path('app_torrentio_movies', {
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
target: 'movie_results_frame',
block: 'movie_results'
}) }}" />
</div> </div>
{% elseif "tvshows" == results.media.mediaType %} {% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList <twig:TvEpisodeList

View File

@@ -0,0 +1,19 @@
{% block movie_results %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<div class="overflow-hidden rounded-md">
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
</div>
</div>
</template>
</turbo-stream>
{% endblock %}
{% block tvshow_results %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
{{ include('torrentio/partial/option-table.html.twig', {controller: 'tv-results'}) }}
</template>
</turbo-stream>
{% endblock %}