Compare commits

...

7 Commits

14 changed files with 200 additions and 35 deletions

View File

@@ -64,6 +64,16 @@ dialog[data-dialog-target="dialog"][closing] {
animation: fade-out 200ms forwards;
}
.r-tablecell {
display: none;
}
@media screen and (min-width: 768px) {
.r-tablecell {
display: inline-table;
}
}
.options-table {
display: flex;

View File

@@ -29,3 +29,11 @@ controllersMonitor:
type: attribute
defaults:
schemes: ['https']
controllersTorrentio:
resource:
path: ../src/Torrentio/Framework/Controller
namespace: App\Torrentio\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Monitor\Framework\Scheduler\MonitorDispatcher;
use App\Tmdb\Tmdb;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -30,9 +31,9 @@ final class IndexController extends AbstractController
}
#[Route('/test', name: 'app_test')]
public function test()
public function test(MonitorDispatcher $dispatcher): Response
{
$result = $this->monitorTvShowHandler->handle(new MonitorTvShowCommand(355));
return $this->json($result);
$dispatcher();
return new Response();
}
}

View File

@@ -6,8 +6,10 @@ use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Monitor\Framework\Scheduler\MonitorDispatcher;
use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -50,4 +52,15 @@ class ApiController extends AbstractController
'message' => $response
]);
}
#[Route('/api/monitor/dispatch', name: 'api_monitor_dispatch', methods: ['GET'])]
public function dispatch(MonitorDispatcher $dispatcher): Response
{
$dispatcher();
return $this->json([
'status' => 200,
'message' => 'Manually dispatched MonitorDispatcher'
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Monitor\Framework\Entity;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User;
use Carbon\Carbon;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -148,7 +149,7 @@ class Monitor
public function getLastSearch(): ?\DateTimeInterface
{
return $this->lastSearch;
return Carbon::parse($this->lastSearch);
}
public function setLastSearch(?\DateTimeInterface $lastSearch): static

View File

@@ -7,6 +7,7 @@ use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Framework\Repository\MonitorRepository;
use Carbon\Carbon;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
@@ -23,6 +24,8 @@ class MonitorDispatcher
public function __invoke() {
$this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher');
$this->cleanupStuckMonitors();
$monitorHandlers = [
'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class,
@@ -41,4 +44,18 @@ class MonitorDispatcher
$this->bus->dispatch(new $command($monitor->getId()));
}
}
private function cleanupStuckMonitors(): void
{
$hoursStuck = 4;
$monitors = $this->monitorRepository->findBy(['status' => 'In Progress']);
foreach ($monitors as $monitor) {
// Reset the status to active so it will be executed again
if ($monitor->getLastSearch()->diffInHours(Carbon::today()) > $hoursStuck) {
$this->logger->info('[MonitorDispatcher] Cleaning up monitor: ' . $monitor->getId() . ' (' . $monitor->getTitle() . '), resetting status to \'Active\' from \''. $monitor->getStatus() .'\' after being stuck for ' . $hoursStuck . ' hours.');
$monitor->setStatus('Active');
}
}
$this->monitorRepository->getEntityManager()->flush();
}
}

View File

@@ -32,7 +32,7 @@ class Torrentio
]);
}
public function search(string $imdbCode, string $type, array $filter = []): array
public function search(string $imdbCode, string $type, bool $parseResults = true): array
{
$cacheKey = "torrentio.{$imdbCode}";
@@ -56,10 +56,14 @@ class Torrentio
return [];
});
return $this->parse($results, $filter);
if (true === $parseResults) {
return $this->parse($results);
}
return $results;
}
public function fetchEpisodeResults(string $imdbId, int $season, int $episode): array
public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array
{
$cacheKey = "torrentio.$imdbId.$season.$episode";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
@@ -86,18 +90,15 @@ class Torrentio
throw new TorrentioRateLimitException();
}
return $this->parse($results, []);
}
public function parse(array $data, array $filter): array
{
$ruleEngine = new RuleEngine();
foreach ($filter as $rule => $value) {
if ('resolution' === $rule) {
$ruleEngine->addRule(new Resolution($value));
}
if (true === $parseResults) {
return $this->parse($results);
}
return $results;
}
public function parse(array $data): array
{
$results = [];
foreach ($data['streams'] as $stream) {
if (!str_starts_with($stream['url'], "https")) {
@@ -119,9 +120,7 @@ class Torrentio
$bingeGroup
);
if ($ruleEngine->validateAll($result)) {
$results[] = $result;
}
$results[] = $result;
}
return $results;

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Client\Torrentio;
use App\Torrentio\Exception\TorrentioRateLimitException;
use App\Util\Broadcaster;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class ApiController extends AbstractController
{
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly Broadcaster $broadcaster,
private readonly Torrentio $torrentio,
) {}
#[Route('/api/torrentio/{imdbId}/{season?}/{episode?}', name: 'api_torrentio')]
public function api(string $imdbId, ?int $season, ?int $episode): Response
{
if (null !== $season && null !== $episode) {
return $this->json(
$this->torrentio->fetchEpisodeResults($imdbId, $season, $episode, false)
);
}
return $this->json(
$this->torrentio->search($imdbId, 'movies', false),
);
}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.movies.%s.%s",
$input->tmdbId,
$input->imdbId
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
});
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
try {
// 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,
]);
// });
} catch (TorrentioRateLimitException $exception) {
$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',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
$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

@@ -1,6 +1,6 @@
<?php
namespace App\Controller;
namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
@@ -16,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class TorrentioController extends AbstractController
final class WebController extends AbstractController
{
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
@@ -54,13 +54,13 @@ final class TorrentioController extends AbstractController
);
try {
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
// 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,
]);
});
// });
} catch (TorrentioRateLimitException $exception) {
$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',

View File

@@ -15,11 +15,11 @@
Title
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ?? "hidden" }}">
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
Filename
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ?? "hidden" }}">
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
Media type
</th>
<th scope="col"
@@ -27,7 +27,7 @@
Progress
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
</th>
</tr>
</thead>

View File

@@ -1,7 +1,7 @@
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}"
class="mr-1 hover:underline rounded-md"
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate"
>
{{ download.title }}
</a>
@@ -11,11 +11,11 @@
{% endif %}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] {{ isWidget == true ?? "hidden" }} truncate">
{{ download.filename }}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] {{ isWidget == true ? "hidden" : "r-tablecell" }} truncate">
{{ download.filename }}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate {{ isWidget == true ?? "hidden" }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
{{ download.mediaType }}
</td>

View File

@@ -41,7 +41,7 @@
<tbody id="monitors" class="divide-y divide-gray-50">
{% if this.monitors.items|length > 0 %}
{% for monitor in this.monitors.items %}
<twig:MonitorListRow :monitor="monitor" />
<twig:MonitorListRow :monitor="monitor" isWidget="{{ this.isWidget }}" />
{% endfor %}
{% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all">

View File

@@ -63,7 +63,7 @@
title: results.media.title,
filename: result.filename,
mediaType: results.media.mediaType,
imdbId: results.media.imdbId,
imdbId: results.media.imdbId ?? app.current_route_parameters.imdbId,
episodeId: results|episode_id_from_results
}) }}
{{ stimulus_action('download_button', 'download', 'click') }}

View File

@@ -3,7 +3,7 @@
{% block h2 %}Preferences{% endblock %}
{% block body %}
<div class="p-4 flex flex-row gap-2">
<div class="p-4 flex flex-col md:flex-row gap-2">
<twig:Card title="Media Preferences" class="w-full">
<p class="text-gray-50 mb-2">Define a filter to be pre-applied to your download options.</p>
<form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_save_media_preferences') }}">