Compare commits

..

1 Commits

Author SHA1 Message Date
Brock H Caldwell
21bc43bb84 wip(Dashboard): grid widgets 2025-11-07 20:56:32 -06:00
31 changed files with 112 additions and 221 deletions

6
.env
View File

@@ -61,10 +61,4 @@ NTFY_DSN=
###> sentry/sentry-symfony ### ###> sentry/sentry-symfony ###
SENTRY_DSN= SENTRY_DSN=
SENTRY_JS_URL=
###< sentry/sentry-symfony ### ###< sentry/sentry-symfony ###
# TMDB 'with_original_language' option
# - only include media originally
# produced in this language
TMDB_ORIGINAL_LANGUAGE=en

1
assets/bootstrap.js vendored
View File

@@ -10,6 +10,7 @@ import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover'; import Popover from '@stimulus-components/popover';
import Dialog from '@stimulus-components/dialog'; import Dialog from '@stimulus-components/dialog';
import Dropdown from '@stimulus-components/dropdown'; import Dropdown from '@stimulus-components/dropdown';
import 'animate.css'; import 'animate.css';
const app = startStimulusApp(); const app = startStimulusApp();

View File

@@ -0,0 +1,40 @@
import { Controller } from '@hotwired/stimulus';
import {GridStack} from "../vendor/gridstack/gridstack.index.js";
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
grid;
initialize() {
}
connect() {
this.grid = GridStack.init({
column: 2,
alwaysShowResizeHandle: true,
margin: "2rem",
resizable: {
handles: 'e,se,s,sw,w'
}
});
this.grid.load();
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2zm11.5 5.175l3.5 1.556V4.269l-3.5 1.556zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"/></svg>

Before

Width:  |  Height:  |  Size: 374 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M4.406 1.342A5.53 5.53 0 0 1 8 0c2.69 0 4.923 2 5.166 4.579C14.758 4.804 16 6.137 16 7.773C16 9.569 14.502 11 12.687 11H10a.5.5 0 0 1 0-1h2.688C13.979 10 15 8.988 15 7.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 2.825 10.328 1 8 1a4.53 4.53 0 0 0-2.941 1.1c-.757.652-1.153 1.438-1.153 2.055v.448l-.445.049C2.064 4.805 1 5.952 1 7.318C1 8.785 2.23 10 3.781 10H6a.5.5 0 0 1 0 1H3.781C1.708 11 0 9.366 0 7.318c0-1.763 1.266-3.223 2.942-3.593c.143-.863.698-1.723 1.464-2.383"/><path d="M7.646 15.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 14.293V5.5a.5.5 0 0 0-1 0v8.793l-2.146-2.147a.5.5 0 0 0-.708.708z"/></g></svg>

Before

Width:  |  Height:  |  Size: 720 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/></g></svg>

Before

Width:  |  Height:  |  Size: 377 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path fill="currentColor" d="M2048 1088H250l787 787l-90 90L6 1024L947 83l90 90l-787 787h1798z"/></svg>

Before

Width:  |  Height:  |  Size: 167 B

View File

@@ -4,6 +4,7 @@ when@prod:
register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK
options: options:
release: '%app.version%'
traces_sample_rate: 1 traces_sample_rate: 1
profiles_sample_rate: 1 profiles_sample_rate: 1
attach_stacktrace: true attach_stacktrace: true

View File

@@ -1,7 +1,6 @@
twig: twig:
globals: globals:
version: '%app.version%' version: '%app.version%'
sentry_javascript_url: '%sentry.javascript_url%'
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
date: date:
format: 'm/d/Y' format: 'm/d/Y'

View File

@@ -48,10 +48,6 @@ parameters:
notification.transport: '%env(NOTIFICATION_TRANSPORT)%' notification.transport: '%env(NOTIFICATION_TRANSPORT)%'
notification.ntfy.dsn: '%env(NTFY_DSN)%' notification.ntfy.dsn: '%env(NTFY_DSN)%'
# Sentry
sentry.dsn: '%env(SENTRY_DSN)%'
sentry.javascript_url: '%env(SENTRY_JS_URL)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
_defaults: _defaults:

View File

@@ -70,4 +70,7 @@ return [
'@ungap/custom-elements' => [ '@ungap/custom-elements' => [
'version' => '1.3.0', 'version' => '1.3.0',
], ],
'gridstack' => [
'version' => '12.3.3',
],
]; ];

View File

@@ -53,15 +53,6 @@ final class ConfigResolver
#[Autowire(param: 'notification.ntfy.dsn')] #[Autowire(param: 'notification.ntfy.dsn')]
private ?string $notificationNtfyDsn = null, private ?string $notificationNtfyDsn = null,
#[Autowire(param: 'sentry.dsn')]
private ?string $sentryDsn = null,
#[Autowire(param: 'sentry.environment')]
private ?string $sentryEnvironment = null,
#[Autowire(param: 'sentry.javascript_url')]
private ?string $sentryJavascriptUrl = null,
) {} ) {}
public function validate(): bool public function validate(): bool
@@ -129,13 +120,4 @@ final class ConfigResolver
] ]
]; ];
} }
public function getSentryConfig()
{
return [
'dsn' => $this->sentryDsn,
'environment' => $this->sentryEnvironment,
'javascript_url' => $this->sentryJavascriptUrl,
];
}
} }

View File

@@ -50,7 +50,7 @@ final class IndexController extends AbstractController
#[Route('/test')] #[Route('/test')]
public function monitorTvShow(MonitorTvShowHandler $handler): Response public function monitorTvShow(MonitorTvShowHandler $handler): Response
{ {
throw new \Exception('Test'); // $handler->handle(new MonitorTvShowCommand(82));
return $this->render('index/test.html.twig', []); return $this->render('index/test.html.twig', []);
} }
} }

View File

@@ -46,14 +46,14 @@ readonly class DownloadSeasonHandler implements HandlerInterface
$downloadCommands = []; $downloadCommands = [];
foreach ($episodesInSeason as $episode) { foreach ($episodesInSeason as $episode) {
$this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode->episodeNumber); $this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode['episode_number']);
$results = $this->getTvShowOptionsHandler->handle( $results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand( new GetTvShowOptionsCommand(
$series->tmdbId, $series->tmdbId,
$command->imdbId, $command->imdbId,
$command->season, $command->season,
$episode->episodeNumber $episode['episode_number']
) )
); );
@@ -67,7 +67,7 @@ readonly class DownloadSeasonHandler implements HandlerInterface
if (null !== $result) { if (null !== $result) {
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching result'); $this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching result');
$this->logger->info('> [DownloadTvSeasonHandler] ......Dispatching DownloadMediaCommand for "' . $series->title . '" season ' . $command->season . ' episode ' . $episode->episodeNumber); $this->logger->info('> [DownloadTvSeasonHandler] ......Dispatching DownloadMediaCommand for "' . $series->title . '" season ' . $command->season . ' episode ' . $episode['episode_number']);
$downloadCommand = new DownloadMediaCommand( $downloadCommand = new DownloadMediaCommand(
$result->url, $result->url,
$series->title, $series->title,
@@ -99,10 +99,10 @@ readonly class DownloadSeasonHandler implements HandlerInterface
->filter(fn ($episode) => ->filter(fn ($episode) =>
property_exists($episode, 'episode') property_exists($episode, 'episode')
&& property_exists($episode, 'season') && property_exists($episode, 'season')
&& null !== $episode->episodeNumber && null !== $episode->episode
&& null !== $episode->season && null !== $episode->season
) )
->rekey(fn($episode) => $episode->episodeNumber); ->rekey(fn($episode) => $episode->episode);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle()); $this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
} }
} }

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbClient;
use Carbon\Carbon;
use DateTimeImmutable;
use Psr\Log\LoggerInterface;
trait MonitorHandlerTrait
{
private MonitorRepository $monitorRepository;
private LoggerInterface $logger;
private TmdbClient $tmdb;
private function episodeReleasedAfterMonitorCreated(
string|DateTimeImmutable $monitorStartDate,
TmdbEpisodeDto $episodeInShow
): bool {
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
$episodeAirDate = Carbon::parse($episodeInShow->airDate);
return $episodeAirDate >= $monitorStartDate;
}
private function episodeExists(TmdbEpisodeDto $episodeInShow, Map $downloadedEpisodes): bool
{
return $downloadedEpisodes->filter(
fn(object $episode) => $episode->episode === $episodeInShow->episodeNumber
&& $episode->season === $episodeInShow->seasonNumber
)->count() > 0;
}
private function monitorExists(Monitor $monitor, TmdbEpisodeDto $episode): bool
{
return $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $episode->seasonNumber,
'episode' => $episode->episodeNumber,
'status' => ['New', 'Active', 'In Progress']
]) !== null;
}
private function refreshData(Monitor $monitor)
{
if (null === $monitor->getPoster()) {
$this->logger->info('> [MonitorTvShowHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
$poster = $this->tmdb->tvshowDetails($monitor->getImdbId())->poster;
if (null !== $poster && "" !== $poster) {
$monitor->setPoster($poster);
}
}
}
}

View File

@@ -29,13 +29,10 @@ class CalendarController extends AbstractController
$monitors = $monitorRepository->whereAirDateNotNull(); $monitors = $monitorRepository->whereAirDateNotNull();
$calendar->event(Map::from($monitors)->map(function (Monitor $monitor) { $calendar->event(Map::from($monitors)->map(function (Monitor $monitor) {
$event = new Event($monitor->getTitle()) return new Event($monitor->getTitle())
->startsAt($monitor->getAirDate()) ->startsAt($monitor->getAirDate())
->attachment($monitor->getPoster())
->fullDay(); ->fullDay();
if (null !== $monitor->getPoster()) {
$event->attachment($monitor->getPoster());
}
return $event;
})->toArray()); })->toArray());
return new Response($calendar->get(), 200, [ return new Response($calendar->get(), 200, [

View File

@@ -29,7 +29,7 @@ class WebController extends AbstractController
private readonly Environment $renderer, private readonly Environment $renderer,
) {} ) {}
#[Route('/monitors', name: 'app.monitors', methods: ['GET'])] #[Route('/monitors', name: 'app_monitors', methods: ['GET'])]
public function addMonitor() public function addMonitor()
{ {
return $this->render('monitor/index.html.twig'); return $this->render('monitor/index.html.twig');

View File

@@ -33,7 +33,9 @@ class MonitorDispatcher
'tvshows' => MonitorTvShowCommand::class, 'tvshows' => MonitorTvShowCommand::class,
]; ];
$monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]); $monitors = $this->monitorRepository->findBy([
'status' => ['New', 'Active'],
]);
foreach ($monitors as $monitor) { foreach ($monitors as $monitor) {
$monitor->setStatus('In Progress'); $monitor->setStatus('In Progress');

View File

@@ -19,7 +19,6 @@ use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener; use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener; use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\RequestEvent; use Tmdb\Event\RequestEvent;
use Tmdb\Repository\DiscoverRepository;
use Tmdb\Repository\MovieRepository; use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository; use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository; use Tmdb\Repository\TvEpisodeRepository;
@@ -31,7 +30,6 @@ use Tmdb\Token\Api\BearerToken;
class TmdbClient class TmdbClient
{ {
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500"; const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
const APPEND_TO_RESPONSE = 'external_ids,credits,watch/providers';
protected Client $client; protected Client $client;
protected MovieRepository $movieRepository; protected MovieRepository $movieRepository;
@@ -40,8 +38,6 @@ class TmdbClient
protected TvEpisodeRepository $tvEpisodeRepository; protected TvEpisodeRepository $tvEpisodeRepository;
protected SearchRepository $searchRepository; protected SearchRepository $searchRepository;
protected DiscoverRepository $discoverRepository;
protected array $mediaTypeMap = [ protected array $mediaTypeMap = [
MediaType::Movie->value => MediaType::Movie->value, MediaType::Movie->value => MediaType::Movie->value,
MediaType::TvShow->value => MediaType::TvShow->value, MediaType::TvShow->value => MediaType::TvShow->value,
@@ -52,14 +48,11 @@ class TmdbClient
protected $repos = []; protected $repos = [];
private ?string $originalLanguage = 'en';
public function __construct( public function __construct(
private readonly SerializerInterface $serializer, private readonly SerializerInterface $serializer,
private readonly CacheItemPoolInterface $cache, private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey, #[Autowire(env: 'TMDB_API')] string $apiKey,
#[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
) { ) {
$this->client = new Client( $this->client = new Client(
[ [
@@ -81,7 +74,7 @@ class TmdbClient
'hydration' => [ 'hydration' => [
'event_listener_handles_hydration' => false, 'event_listener_handles_hydration' => false,
'only_for_specified_models' => [] 'only_for_specified_models' => []
], ]
] ]
); );
@@ -114,14 +107,11 @@ class TmdbClient
$this->tvSeasonRepository = new TvSeasonRepository($this->client); $this->tvSeasonRepository = new TvSeasonRepository($this->client);
$this->tvEpisodeRepository = new TvEpisodeRepository($this->client); $this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
$this->searchRepository = new SearchRepository($this->client); $this->searchRepository = new SearchRepository($this->client);
$this->discoverRepository = new DiscoverRepository($this->client);
$this->repos = [ $this->repos = [
MediaType::Movie->value => $this->movieRepository, MediaType::Movie->value => $this->movieRepository,
MediaType::TvShow->value => $this->tvRepository, MediaType::TvShow->value => $this->tvRepository,
MediaType::TvEpisode->value => $this->tvEpisodeRepository, MediaType::TvEpisode->value => $this->tvEpisodeRepository,
]; ];
$this->originalLanguage = $originalLanguage;
} }
public function search(string $term): TmdbResult|Map public function search(string $term): TmdbResult|Map
@@ -143,7 +133,7 @@ class TmdbClient
{ {
$tmdbId = $this->findByImdbId($imdbId)['id']; $tmdbId = $this->findByImdbId($imdbId)['id'];
return $this->parseResult( return $this->parseResult(
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]), $this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => 'external_ids,credits']),
MediaType::Movie->value, MediaType::Movie->value,
$imdbId $imdbId
); );
@@ -152,7 +142,7 @@ class TmdbClient
public function tvshowDetails(string $imdbId): ?TmdbResult public function tvshowDetails(string $imdbId): ?TmdbResult
{ {
$tmdbId = $this->findByImdbId($imdbId)['id']; $tmdbId = $this->findByImdbId($imdbId)['id'];
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]); $media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => 'external_ids,credits']);
$media['seasons'] = Map::from($media['seasons'])->filter(function ($data) { $media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
return $data['season_number'] !== 0 && return $data['season_number'] !== 0 &&
@@ -173,7 +163,7 @@ class TmdbClient
public function tvSeasonDetails(string $tmdbId, int $season): array public function tvSeasonDetails(string $tmdbId, int $season): array
{ {
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => static::APPEND_TO_RESPONSE]); $result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => 'external_ids,credits']);
$result['episodes'] = Map::from($result['episodes'])->map(function ($data) { $result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
$data['still_path'] = self::POSTER_IMG_PATH . $data['still_path']; $data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
$data['poster'] = $data['still_path']; $data['poster'] = $data['still_path'];
@@ -184,7 +174,7 @@ class TmdbClient
public function tvEpisodeDetails(string $tmdbId, string $showImdbId, int $season, int $episode): TmdbResult|TmdbEpisodeDto|null public function tvEpisodeDetails(string $tmdbId, string $showImdbId, int $season, int $episode): TmdbResult|TmdbEpisodeDto|null
{ {
$result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => static::APPEND_TO_RESPONSE]); $result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => 'external_ids,credits']);
return $this->parseResult( return $this->parseResult(
$result, $result,
MediaType::TvEpisode->value, MediaType::TvEpisode->value,
@@ -203,10 +193,7 @@ class TmdbClient
public function popularMovies(int $resultCount = 6): Map public function popularMovies(int $resultCount = 6): Map
{ {
$results = $this->discoverRepository->getApi()->discoverMovies([ $results = $this->movieRepository->getApi()->getPopular();
'with_original_language' => $this->originalLanguage,
'append_to_response' => 'external_ids,watch/providers',
]);
$results['results'] = Map::from($results['results'])->map(function ($result) { $results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::Movie->value; $result['media_type'] = MediaType::Movie->value;
return $result; return $result;
@@ -219,10 +206,7 @@ class TmdbClient
public function popularTvShows(int $resultCount = 6): Map public function popularTvShows(int $resultCount = 6): Map
{ {
$results = $this->discoverRepository->getApi()->discoverTv([ $results = $this->tvRepository->getApi()->getPopular();
'with_original_language' => $this->originalLanguage,
'append_to_response' => 'external_ids,watch/providers',
]);
$results['results'] = Map::from($results['results'])->map(function ($result) { $results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::TvShow->value; $result['media_type'] = MediaType::TvShow->value;
return $result; return $result;

View File

@@ -68,10 +68,4 @@ class Torrentio
return $results; return $results;
} }
public function getDestinationUrl(string $url)
{
$request = get_headers($url)[8];
return explode(' ', $request)[1];
}
} }

View File

@@ -7,7 +7,6 @@ use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput; use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput; use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Client\Torrentio;
use App\Torrentio\Exception\TorrentioRateLimitException; use App\Torrentio\Exception\TorrentioRateLimitException;
use Carbon\Carbon; use Carbon\Carbon;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
@@ -22,8 +21,6 @@ use Symfony\UX\Turbo\TurboBundle;
final class WebController extends AbstractController final class WebController extends AbstractController
{ {
const REAL_DEBRID_STREAM_URL = 'https://real-debrid.com/streaming-%s';
public function __construct( public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler, private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler, private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
@@ -102,14 +99,4 @@ final class WebController extends AbstractController
] ]
); );
} }
#[Route('/torrentio/stream/{url}', name: 'app.torrentio.stream')]
public function streamVideo(string $url, Torrentio $torrentio): Response
{
$destinationUrl = $torrentio->getDestinationUrl(\base64_decode($url));
$urlPathParts = explode('/', parse_url($destinationUrl)['path']);
$videoId = $urlPathParts[2];
$url = sprintf(self::REAL_DEBRID_STREAM_URL, $videoId);
return $this->redirect($url);
}
} }

View File

@@ -112,18 +112,4 @@ class UtilExtension
return new EpisodeIdDto($season, $episode); return new EpisodeIdDto($season, $episode);
} }
#[AsTwigFunction('sentry_enabled')]
public function sentryEnabled(): bool
{
$sentryConfig = $this->config->getSentryConfig();
return $sentryConfig['javascript_url'] !== null &&
$sentryConfig['javascript_url'] !== '';
}
#[AsTwigFilter('base64_encode')]
public function base64_encode(string $data): string
{
return \base64_encode($data);
}
} }

View File

@@ -12,7 +12,6 @@
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
<script src="https://bugs.caldwell.digital/js-sdk-loader/8dddf7fb26fbec602ad212173a942450.min.js" crossorigin="anonymous"></script>
{% endblock %} {% endblock %}
</head> </head>
<body class="bg-cyan-950 flex flex-col h-full"> <body class="bg-cyan-950 flex flex-col h-full">

View File

@@ -11,11 +11,14 @@
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block pre_js %}{% endblock %}
{% block importmap %}
{{ importmap('app') }}
{% endblock %}
{% block post_js %}{% endblock %}
{% if sentry_enabled() %}
<script src="{{ sentry_javascript_url }}" crossorigin="anonymous"></script>
{% endif %}
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script> <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
{% endblock %} {% endblock %}
</head> </head>
@@ -26,9 +29,9 @@
</div> </div>
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll"> <div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
<twig:Header /> <twig:Header />
<div class="flex flex-col md:flex-row justify-between md:items-center"> <div class="flex justify-between items-center">
<h2 class="px-4 mt-4 mb-4 md:mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2> <h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<div class="flex mx-4 mb-2 md:mt-4 gap-2 items-center grow-0 md:px-4"> <div class="flex mt-4 gap-2 items-center grow-0 md:px-4">
{% block action_buttons %}{% endblock %} {% block action_buttons %}{% endblock %}
</div> </div>
</div> </div>

View File

@@ -55,7 +55,7 @@
{% if this.isWidget and this.monitors.items|length > 5 %} {% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all"> <tr id="monitor_view_all">
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black"> <td colspan="100%" class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black">
<a href="{{ path('app.monitors') }}">View All Monitors</a> <a href="{{ path('app_monitors') }}">View All Monitors</a>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}

View File

@@ -21,7 +21,7 @@
</li> </li>
<li> <li>
<a href="{{ path('app.monitors') }}" <a href="{{ path('app_monitors') }}"
class="block rounded-lg class="block rounded-lg
bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60
px-4 py-2 text-sm font-medium text-gray-50"> px-4 py-2 text-sm font-medium text-gray-50">

View File

@@ -4,20 +4,31 @@
{% block h2 %}Dashboard{% endblock %} {% block h2 %}Dashboard{% endblock %}
{% block body %} {% block body %}
<div class="p-4 flex flex-col grow gap-4 z-10"> <div class="p-4 z-10">
<div class="flex flex-col md:flex-row gap-4"> <div class="grid-stack gs-2">
<twig:Card title="Active Downloads" class="w-full"> <div class="grid-stack-item" gs-x="1">
<twig:DownloadList :type="'active'" /> <div class="grid-stack-item-content">
</twig:Card> <twig:Card title="Active Downloads">
<twig:DownloadList :type="'active'" />
</twig:Card>
</div>
</div>
<twig:Card title="Recent Downloads" class="w-full"> <div class="grid-stack-item" gs-x="2">
<twig:DownloadList :type="'complete'" /> <div class="grid-stack-item-content">
</twig:Card> <twig:Card title="Complete Downloads" >
</div> <twig:DownloadList :type="'complete'" />
<div class="flex flex-col md:flex-row gap-4"> </twig:Card>
<twig:Card title="Monitors" class="w-full"> </div>
<twig:MonitorList :type="'active'" :isWidget="true" /> </div>
</twig:Card>
<div class="grid-stack-item" gs-x="3">
<div class="grid-stack-item-content">
<twig:Card title="Active Monitors">
<twig:MonitorList :type="'active'" />
</twig:Card>
</div>
</div>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<twig:Card title="Popular Movies" contentClass="grid grid-cols-2 gap-4 md:flex md:flex-row md:justify-between w-full"> <twig:Card title="Popular Movies" contentClass="grid grid-cols-2 gap-4 md:flex md:flex-row md:justify-between w-full">
@@ -45,5 +56,6 @@
{% endfor %} {% endfor %}
</twig:Card> </twig:Card>
</div> </div>
<div class="grid-stack" data-controller="dashboard-widgets"></div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -15,12 +15,6 @@
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script> <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
<div class="p-4"> <div class="p-4">
<div class="mb-4 text-white">
<a href="{{ path('app.monitors') }}" class="btn btn-primary inline-flex items-center gap-2">
<twig:ux:icon name="fluent-mdl2:back" width="14" /> Back to Monitors
</a>
</div>
<twig:Card title="Upcoming episodes of shows your monitoring"> <twig:Card title="Upcoming episodes of shows your monitoring">
<a href="{{ path('app.monitors.ical', {email: app.user.email}) }}" title="Subscribe to the 'Upcoming Episodes' calendar via iCal. Click to export the events to a .ics file or copy the link and use it to subscribe in a calendar app that supports iCal/ics calendars." class="mb-2 self-end dark:text-white decoration-underline"> <a href="{{ path('app.monitors.ical', {email: app.user.email}) }}" title="Subscribe to the 'Upcoming Episodes' calendar via iCal. Click to export the events to a .ics file or copy the link and use it to subscribe in a calendar app that supports iCal/ics calendars." class="mb-2 self-end dark:text-white decoration-underline">
<twig:ux:icon name="lets-icons:calendar-add-light" width="24" class="text-orange-500" /> <twig:ux:icon name="lets-icons:calendar-add-light" width="24" class="text-orange-500" />

View File

@@ -5,11 +5,6 @@
{% block body %} {% block body %}
<div class="px-4 py-2"> <div class="px-4 py-2">
<div class="mb-4 text-white">
<a href="{{ path('app.monitors') }}" class="btn btn-primary inline-flex items-center gap-2">
<twig:ux:icon name="fluent-mdl2:back" width="14" /> Back to Monitors
</a>
</div>
<twig:Card title="Viewing your monitors for {{ monitor.title }}"> <twig:Card title="Viewing your monitors for {{ monitor.title }}">
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6"> <div class="p-2 md:p-4 flex flex-col md:flex-row gap-6">
{% if results.media.poster != null %} {% if results.media.poster != null %}
@@ -58,6 +53,7 @@
{% if results.media.genres != null %} {% if results.media.genres != null %}
<div id="genres" class="text-gray-50 my-4"> <div id="genres" class="text-gray-50 my-4">
{# <strong>Genres</strong>: <br />#}
{% for genre in results.media.genres %} {% for genre in results.media.genres %}
<small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small> <small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small>
{% endfor %} {% endfor %}
@@ -69,10 +65,10 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex flex-col grow text-white"> <div class="flex flex-col grow text-white">
<strong class="mb-1">In Your Library</strong> <strong class="mb-1">In Your Library</strong>
<div class="flex flex-col md:flex-row border-t-orange-500 text-xs gap-4"> <div class="flex flex-col md:flex-row border-t-orange-500 text-xs gap-2">
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-sm mb-1">Seasons</span> <span class="text-sm mb-1">Seasons</span>
<div class="flex flex-row border p-2 border-orange-500 rounded-lg text-xs items-center"> <div class="flex flex-col md:flex-row border p-2 border-orange-500 rounded-lg text-xs items-center">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white"> <span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white">
<span>{{ library.seasonCount }}</span> full <span>{{ library.seasonCount }}</span> full
</span> </span>
@@ -87,7 +83,7 @@
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-sm mb-1">Episodes</span> <span class="text-sm mb-1">Episodes</span>
<div class="flex flex-row border p-2 border-orange-500 rounded-lg text-xs items-center"> <div class="flex flex-col md:flex-row border p-2 border-orange-500 rounded-lg text-xs items-center">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-cyan-500 rounded-lg text-white"> <span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-cyan-500 rounded-lg text-white">
<span>{{ library.episodeCount }}</span> existing <span>{{ library.episodeCount }}</span> existing
</span> </span>
@@ -99,14 +95,14 @@
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-sm mb-1">Monitors</span> <span class="text-sm mb-1">Monitors</span>
<div class="flex flex-row border p-2 border-orange-500 rounded-lg text-xs items-center"> <div class="flex flex-col md:flex-row border p-2 border-orange-500 rounded-lg text-xs items-center">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white"> <span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-pink-500 rounded-lg text-white">
<span>{{ library.monitorCount }}</span> total <span>{{ library.monitorCount }}</span> total
</span> </span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white"> <span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-pink-500 rounded-lg text-white">
<span>{{ library.activeMonitorCount }}</span> active <span>{{ library.activeMonitorCount }}</span> active
</span> </span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white"> <span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-pink-500 rounded-lg text-white">
<span>{{ library.completeMonitorCount }}</span> complete <span>{{ library.completeMonitorCount }}</span> complete
</span> </span>
</div> </div>

View File

@@ -86,18 +86,9 @@
{{ result.languageFlags|raw }} {{ result.languageFlags|raw }}
</td> </td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-start mb:justify-end"> <td class="px-4 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-start mb:justify-end">
<a href="{{ url('app.torrentio.stream', {url: result.url|base64_encode}) }}" title="Stream in your browser." class="p-1.5 bg-blue-600 rounded-md text-gray-50"> <button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50">
<twig:ux:icon name="bi:camera-video" width="20"/> Download
</a> </button>
<button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50" title="Download to your mounted directory.">
<twig:ux:icon name="bi:cloud-download" width="20" />
</button>
<a href="{{ result.url }}" title="Download to your local device." class="p-1.5 bg-orange-500 rounded-md text-gray-50">
<twig:ux:icon name="bi:download" width="20" />
</a>
<label for="select"> <label for="select">
<input id="select" type="checkbox" name="select" /> <input id="select" type="checkbox" name="select" />
</label> </label>

View File

@@ -1,7 +0,0 @@
{% extends 'base.html.twig' %}
{% block title %}Streaming &mdash; Torrentio{% endblock %}
{% block body %}
<iframe width="100%" height="100%" src="{{ stream_url }}"></iframe>
{% endblock %}