Compare commits

...

22 Commits

Author SHA1 Message Date
Brock H Caldwell
2effa0fb07 feat: new Discover section shows watch providers for results
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 24s
SonarQube Scan / SonarQube Trigger (push) Failing after 36s
2025-11-11 23:08:20 -06:00
Brock H Caldwell
c2474942a1 fix: uses new action
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 28s
2025-11-10 16:54:49 -06:00
Brock H Caldwell
c175dddede fix: hard codes qube host
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 9s
2025-11-10 16:29:35 -06:00
Brock H Caldwell
0e1d8e15e3 fix: hard codes qube host
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 10s
2025-11-10 16:26:00 -06:00
Brock H Caldwell
d38f8ba4be fix: adds gitea workflow
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 9s
2025-11-10 15:45:01 -06:00
Brock H Caldwell
e20def1325 task: adds gitea action to trigger sonarqube scan 2025-11-10 15:42:53 -06:00
Brock H Caldwell
ed69ed61b8 fix: sentry release 2025-11-09 10:08:41 -06:00
Brock H Caldwell
9c8e625316 fix(Sentry): uses correct version parameter 2025-11-09 10:00:46 -06:00
Brock H Caldwell
ef6ed20a0b fix: adds sentry release 2025-11-09 09:53:21 -06:00
Brock H Caldwell
da0eab652b test(Sentry): enables logs 2025-11-08 23:14:30 -06:00
Brock H Caldwell
17de41dc57 fix(Calendar): null attachment 2025-11-08 22:23:49 -06:00
Brock H Caldwell
d2eaccaf93 test: removes sentry release config 2025-11-08 18:44:00 -06:00
Brock H Caldwell
37e13347b2 feat(Download): adds streaming and local download options 2025-11-08 16:26:38 -06:00
Brock H Caldwell
7dd40b4525 feat: makes sentry more configurable 2025-11-08 14:38:55 -06:00
Brock H Caldwell
2315b995e0 fix: adds test exception 2025-11-08 12:40:09 -06:00
Brock H Caldwell
83401f2a7a fix: disable sentry traces 2025-11-08 12:39:01 -06:00
Brock H Caldwell
50bcb4e1df feat(Tmdb): adds pre-filter option to filter by media language 2025-11-08 12:34:53 -06:00
Brock H Caldwell
ea569b480d fix: download season handler 2025-11-07 23:24:59 -06:00
Brock H Caldwell
50ec0c1d6f qol(Monitors): adds back buttons, provides more data about child monitors 2025-11-07 21:24:27 -06:00
Brock H Caldwell
4ae70115b5 feat: additional info displayed on child monitor page 2025-11-07 12:59:24 -06:00
Brock H Caldwell
f4982af991 feat: landing page for show monitors 2025-11-06 15:26:57 -06:00
Brock H Caldwell
f253b33910 feat: shows monitor poster on modal 2025-11-06 15:16:59 -06:00
56 changed files with 1520 additions and 106 deletions

6
.env
View File

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

View File

@@ -0,0 +1,24 @@
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
name: SonarQube Scan
jobs:
sonarqube:
name: SonarQube Trigger
runs-on: ubuntu-latest
steps:
- name: Checking out
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube Scan
uses: https://code.caldwell.digital/tools/sonarqube-action@v0.0.3
with:
host: "https://qube.caldwell.digital"
login: ${{ secrets.SONARQUBE_TOKEN }}
projectName: "torsearch"
projectBaseDir: "./src"

View File

@@ -28,6 +28,9 @@ export default class PreviewContentDialog extends HTMLDialogElement {
}
display({ heading, content }) {
if (this.hasAttribute('mdWidth')) {
this.style.width = this.getAttribute('mdWidth');
}
this.setHeading(heading);
this.setContent(content);
this.showModal();

View File

@@ -0,0 +1,54 @@
import { Controller } from '@hotwired/stimulus';
/*
* 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 {
static targets = ['poster', 'moreBtn', 'moreLink']
moreResultsClicks = 0;
initialize() {
// Called once when the controller is first instantiated (per element)
// Here you can initialize variables, create scoped callables for event
// listeners, instantiate external libraries, etc.
// this._fooBar = this.fooBar.bind(this)
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
}
// 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)
}
moreResults() {
const elems = this.posterTargets.filter(poster => poster.classList.contains('hidden'));
if (this.moreResultsClicks <= 2) {
elems.slice(0, 6).forEach(poster => poster.classList.remove('hidden'));
this.moreResultsClicks++;
if (this.moreResultsClicks === 2) {
this.moreBtnTarget.classList.add('hidden');
this.moreLinkTarget.classList.remove('hidden');
}
}
}
}

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 720 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 167 B

View File

@@ -23,6 +23,6 @@ return [
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true],
SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true, 'dev' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
];

View File

@@ -1,40 +1,40 @@
when@prod:
sentry:
register_error_listener: true # Disables the ErrorListener to avoid duplicated log in sentry
register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK
sentry:
register_error_listener: true # Disables the ErrorListener to avoid duplicated log in sentry
register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK
options:
release: '%app.version%'
traces_sample_rate: 1
profiles_sample_rate: 1
attach_stacktrace: true
options:
release: 'torsearch@%app.version%'
enable_logs: true
traces_sample_rate: 1
profiles_sample_rate: 1
attach_stacktrace: true
tracing:
tracing:
enabled: true
dbal: # DB queries
enabled: true
cache: # cache pools
enabled: true
twig: # templating engine
enabled: true
dbal: # DB queries
enabled: true
cache: # cache pools
enabled: true
twig: # templating engine
enabled: true
services:
# (Optionally) Configure the breadcrumb handler as a service (needed for the breadcrumb Monolog handler)
Sentry\Monolog\BreadcrumbHandler:
arguments:
- '@Sentry\State\HubInterface'
- !php/const Monolog\Logger::INFO # Configures the level of messages to capture as breadcrumbs
monolog:
handlers:
# (Optionally) Register the breadcrumb handler as a Monolog handler
sentry_breadcrumbs:
type: service
name: sentry_breadcrumbs
id: Sentry\Monolog\BreadcrumbHandler
# Register the handler as a Monolog handler to capture messages as events
sentry:
type: sentry
level: !php/const Monolog\Logger::ERROR # Configures the level of messages to capture as events
hub_id: Sentry\State\HubInterface
fill_extra_context: true # Enables sending monolog context to Sentry
process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders in reported messages
services:
# (Optionally) Configure the breadcrumb handler as a service (needed for the breadcrumb Monolog handler)
Sentry\Monolog\BreadcrumbHandler:
arguments:
- '@Sentry\State\HubInterface'
- !php/const Monolog\Logger::INFO # Configures the level of messages to capture as breadcrumbs
monolog:
handlers:
# (Optionally) Register the breadcrumb handler as a Monolog handler
sentry_breadcrumbs:
type: service
name: sentry_breadcrumbs
id: Sentry\Monolog\BreadcrumbHandler
# Register the handler as a Monolog handler to capture messages as events
sentry:
type: sentry
level: !php/const Monolog\Logger::ERROR # Configures the level of messages to capture as events
hub_id: Sentry\State\HubInterface
fill_extra_context: true # Enables sending monolog context to Sentry
process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders in reported messages

View File

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

View File

@@ -6,6 +6,14 @@ controllersBase:
defaults:
schemes: [ 'https' ]
controllersDiscover:
resource:
path: ../src/Discover/Framework/Controller/
namespace: App\Discover\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersEventLog:
resource:
path: ../src/EventLog/Framework/Controller/

View File

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

View File

@@ -53,6 +53,15 @@ final class ConfigResolver
#[Autowire(param: 'notification.ntfy.dsn')]
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
@@ -120,4 +129,13 @@ 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')]
public function monitorTvShow(MonitorTvShowHandler $handler): Response
{
// $handler->handle(new MonitorTvShowCommand(82));
throw new \Exception('Test');
return $this->render('index/test.html.twig', []);
}
}

View File

@@ -168,6 +168,37 @@ class MediaFiles
return false;
}
/**
* @param string $tvshowTitle
* @return array<SplFileInfo>|false
*/
public function tvshowExists(string $tvshowTitle): Map|false
{
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);
if ($existingEpisodes->isEmpty()) {
return false;
}
$episodes = new Map;
/** @var SplFileInfo $episode */
foreach ($existingEpisodes as $episode) {
$ptn = (object) (new PTN())->parse($episode->getFilename());
if (!property_exists($ptn, 'season') || !property_exists($ptn, 'episode')) {
continue;
}
$episodes->push($episode);
}
if ($episodes->count() > 0) {
return $episodes;
}
return false;
}
public function movieExists(string $title)
{
$filepath = $this->moviesPath . DIRECTORY_SEPARATOR . $title;

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Discover\Framework\Controller;
use App\Base\Enum\MediaType;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbMovieGenre;
use App\Tmdb\TmdbTvShowGenre;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/discover')]
class WebController extends AbstractController
{
#[Route('/', name: 'app.discover')]
public function index(TmdbClient $tmdb)
{
$movies = $tmdb->popularMovies(18);
$tvshows = $tmdb->popularTvShows(18);
return $this->render('discover/index.html.twig', [
'movies' => $movies,
'shows' => $tvshows,
]);
}
#[Route('/{mediaType}/{genreId?}', name: 'app.discover.browse')]
public function browse(string $mediaType, ?string $genreId, TmdbClient $tmdb)
{
if (null === $genreId) {
if (MediaType::tryFrom($mediaType) === null) {
return new Response(status: 404);
}
return $this->render('discover/browse.html.twig', [
'genres' => self::getGenres($mediaType),
'media_type' => $mediaType,
]);
}
if (MediaType::tryFrom($mediaType) === null) {
return new Response(status: 404);
}
if (TmdbMovieGenre::tryFrom($genreId) === null &&
TmdbTvShowGenre::tryFrom($genreId) === null
) {
return new Response(status: 404);
}
$results = match ($mediaType) {
MediaType::Movie->value => $tmdb->discoverMovies(
[TmdbMovieGenre::from($genreId)->value]
),
MediaType::TvShow->value => $tmdb->discoverTvShows(
[TmdbTvShowGenre::from($genreId)->value]
),
};
return $this->render('discover/browse_genre.html.twig', [
'media' => $results,
'genre' => TmdbMovieGenre::from($genreId)->name,
'genre_id' => $genreId,
'media_type' => $mediaType,
]);
}
#[Route('/{mediaType}/{genreId}', name: 'app.discover.browse_genre')]
public function browseGenre(string $mediaType, string $genreId, TmdbClient $tmdb)
{
if (MediaType::tryFrom($mediaType) === null) {
return new Response(status: 404);
}
if (TmdbMovieGenre::tryFrom($genreId) === null &&
TmdbTvShowGenre::tryFrom($genreId) === null
) {
return new Response(status: 404);
}
$results = match ($mediaType) {
MediaType::Movie->value => $tmdb->discoverMovies(
[TmdbMovieGenre::from($genreId)->value]
),
MediaType::TvShow->value => $tmdb->discoverTvShows(
[TmdbTvShowGenre::from($genreId)->value]
),
};
return $this->render('discover/browse.html.twig', [
'media' => $results,
'media_type' => $mediaType,
]);
}
private static function getGenres(string $mediaType): array
{
return match ($mediaType) {
MediaType::Movie->value => [
'Action' => TmdbMovieGenre::Action->value,
'Adventure' => TmdbMovieGenre::Adventure->value,
'Animation' => TmdbMovieGenre::Animation->value,
'Comedy' => TmdbMovieGenre::Comedy->value,
'Crime' => TmdbMovieGenre::Crime->value,
'Documentary' => TmdbMovieGenre::Documentary->value,
'Drama' => TmdbMovieGenre::Drama->value,
'Family' => TmdbMovieGenre::Family->value,
'Fantasy' => TmdbMovieGenre::Fantasy->value,
'History' => TmdbMovieGenre::History->value,
'Horror' => TmdbMovieGenre::Horror->value,
'Music' => TmdbMovieGenre::Music->value,
'Mystery' => TmdbMovieGenre::Mystery->value,
'Romance' => TmdbMovieGenre::Romance->value,
'Science Fiction' => TmdbMovieGenre::ScienceFiction->value,
'TV Movie' => TmdbMovieGenre::TvMovie->value,
'Thriller' => TmdbMovieGenre::Thriller->value,
'War' => TmdbMovieGenre::War->value,
'Western' => TmdbMovieGenre::Western->value,
],
MediaType::TvShow->value => [
'Action & Adventure' => TmdbTvShowGenre::ActionAndAdventure->value,
'Animation' => TmdbTvShowGenre::Animation->value,
'Comedy' => TmdbTvShowGenre::Comedy->value,
'Crime' => TmdbTvShowGenre::Crime->value,
'Documentary' => TmdbTvShowGenre::Documentary->value,
'Drama' => TmdbTvShowGenre::Drama->value,
'Family' => TmdbTvShowGenre::Family->value,
'Kids' => TmdbTvShowGenre::Kids->value,
'Mystery' => TmdbTvShowGenre::Mystery->value,
'News' => TmdbTvShowGenre::News->value,
'Reality' => TmdbTvShowGenre::Reality->value,
'Sci-Fi & Fantasy' => TmdbTvShowGenre::SciFiAndFantasy->value,
'Soap' => TmdbTvShowGenre::Soap->value,
'Talk' => TmdbTvShowGenre::Talk->value,
'War & Politics' => TmdbTvShowGenre::WarAndPolitics->value,
'Western' => TmdbTvShowGenre::Western->value,
],
default => [],
};
}
}

View File

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

View File

@@ -0,0 +1,59 @@
<?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

@@ -0,0 +1,20 @@
<?php
namespace App\Library\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<GetMediaFromLibraryCommand>
*/
class GetMediaFromLibraryCommand implements CommandInterface
{
public function __construct(
public ?int $userId = null,
public ?string $mediaType = null,
public ?string $imdbId = null,
public ?string $title = null,
public ?string $season = null,
public ?string $episode = null,
) {}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Library\Action\Handler;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Service\MediaFiles;
use App\Base\Util\PTN;
use App\Library\Action\Command\GetMediaFromLibraryCommand;
use App\Library\Action\Result\GetMediaFromLibraryResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Psr\Log\LoggerInterface;
/**
* @implements HandlerInterface<GetMediaFromLibraryCommand,GetMediaFromLibraryResult>
*/
class GetMediaInfoFromLibraryHandler implements HandlerInterface
{
public function __construct(
private readonly TmdbClient $tmdb,
private readonly MediaFiles $mediaFiles,
private readonly LoggerInterface $logger,
private readonly MonitorRepository $monitorRepository,
) {}
public function handle(C $command): R
{
$result = new GetMediaFromLibraryResult();
$tmdbResult = $this->fetchTmdbData($command->imdbId, $command->mediaType);
if (null === $tmdbResult) {
$this->logger->warning('[GetMediaInfoFromLibraryHandler] TMDb result was not found, this may lead to issues in the rest of the library search', (array) $command);
}
$this->setResultExists($tmdbResult->mediaType, $tmdbResult->title, $result);
if ($result->notExists()) {
return $result;
}
$this->parseFromTmdbResult($tmdbResult, $result);
if ($command->mediaType === MediaType::TvShow->value) {
$this->setEpisodes($tmdbResult, $result);
$this->setSeasons($tmdbResult, $result);
$this->setMonitors($command->userId, $command->imdbId, $result);
}
return $result;
}
private function fetchTmdbData(string $imdbId, string $mediaType): ?TmdbResult
{
return match($mediaType) {
MediaType::Movie->value => $this->tmdb->movieDetails($imdbId),
MediaType::TvShow->value => $this->tmdb->tvShowDetails($imdbId),
default => null,
};
}
private function setResultExists(string $mediaType, string $title, GetMediaFromLibraryResult $result): void
{
$fsResult = match($mediaType) {
MediaType::Movie->value => $this->mediaFiles->movieExists($title),
MediaType::TvShow->value => $this->mediaFiles->tvShowExists($title),
default => false,
};
if (false === $fsResult) {
$result->setExists(false);
} else {
$result->setExists(true);
}
}
public function parseFromTmdbResult(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
{
$result->setTitle($tmdbResult->title);
$result->setMediaType($tmdbResult->mediaType);
$result->setImdbId($tmdbResult->imdbId);
}
public function setEpisodes(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
{
$existingEpisodeFiles = $this->mediaFiles->tvshowExists($tmdbResult->title);
$existingEpisodeMap = [];
foreach ($existingEpisodeFiles as $file) {
/** @var \SplFileInfo $file */
$ptn = (object) new PTN()->parse($file->getBasename());
if (!array_key_exists($ptn->season, $existingEpisodeMap)) {
$existingEpisodeMap[$ptn->season] = [];
}
if (!in_array($ptn->episode, $existingEpisodeMap[$ptn->season])) {
$existingEpisodeMap[$ptn->season][] = $ptn->episode;
}
}
$existingEpisodes = [];
$missingEpisodes = [];
foreach ($tmdbResult->episodes as $season => $episodes) {
foreach ($episodes as $episode) {
if (array_key_exists($season, $existingEpisodeMap)) {
if (in_array($episode->episodeNumber, $existingEpisodeMap[$season])) {
$existingEpisodes[] = $episode;
} else {
$missingEpisodes[] = $episode;
}
} else {
$missingEpisodes[] = $episode;
}
}
}
$result->setEpisodes($existingEpisodes);
$result->setMissingEpisodes($missingEpisodes);
}
public function setSeasons(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
{
$existingEpisodeFiles = $this->mediaFiles->tvshowExists($tmdbResult->title);
$existingEpisodeMap = [];
foreach ($existingEpisodeFiles as $file) {
/** @var \SplFileInfo $file */
$ptn = (object) new PTN()->parse($file->getBasename());
$existingEpisodeMap[$ptn->season][] = $ptn->episode;
}
$existingFullSeasons = [];
$existingPartialSeasons = [];
$missingSeasons = [];
foreach ($existingEpisodeMap as $season => $episodes) {
if (count($tmdbResult->episodes[$season]) === count($episodes)) {
$existingFullSeasons[] = $season;
} elseif (count($episodes) > 0) {
$existingPartialSeasons[] = $season;
}
}
$seasons = array_keys($tmdbResult->episodes);
foreach ($seasons as $season) {
if (!in_array($season, $existingFullSeasons) && !in_array($season, $existingPartialSeasons)) {
$missingSeasons[] = $season;
}
}
$result->setSeasons($existingFullSeasons);
$result->setPartialSeasons($existingPartialSeasons);
$result->setMissingSeasons($missingSeasons);
}
public function setMonitors(int $userId, string $imdbId, GetMediaFromLibraryResult $result)
{
$result->setMonitorCount(
$this->monitorRepository->countUserChildrenByParentId($userId, $imdbId)
);
$result->setActiveMonitorCount(
$this->monitorRepository->countUserActiveChildrenByParentId($userId, $imdbId)
);
$result->setCompleteMonitorCount(
$this->monitorRepository->countUserCompleteChildrenByParentId($userId, $imdbId)
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Library\Action\Input;
use App\Library\Action\Command\GetMediaFromLibraryCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<GetMediaInfoFromLibraryInput, GetMediaFromLibraryCommand>
*/
class GetMediaInfoFromLibraryInput implements InputInterface
{
public function __construct(
#[SourceRequest('imdbId', nullify: true)]
public ?string $imdbId = null,
#[SourceRequest('title', nullify: true)]
public ?string $title = null,
#[SourceRequest('season', nullify: true)]
public ?string $season = null,
#[SourceRequest('episode', nullify: true)]
public ?string $episode = null,
) {}
public function toCommand(): C
{
if (null === $this->imdbId && null === $this->title) {
throw new \InvalidArgumentException('Either imdbId or title must be set', 400);
}
return new GetMediaFromLibraryCommand(
imdbId: $this->imdbId,
title: $this->title,
season: $this->season,
episode: $this->episode,
);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Library\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class GetMediaFromLibraryResult implements ResultInterface
{
private bool $exists;
private ?string $title = null;
private ?string $imdbId = null;
private ?string $mediaType = null;
private ?array $episodes = null;
private ?array $missingEpisodes = null;
private ?array $seasons = null;
private ?array $partialSeasons = null;
private ?array $missingSeasons = null;
private ?int $monitorCount = null; // Monitor Repo
private ?int $activeMonitorCount = null; // Monitor Repo
private ?int $completeMonitorCount = null; // Monitor Repo
public function exists(): bool
{
return $this->exists;
}
public function notExists(): bool
{
return !$this->exists;
}
public function setExists(bool $exists): void
{
$this->exists = $exists;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): void
{
$this->title = $title;
}
public function getImdbId(): ?string
{
return $this->imdbId;
}
public function setImdbId(?string $imdbId): void
{
$this->imdbId = $imdbId;
}
public function getMediaType(): ?string
{
return $this->mediaType;
}
public function setMediaType(?string $mediaType): void
{
$this->mediaType = $mediaType;
}
public function getEpisodes(): ?array
{
return $this->episodes;
}
public function setEpisodes(?array $episodes): void
{
$this->episodes = $episodes;
}
public function getEpisodeCount(): ?int
{
return count($this->episodes);
}
public function getMissingEpisodes(): ?array
{
return $this->missingEpisodes;
}
public function setMissingEpisodes(?array $missingEpisodes): void
{
$this->missingEpisodes = $missingEpisodes;
}
public function getMissingEpisodeCount(): ?int
{
return count($this->missingEpisodes);
}
public function getSeasons(): ?array
{
return $this->seasons;
}
public function setSeasons(?array $seasons): void
{
$this->seasons = $seasons;
}
public function getSeasonCount(): ?int
{
return count($this->seasons);
}
public function getPartialSeasons(): ?array
{
return $this->partialSeasons;
}
public function setPartialSeasons(?array $partialSeasons): void
{
$this->partialSeasons = $partialSeasons;
}
public function getPartialSeasonCount(): ?int
{
return count($this->partialSeasons);
}
public function getMissingSeasons(): ?array
{
return $this->missingSeasons;
}
public function setMissingSeasons(?array $missingSeasons): void
{
$this->missingSeasons = $missingSeasons;
}
public function getMissingSeasonCount(): ?int
{
return count($this->missingSeasons);
}
public function getMonitorCount(): ?int
{
return $this->monitorCount;
}
public function setMonitorCount(?int $monitorCount): void
{
$this->monitorCount = $monitorCount;
}
public function getActiveMonitorCount(): ?int
{
return $this->activeMonitorCount;
}
public function setActiveMonitorCount(?int $activeMonitorCount): void
{
$this->activeMonitorCount = $activeMonitorCount;
}
public function getCompleteMonitorCount(): ?int
{
return $this->completeMonitorCount;
}
public function setCompleteMonitorCount(?int $completeMonitorCount): void
{
$this->completeMonitorCount = $completeMonitorCount;
}
}

View File

@@ -98,6 +98,7 @@ class ApiController extends AbstractController
'allDay' => true,
'backgroundColor' => $eventColors[$monitor->getImdbId()],
'borderColor' => $eventColors[$monitor->getImdbId()],
'attachment' => $monitor->getPoster(),
];
});

View File

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

View File

@@ -3,12 +3,17 @@
namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Library\Action\Command\GetMediaFromLibraryCommand;
use App\Library\Action\Handler\GetMediaInfoFromLibraryHandler;
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\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Tmdb\TmdbClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -24,7 +29,7 @@ class WebController extends AbstractController
private readonly Environment $renderer,
) {}
#[Route('/monitors', name: 'app_monitors', methods: ['GET'])]
#[Route('/monitors', name: 'app.monitors', methods: ['GET'])]
public function addMonitor()
{
return $this->render('monitor/index.html.twig');
@@ -37,10 +42,27 @@ class WebController extends AbstractController
}
#[Route('/monitors/{id}', name: 'app.monitor.view', methods: ['GET'])]
public function viewMonitor(Monitor $monitor)
public function viewMonitor(Monitor $monitor, GetMediaInfoHandler $getMediaInfoHandler, GetMediaInfoFromLibraryHandler $handler)
{
$media = $getMediaInfoHandler->handle(
new GetMediaInfoCommand(
imdbId: $monitor->getImdbId(),
mediaType: 'tvshows',
)
);
$libraryResult = $handler->handle(
new GetMediaFromLibraryCommand(
$this->getUser()->getId(),
$media->media->mediaType,
$media->media->imdbId,
$media->media->title,
)
);
return $this->render('monitor/view.html.twig', [
'monitor' => $monitor,
'results' => $media,
'library' => $libraryResult
]);
}
}

View File

@@ -41,4 +41,83 @@ class MonitorRepository extends ServiceEntityRepository
->getQuery();
return $query->getResult();
}
public function getActiveUserMonitors()
{
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
->andWhere('m.parent IS NULL')
->setParameter('statuses', ['New', 'In Progress', 'Active'])
->setParameter('term', '%'.$this->term.'%')
->orderBy('m.id', 'DESC')
->getQuery()
);
}
public function getChildMonitorsByParentId(int $parentId)
{
return $this->asPaginator(
$this->monitorRepository->createQueryBuilder('m')
->andWhere("m.parent = :parentId")
->setParameter('parentId', $parentId)
->orderBy('m.id', 'DESC')
->getQuery()
);
}
public function getCompleteUserMonitors()
{
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status = :status')
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
->setParameter('status', 'Complete')
->setParameter('term', '%'.$this->term.'%')
->orderBy('m.id', 'DESC')
->getQuery()
);
}
public function countUserChildrenByParentId(int $userId, string $imdbId): ?int
{
return $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->andWhere('m.user = :user')
->andWhere('m.imdbId = :imdbId')
->setParameter('user', $userId)
->setParameter('imdbId', $imdbId)
->getQuery()
->getSingleScalarResult()
;
}
public function countUserActiveChildrenByParentId(int $userId, string $imdbId): ?int
{
return $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->andWhere('m.user = :user')
->andWhere('m.imdbId = :imdbId')
->andWhere('m.status IN (:statuses)')
->setParameter('user', $userId)
->setParameter('statuses', ['Active', 'New', 'In Progress'])
->setParameter('imdbId', $imdbId)
->getQuery()
->getSingleScalarResult()
;
}
public function countUserCompleteChildrenByParentId(int $userId, string $imdbId): ?int
{
return $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->andWhere('m.user = :user')
->andWhere('m.imdbId = :imdbId')
->andWhere('m.status IN (:statuses)')
->setParameter('user', $userId)
->setParameter('statuses', ['Complete'])
->setParameter('imdbId', $imdbId)
->getQuery()
->getSingleScalarResult()
;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Tmdb\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class WatchProviderDto
{
const BASE_LOGO_PATH = 'https://image.tmdb.org/t/p/w185';
#[SerializedPath('[provider_id]')]
public int $id;
#[SerializedPath('[display_priority]')]
public int $displayPriority;
#[SerializedPath('[provider_name]')]
public string $name;
#[SerializedPath('[logo_path]')]
public string $logo {
set(string $value) => self::BASE_LOGO_PATH . $value;
}
public string $url;
}

View File

@@ -2,13 +2,18 @@
namespace App\Tmdb\Framework\Controller;
use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher;
use App\Library\Action\Result\LibrarySearchResult;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbMovieGenre;
use App\Tmdb\TmdbResult;
use App\Tmdb\TmdbTvShowGenre;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboBundle;
class ApiController extends AbstractController
{
@@ -47,4 +52,50 @@ class ApiController extends AbstractController
'results' => $results,
]);
}
#[Route('/api/tmdb/watch-providers/{mediaType}/{tmdbId}', name: 'api.tmdb.watch_providers', methods: ['GET'])]
public function watchProviders(string $mediaType, string $tmdbId, Request $request, TmdbClient $tmdb)
{
$result = $tmdb->watchProviders($tmdbId, $mediaType);
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse(['providers' => $result], $request);
}
return $this->json($result);
}
#[Route('/api/tmdb/genre/{mediaType}/{genreId}', name: 'api.tmdb.genre', methods: ['GET'])]
public function genreResults(string $mediaType, string $genreId, Request $request, TmdbClient $tmdb)
{
$genre = TmdbMovieGenre::from($genreId);
$results['media_type'] = $mediaType;
$results['genre'] = $genre->name;
$results['genre_id'] = $genre->value;
$results['media'] = match($mediaType) {
MediaType::Movie->value => $tmdb->discoverMovies(
[TmdbMovieGenre::from($genre->value)],
),
MediaType::TvShow->value => $tmdb->discoverTvShows(
[TmdbTvShowGenre::from($genreId)]
)
};
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse(['result' => $results], $request);
}
return $this->json($results);
}
private function sendFragmentResponse(mixed $result, Request $request): Response
{
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock(
'discover/fragments.html.twig',
$request->query->get('block'),
[
'result' => $result,
'target' => $request->query->get('target')
]
);
}
}

View File

@@ -7,6 +7,7 @@ use App\Base\Enum\MediaType;
use App\Tmdb\Dto\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto;
use App\Tmdb\Dto\WatchProviderDto;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
@@ -28,6 +29,7 @@ class TmdbResultDenormalizer implements DenormalizerInterface
$result->directors = $this->getDirectors($data);
$result->producers = $this->getProducers($data);
$result->creators = $this->getCreators($data);
$result->watchProviders = $this->getWatchProviders($data);
return $result;
}
@@ -87,6 +89,17 @@ class TmdbResultDenormalizer implements DenormalizerInterface
->toArray();
}
public function getWatchProviders(array $data): ?array
{
if (!array_key_exists('watch/providers', $data)) {
return null;
}
// ToDo: Make region configurable
return Map::from($data['watch/providers']['results']['US']['flatrate'])
->map(fn($item) => $this->normalizer->denormalize($item, WatchProviderDto::class))
->toArray();
}
public function supportsDenormalization(
mixed $data,
string $type,

View File

@@ -6,9 +6,11 @@ use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\Dto\WatchProviderDto;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Serializer\SerializerInterface;
use Tmdb\Api\Find;
use Tmdb\Client;
@@ -19,6 +21,7 @@ use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\RequestEvent;
use Tmdb\Repository\DiscoverRepository;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository;
@@ -30,6 +33,7 @@ use Tmdb\Token\Api\BearerToken;
class TmdbClient
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
const APPEND_TO_RESPONSE = 'external_ids,credits';
protected Client $client;
protected MovieRepository $movieRepository;
@@ -38,6 +42,8 @@ class TmdbClient
protected TvEpisodeRepository $tvEpisodeRepository;
protected SearchRepository $searchRepository;
protected DiscoverRepository $discoverRepository;
protected array $mediaTypeMap = [
MediaType::Movie->value => MediaType::Movie->value,
MediaType::TvShow->value => MediaType::TvShow->value,
@@ -48,11 +54,14 @@ class TmdbClient
protected $repos = [];
private ?string $originalLanguage = 'en';
public function __construct(
private readonly SerializerInterface $serializer,
private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey,
#[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
) {
$this->client = new Client(
[
@@ -74,7 +83,7 @@ class TmdbClient
'hydration' => [
'event_listener_handles_hydration' => false,
'only_for_specified_models' => []
]
],
]
);
@@ -107,11 +116,14 @@ class TmdbClient
$this->tvSeasonRepository = new TvSeasonRepository($this->client);
$this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
$this->searchRepository = new SearchRepository($this->client);
$this->discoverRepository = new DiscoverRepository($this->client);
$this->repos = [
MediaType::Movie->value => $this->movieRepository,
MediaType::TvShow->value => $this->tvRepository,
MediaType::TvEpisode->value => $this->tvEpisodeRepository,
];
$this->originalLanguage = $originalLanguage;
}
public function search(string $term): TmdbResult|Map
@@ -129,11 +141,55 @@ class TmdbClient
return $this->parseListOfResults($results);
}
public function discoverMovies(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map
{
if (!empty($genres) && $genres[0] instanceof TmdbMovieGenre) {
$genres = array_map(fn ($genre) => $genre->value, $genres);
}
$results = $this->discoverRepository->getApi()->discoverMovies([
'page' => $page,
'page_size' => $pageSize,
'with_genres' => implode(',', $genres),
'with_original_language' => $this->originalLanguage,
'append_to_response' => static::APPEND_TO_RESPONSE,
]);
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::Movie->value;
return $result;
});
return $this->parseListOfResults(
$results,
);
}
public function discoverTvshows(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map
{
if (!empty($genres) && $genres[0] instanceof TmdbTvShowGenre) {
$genres = array_map(fn ($genre) => $genre->value, $genres);
}
$results = $this->discoverRepository->getApi()->discoverTv([
'page' => $page,
'page_size' => $pageSize,
'with_genres' => implode(',', $genres),
'with_original_language' => $this->originalLanguage,
'append_to_response' => static::APPEND_TO_RESPONSE,
]);
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::TvShow->value;
return $result;
});
return $this->parseListOfResults(
$results,
);
}
public function movieDetails(string $imdbId): ?TmdbResult
{
$tmdbId = $this->findByImdbId($imdbId)['id'];
return $this->parseResult(
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => 'external_ids,credits']),
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]),
MediaType::Movie->value,
$imdbId
);
@@ -142,7 +198,7 @@ class TmdbClient
public function tvshowDetails(string $imdbId): ?TmdbResult
{
$tmdbId = $this->findByImdbId($imdbId)['id'];
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => 'external_ids,credits']);
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]);
$media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
return $data['season_number'] !== 0 &&
@@ -163,7 +219,7 @@ class TmdbClient
public function tvSeasonDetails(string $tmdbId, int $season): array
{
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => 'external_ids,credits']);
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => static::APPEND_TO_RESPONSE]);
$result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
$data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
$data['poster'] = $data['still_path'];
@@ -174,7 +230,7 @@ class TmdbClient
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' => 'external_ids,credits']);
$result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => static::APPEND_TO_RESPONSE]);
return $this->parseResult(
$result,
MediaType::TvEpisode->value,
@@ -191,9 +247,20 @@ class TmdbClient
);
}
public function watchProviders(string $tmdbId, string $mediaType): Map
{
$results = $this->repos[$mediaType]->getApi()->getWatchProviders($tmdbId)['results']['US']['flatrate'];
return Map::from($results)->map(function ($result) {
return $this->serializer->denormalize($result, WatchProviderDto::class);
});
}
public function popularMovies(int $resultCount = 6): Map
{
$results = $this->movieRepository->getApi()->getPopular();
$results = $this->discoverRepository->getApi()->discoverMovies([
'with_original_language' => $this->originalLanguage,
'append_to_response' => 'external_ids,watch/providers',
]);
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::Movie->value;
return $result;
@@ -206,7 +273,10 @@ class TmdbClient
public function popularTvShows(int $resultCount = 6): Map
{
$results = $this->tvRepository->getApi()->getPopular();
$results = $this->discoverRepository->getApi()->discoverTv([
'with_original_language' => $this->originalLanguage,
'append_to_response' => 'external_ids,watch/providers',
]);
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::TvShow->value;
return $result;

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Tmdb;
enum TmdbMovieGenre: int
{
case Action = 28;
case Adventure = 12;
case Animation = 16;
case Comedy = 35;
case Crime = 80;
case Documentary = 99;
case Drama = 18;
case Family = 10751;
case Fantasy = 14;
case History = 36;
case Horror = 27;
case Music = 10402;
case Mystery = 9648;
case Romance = 10749;
case ScienceFiction = 878;
case TvMovie = 10770;
case Thriller = 53;
case War = 10752;
case Western = 37;
}

View File

@@ -7,6 +7,7 @@ use App\Tmdb\Dto\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\Dto\WatchProviderDto;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\SerializedPath;
@@ -57,5 +58,6 @@ class TmdbResult
public ?int $runtime = null,
public ?int $numberSeasons = null,
public ?int $latestSeason = null,
public ?array $watchProviders = null
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Tmdb;
enum TmdbTvShowGenre: int
{
case ActionAndAdventure = 10759;
case Animation = 16;
case Comedy = 35;
case Crime = 80;
case Documentary = 99;
case Drama = 18;
case Family = 10751;
case Kids = 10762;
case Mystery = 9648;
case News = 10763;
case Reality = 10764;
case SciFiAndFantasy = 10765;
case Soap = 10766;
case Talk = 10767;
case WarAndPolitics = 10768;
case Western = 37;
}

View File

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

View File

@@ -7,6 +7,7 @@ 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 Carbon\Carbon;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -21,6 +22,8 @@ use Symfony\UX\Turbo\TurboBundle;
final class WebController extends AbstractController
{
const REAL_DEBRID_STREAM_URL = 'https://real-debrid.com/streaming-%s';
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
@@ -99,4 +102,14 @@ 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

@@ -0,0 +1,18 @@
<?php
namespace App\Twig\Components;
use Aimeos\Map;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class PosterContainer
{
public array|Map $media;
public string $mediaType;
public ?string $genreId = null;
public ?string $genre = null;
// Only show 6 results and a 'more' button
public bool $tease = true;
}

View File

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

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{ pwa() }}
<title>{% block title %}Welcome!{% endblock %}</title>
<title>{% block title %}Torsearch{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
@@ -12,6 +12,10 @@
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% 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>
{% endblock %}
</head>
@@ -22,9 +26,9 @@
</div>
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
<twig:Header />
<div class="flex justify-between items-center">
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<div class="flex mt-4 gap-2 items-center grow-0 md:px-4">
<div class="flex flex-col md:flex-row justify-between md:items-center">
<h2 class="px-4 mt-4 mb-4 md: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">
{% block action_buttons %}{% endblock %}
</div>
</div>

View File

@@ -55,7 +55,7 @@
{% if this.isWidget and this.monitors.items|length > 5 %}
<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">
<a href="{{ path('app_monitors') }}">View All Monitors</a>
<a href="{{ path('app.monitors') }}">View All Monitors</a>
</td>
</tr>
{% endif %}

View File

@@ -1,4 +1,4 @@
<tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900"
<tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer"
monitor-id="{{ monitor.id }}"
parent-id="{{ monitor.parent.id ?? null }}"
imdb-id="{{ monitor.imdbId }}"
@@ -28,11 +28,13 @@
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ monitor|monitor_media_id }}
</td>
{# Monitor is a CHILD monitor #}
{% if null != monitor.parent %}
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{{ monitor.searchCount }}
</td>
{% else %}
{# Monitor is a PARENT monitor #}
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{{ monitor.children|length }}
</td>

View File

@@ -21,7 +21,7 @@
</li>
<li>
<a href="{{ path('app_monitors') }}"
<a href="{{ path('app.monitors') }}"
class="block rounded-lg
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">
@@ -29,6 +29,15 @@
</a>
</li>
<li>
<a href="{{ path('app.discover') }}"
class="block rounded-lg
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">
Discover
</a>
</li>
<li>
<a href="{{ path('app_user_preferences') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">

View File

@@ -1,14 +1,21 @@
<div{{ attributes }}>
{% if image != null and image != "https://image.tmdb.org/t/p/w500" %}
<a href="{{ path('app_search_result', {
mediaType: mediaType,
imdbId: imdbId
}) }}">
<img src="{{ preload(image) }}" class="w-full rounded-md" />
</a>
{% else %}
<div class="w-full md:w-32 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="16" name="hugeicons:loading-01" />
</div>
{% endif %}
<a href="{{ path('app_search_result', {
mediaType: mediaType,
imdbId: imdbId
}) }}">
<img src="{{ preload(image) }}" class="w-full md:w-40 rounded-md" />
</a>
<a href="{{ path('app_search_result', {
mediaType: mediaType,
imdbId: imdbId
}) }}">
<h3 class="text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
<h3 class="mt-2 text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
</a>
</div>

View File

@@ -0,0 +1,30 @@
<div{{ attributes.defaults(stimulus_controller('discover_media_results')) }} class="flex flex-col">
<div class="grid grid-cols-1 md:grid-cols-6 gap-4">
{% for i in range(0, media|length - 1) %}
{% if i > 5 and tease is true %}
{% set class_list = "hidden" %}
{% else %}
{% set class_list = "" %}
{% endif %}
{% set poster = media[i] %}
<twig:Poster data-discover-media-results-target="poster"
imdbId="{{ poster.imdbId }}"
tmdbId="{{ poster.tmdbId }}"
title="{{ poster.title }}"
description="{{ poster.description }}"
image="{{ poster.poster }}"
year="{{ poster.year }}"
mediaType="movies"
class="pb-2 w-full rounded-lg {{ class_list }}"
/>
{% endfor %}
</div>
{% if tease == true %}
<div class="inline-flex self-end text-white">
<button data-discover-media-results-target="moreBtn" data-action="click->discover-media-results#moreResults" href="#" class="underline">More</button>
<a data-discover-media-results-target="moreLink" href="{{ url('app.discover.browse', {mediaType: mediaType, page: 2, genreId: genreId}) }}" class="underline hidden">More &gt;</a>
</div>
{% endif %}
</div>

View File

@@ -1,4 +1,4 @@
<dialog{{ attributes }} is="preview-content-dialog" class="py-3 px-4 w-full md:w-[50rem] rounded-md dark:bg-gray-950/80 dark:border-2 dark:border-orange-500 dark:text-white backdrop-filter backdrop-blur-3xl">
<dialog{{ attributes }} is="preview-content-dialog" class="py-3 px-4 w-full md:w-[{{ mdWidth|default('50rem') }}] rounded-md dark:bg-gray-950/80 dark:border-2 dark:border-orange-500 dark:text-white backdrop-filter backdrop-blur-3xl">
<div class="flex flex-row justify-end">
<twig:ux:icon name="ic:twotone-cancel" width="16.75px" height="16.75px" class="modal-close rounded-full align-middle text-red-600 hover:text-red-700" />
</div>

View File

@@ -1,7 +1,7 @@
<div{{ attributes }}>
<div class="p-4 flex flex-col md:flex-row gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
{% if poster != null and poster != "https://image.tmdb.org/t/p/w500" %}
<img class="w-full md:w-24 rounded-lg" src="{{ poster }}" />
<img class="w-full md:w-24 rounded-lg" src="{{ preload(poster) }}" />
{% else %}
<div class="w-full md:w-32 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="16" name="hugeicons:loading-01" />

View File

@@ -0,0 +1,17 @@
{% extends 'base.html.twig' %}
{% block title %}Discover {{ media_type|capitalize }} &mdash; {{ parent() }}{% endblock %}
{% block h2 %}Discover {{ media_type|capitalize }}{% endblock %}
{% block body %}
<div class="p-4 flex flex-col gap-4">
{% for genreTitle, genreId in genres %}
<twig:Turbo:Frame id="genre_{{ media_type }}_{{ genreId }}" src="{{ path('api.tmdb.genre', {
mediaType: media_type,
genreId: genreId,
block: 'genre_results',
target: 'genre_' ~ media_type~ '_' ~ genreId
}) }}">
</twig:Turbo:Frame>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html.twig' %}
{% block title %}Discover {{ genre }} {{ media_type|capitalize }} &mdash; {{ parent() }}{% endblock %}
{% block h2 %}Discover {{ genre }} {{ media_type|capitalize }}{% endblock %}
{% block body %}
<div class="p-4 flex flex-col gap-4">
<twig:Card title="{{ genre }}" class="w-full">
<twig:PosterContainer tease="'false'" genreId="{{ genre_id }}" mediaType="{{ media_type }}" media="{{ media }}"></twig:PosterContainer>
</twig:Card>
</div>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% block watch_providers %}
{% if result.providers %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
<div class="flex flex-row justify-start items-end gap-1 mt-2">
{% for provider in result.providers %}
<a href="#">
<img class="w-10 h-10 rounded-lg" src="{{ provider.logo }}" alt="{{ provider.name }}" title="{{ provider.name }}" />
</a>
{% endfor %}
</div>
</template>
</turbo-stream>
{% endif %}
{% endblock %}
{% block genre_results %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
<twig:Card title="{{ result.result.genre }}" class="w-full">
<twig:PosterContainer genreId="{{ result.result.genre_id }}" mediaType="{{ result.result.media_type }}" media="{{ result.result.media }}"></twig:PosterContainer>
</twig:Card>
</template>
</turbo-stream>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'base.html.twig' %}
{% block title %}Discover &mdash; {{ parent() }}{% endblock %}
{% block h2 %}Discover New Media{% endblock %}
{% block body %}
<div class="p-4 flex flex-col gap-4">
<twig:Card title="Popular Movies" class="w-full">
<twig:PosterContainer mediaType="movies" media="{{ movies }}" />
</twig:Card>
<twig:Card title="Popular Shows" class="w-full">
<twig:PosterContainer mediaType="tvshows" media="{{ shows }}" />
</twig:Card>
</div>
{% endblock %}

View File

@@ -15,6 +15,12 @@
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
<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">
<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" />
@@ -34,6 +40,7 @@
}
document.addEventListener('DOMContentLoaded', async function() {
const modal = document.getElementById('previewModal');
modal.setAttribute('mdWidth', '25rem');
let data = await fetch('/api/monitor/upcoming-episodes');
data = (await data.json())['data'];
@@ -47,7 +54,12 @@
eventClick: function (data) {
modal.display({
heading: data.event.title,
content: `<p>${data.event.startStr}</p>`
content: `
<div class="flex flex-col gap-4 justify-center items-center">
<img src="${data.event.extendedProps.attachment}" class="w-[90%] rounded-md" />
<p>${data.event.startStr}</p>
</div>
`
})
}
});

View File

@@ -5,7 +5,146 @@
{% block body %}
<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 }}">
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6">
{% if results.media.poster != null %}
<img class="w-full md:w-[12.5rem] rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-full md:w-[12.5rem] h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="24" name="hugeicons:loading-01" />
</div>
{% endif %}
<div class="w-full flex flex-col">
<div class="mb-4 flex flex-row gap-2 justify-between">
<h3 class="text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} ({{ results.media.year }})
</h3>
</div>
<p class="text-gray-50 mb-4">
{{ results.media.description }}
</p>
<div class="text-gray-50 mb-2">
<div id="people" class="mb-1">
{% if results.media.stars != null %}
<strong>Starring</strong>: {{ results.media.stars|join(', ') }} <br />
{% endif %}
{% if results.media.directors != null %}
<strong>Directors</strong>: {{ results.media.directors|join(', ') }} <br />
{% endif %}
{% if results.media.producers != null %}
<strong>Producers</strong>: {{ results.media.producers|join(', ') }} <br />
{% endif %}
{% if results.media.creators != null %}
<strong>Creators</strong>: {{ results.media.creators|join(', ') }} <br />
{% endif %}
</div>
<div id="dates" class="mb-1">
{% if results.media.premiereDate %}
<strong>Premiered</strong>: {{ results.media.premiereDate|date('n/j/Y', 'UTC') }} <br />
{% endif %}
</div>
{% if results.media.genres != null %}
<div id="genres" class="text-gray-50 my-4">
{% for genre in results.media.genres %}
<small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small>
{% endfor %}
</div>
{% endif %}
</div>
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-col gap-4">
<div class="flex flex-col grow text-white">
<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">
<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">
<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>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white">
<span>{{ library.partialSeasonCount }}</span> partial
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white">
<span>{{ library.missingSeasonCount }}</span> missing
</span>
</div>
</div>
<div class="flex flex-col">
<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">
<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>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-cyan-500 rounded-lg text-white">
<span>{{ library.missingEpisodeCount }}</span> missing
</span>
</div>
</div>
<div class="flex flex-col">
<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">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white">
<span>{{ library.monitorCount }}</span> total
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white">
<span>{{ library.activeMonitorCount }}</span> active
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white">
<span>{{ library.completeMonitorCount }}</span> complete
</span>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span class="results-count-number" id="movie_results_count">-</span> results
</span>
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
title: results.media.title,
block: 'media_exists_badge',
target: "meb_" ~ results.media.imdbId
}) }}">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing
</span>
</twig:Turbo:Frame>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.premiereDate|date('n/j/Y', 'UTC') }}
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white" title="This movie has a runtime of {{ results.media.runtime }} minutes.">
{{ results.media.runtime }} minutes
</span>
</div>
{% endif %}
</div>
</div>
<twig:MonitorList :parentMonitorId="monitor.id" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>

View File

@@ -76,48 +76,57 @@
{% if results.media.genres != null %}
<div id="genres" class="text-gray-50 my-4">
{% for genre in results.media.genres %}
<small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small>
<a href="{{ url('app.discover.browse_genre', {mediaType: results.media.mediaType, genreId: genre.id}) }}" class="px-2 py-1 border border-orange-500 rounded-full text-sm">{{ genre }}</a>
{% endfor %}
</div>
{% endif %}
</div>
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-orange-500 rounded-lg text-white">
<span>{{ results.media.numberSeasons }}</span> season(s)
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title='"{{ results.media.title }}" first aired on {{ results.media.premiereDate|date(null, 'UTC') }}.'>
{{ results.media.premiereDate|date(null, 'UTC') }}
</span>
</div>
{% endif %}
<div class="flex flex-col gap-2">
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-orange-500 rounded-lg text-white">
<span>{{ results.media.numberSeasons }}</span> season(s)
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title='"{{ results.media.title }}" first aired on {{ results.media.premiereDate|date(null, 'UTC') }}.'>
{{ results.media.premiereDate|date(null, 'UTC') }}
</span>
</div>
{% endif %}
{% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span class="results-count-number" id="movie_results_count">-</span> results
</span>
{% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span class="results-count-number" id="movie_results_count">-</span> results
</span>
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
title: results.media.title,
block: 'media_exists_badge',
target: "meb_" ~ results.media.imdbId
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
title: results.media.title,
block: 'media_exists_badge',
target: "meb_" ~ results.media.imdbId
}) }}">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing
</span>
</twig:Turbo:Frame>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.premiereDate|date('n/j/Y', 'UTC') }}
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white" title="This movie has a runtime of {{ results.media.runtime }} minutes.">
{{ results.media.runtime }} minutes
</span>
</div>
{% endif %}
<twig:Turbo:Frame id="watch_providers_frame" src="{{ path('api.tmdb.watch_providers', {
mediaType: results.media.mediaType,
tmdbId: results.media.tmdbId,
block: 'watch_providers',
target: 'watch_providers_frame'
}) }}">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing
</span>
</twig:Turbo:Frame>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.premiereDate|date('n/j/Y', 'UTC') }}
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white" title="This movie has a runtime of {{ results.media.runtime }} minutes.">
{{ results.media.runtime }} minutes
</span>
</div>
{% endif %}
</div>
</div>

View File

@@ -86,9 +86,18 @@
{{ result.languageFlags|raw }}
</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">
<button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50">
Download
</button>
<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">
<twig:ux:icon name="bi:camera-video" width="20"/>
</a>
<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">
<input id="select" type="checkbox" name="select" />
</label>

View File

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