Compare commits

...

17 Commits

55 changed files with 769 additions and 231 deletions

View File

@@ -1,5 +1,23 @@
{
"controllers": {
"@spomky-labs/pwa-bundle": {
"connection-status": {
"enabled": true,
"fetch": "eager"
},
"backgroundsync-form": {
"enabled": true,
"fetch": "eager"
},
"sync-broadcast": {
"enabled": true,
"fetch": "eager"
},
"prefetch-on-demand": {
"enabled": true,
"fetch": "eager"
}
},
"@symfony/ux-autocomplete": {
"autocomplete": {
"enabled": true,

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
dev.caldwell.digital:443
{
log {
level DEBUG
}
}
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
reverse_proxy app:80
dev.caldwell.digital:443 {
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
reverse_proxy app:80
}

View File

@@ -2,6 +2,7 @@ services:
caddy:
image: caddy:2.9.1
restart: unless-stopped
tty: true
cap_add:
- NET_ADMIN
ports:

View File

@@ -27,6 +27,7 @@
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"spomky-labs/pwa-bundle": "^1.2",
"stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*",
"symfony/console": "7.3.*",

123
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bfbdc7ee820da20b824f4b1933fe967b",
"content-hash": "0f98dada0a01d471cebf4eb1b51b9006",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -4866,6 +4866,127 @@
],
"time": "2025-06-13T08:35:04+00:00"
},
{
"name": "spomky-labs/pwa-bundle",
"version": "1.2.5",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/pwa-bundle.git",
"reference": "c24711ea8428d14a01132d9e42cb17d84218a1ee"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/pwa-bundle/zipball/c24711ea8428d14a01132d9e42cb17d84218a1ee",
"reference": "c24711ea8428d14a01132d9e42cb17d84218a1ee",
"shasum": ""
},
"require": {
"php": ">=8.2",
"phpdocumentor/reflection-docblock": "^5.3",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/asset": "^6.4|^7.0",
"symfony/asset-mapper": "^6.4|^7.0",
"symfony/config": "^6.4|^7.0",
"symfony/dependency-injection": "^6.4|^7.0",
"symfony/deprecation-contracts": "^3.5",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/property-access": "^6.4|^7.0",
"symfony/property-info": "^6.4|^7.0",
"symfony/routing": "^6.4|^7.0",
"symfony/serializer": "^6.4|^7.0",
"twig/twig": "^3.8"
},
"require-dev": {
"dbrekelmans/bdi": "^1.1",
"ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
"ergebnis/phpunit-slow-test-detector": "^2.14",
"ext-sockets": "*",
"infection/infection": "^0.28|^0.29",
"matthiasnoback/symfony-config-test": "^5.1",
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/extension-installer": "^1.1",
"phpstan/phpdoc-parser": "^1.28|^2.0",
"phpstan/phpstan": "^1.0|^2.0",
"phpstan/phpstan-beberlei-assert": "^1.0|^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
"phpstan/phpstan-phpunit": "^1.4|^2.0",
"phpstan/phpstan-strict-rules": "^1.0|^2.0",
"phpstan/phpstan-symfony": "^1.4|^2.0",
"phpunit/phpunit": "^10.1|^11.0",
"rector/rector": "^1.0|^2.0",
"staabm/phpstan-todo-by": "^0.1.27|^0.2",
"struggle-for-php/sfp-phpstan-psr-log": "^0.21.0|^0.22|^0.23",
"symfony/filesystem": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/mime": "^6.4|^7.0",
"symfony/monolog-bundle": "^3.10",
"symfony/panther": "^2.1",
"symfony/phpunit-bridge": "^6.4|^7.0",
"symfony/translation": "^7.0",
"symfony/yaml": "^6.4|^7.0",
"symplify/easy-coding-standard": "^12.0"
},
"suggest": {
"ext-gd": "Required to generate icons (or Imagick).",
"ext-imagick": "Required to generate icons (or GD).",
"symfony/filesystem": "For generating and manipulating icons or screenshots",
"symfony/mime": "For generating and manipulating icons or screenshots",
"symfony/panther": "For generating screenshots directly from your application"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/spomky-labs/pwa-bundle",
"name": "spomky-labs/pwa-bundle"
}
},
"autoload": {
"psr-4": {
"SpomkyLabs\\PwaBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/spomky-labs/pwa-bundle/contributors"
}
],
"description": "Progressive Web App Manifest Generator Bundle for Symfony.",
"homepage": "https://github.com/spomky-labs",
"keywords": [
"bundle",
"pwa",
"symfony",
"symfony-ux"
],
"support": {
"issues": "https://github.com/Spomky-Labs/pwa-bundle/issues",
"source": "https://github.com/Spomky-Labs/pwa-bundle/tree/1.2.5"
},
"funding": [
{
"url": "https://www.buymeacoffee.com/FlorentMorselli",
"type": "custom"
},
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2024-12-16T08:02:21+00:00"
},
{
"name": "stof/doctrine-extensions-bundle",
"version": "v1.14.0",

View File

@@ -22,4 +22,5 @@ return [
Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true],
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true],
SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::class => ['all' => true],
];

21
config/packages/pwa.yaml Normal file
View File

@@ -0,0 +1,21 @@
pwa:
manifest:
enabled: true
name: "Torsearch"
short_name: "torsearch"
start_url: "/"
display: "standalone"
id: "/"
background_color: "#f98e44"
theme_color: "#083344"
description: Torsearch provides a simple and intuitive way to manage your personal media library.
icons:
- src: "icon.png"
sizes: [ 192 ]
- src: "icon.png"
sizes: [ 192 ]
purpose: maskable
categories:
- entertainment
- multimedia
- utilities

View File

@@ -32,6 +32,8 @@ security:
login_path: '/login/oidc'
check_path: '/login/oidc/auth'
enable_end_session_listener: true
http_basic:
realm: Secured Area
entry_point: form_login
# activate different ways to authenticate

View File

@@ -6,6 +6,14 @@ controllersBase:
defaults:
schemes: [ 'https' ]
controllersLibrary:
resource:
path: ../src/Library/Framework/Controller/
namespace: App\Library\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersSearch:
resource:
path: ../src/Search/Framework/Controller/

View File

@@ -7,6 +7,11 @@ APP_URL="https://dev.caldwell.digital"
APP_SECRET="70169beadfbc8101c393cbfbba27a313"
APP_ENV=prod
# Mercure is a Caddy module built into the webserver
# that facilitates the usage of websockets to transmit
# real time data (download progress, etc.)
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# Use the DATABASE_URL below to use the MariaDB container
# provided in the example.compose.yml file, or remove this
# line and fill in the details of your own MySQL/MariaDB server
@@ -19,39 +24,48 @@ DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-Ma
# This key is never saved anywhere
# else and is passed to Torrentio
# to retrieve download options
#REAL_DEBRID_KEY=""
REAL_DEBRID_KEY=""
# Enter you TMDB API key
# Enter your TMDB API key
# This is used to provide rich search results
# when searching for media and rendering the
# Popular Movies and TV Shows section.
#TMDB_API=
REAL_DEBRID_KEY=""
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
TMDB_API=""
# Use your own Redis instance or use the
# below value to use the container included
# in the example compose.yml file.
REDIS_HOST="redis://redis"
### Auth ###
# Change to "oidc" to and provide the required
# environment variables below to use OIDC auth.
AUTH_METHOD=form_login
# OIDC
OIDC_WELL_KNOWN_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# Allows you to skip the login page and directly
# rely on your IdP for auth.
OIDC_BYPASS_FORM_LOGIN=
# LDAP Config: To use LDAP, enter the below fields
# and run 'php bin/console config:set auth.method ldap'
LDAP_HOST=
LDAP_PORT=
LDAP_ENCRYPTION=
LDAP_BASE_DN=
LDAP_BIND_USER=
LDAP_BIND_PASS=
LDAP_DN_STRING=
LDAP_UID_KEY="uid"
# (LDAP is still in progress and not ready for use)
#LDAP_HOST=
#LDAP_PORT=
#LDAP_ENCRYPTION=
#LDAP_BASE_DN=
#LDAP_BIND_USER=
#LDAP_BIND_PASS=
#LDAP_DN_STRING=
#LDAP_UID_KEY="uid"
# LDAP group that identifies an Admin
# Users with this LDAP group will automatically
# get the admin role in this system.
LDAP_ADMIN_ROLE_DN=""
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_USERNAME_ATTRIBUTE=uid
LDAP_NAME_ATTRIBUTE=displayname
#LDAP_ADMIN_ROLE_DN=""
#LDAP_EMAIL_ATTRIBUTE=mail
#LDAP_USERNAME_ATTRIBUTE=uid
#LDAP_NAME_ATTRIBUTE=displayname

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -2,7 +2,7 @@
namespace App\Base\Framework\Controller;
use App\Base\Util\Broadcaster;
use App\Base\Service\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Base\Util;
namespace App\Base\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;

View File

@@ -140,7 +140,7 @@ class MediaFiles
return $path;
}
public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber)
public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber): SplFileInfo|false
{
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);

View File

@@ -2,9 +2,8 @@
namespace App\Download\Framework\Controller;
use App\Base\Util\Broadcaster;
use App\Base\Service\Broadcaster;
use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Handler\DownloadSeasonHandler;
use App\Download\Action\Handler\PauseDownloadHandler;
use App\Download\Action\Handler\ResumeDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput;
@@ -13,8 +12,6 @@ use App\Download\Action\Input\DownloadSeasonInput;
use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\Framework\Repository\DownloadRepository;
use App\User\Dto\UserPreferencesFactory;
use Nihilarr\PTN;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Library\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class LibrarySearchCommand implements CommandInterface
{
public function __construct(
public ?string $term = null,
public ?string $title = null,
public ?string $imdbId = null,
public ?string $season = null,
public ?string $episode = null,
) {}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Library\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Library\Action\Command\LibrarySearchCommand;
use App\Library\Action\Result\LibrarySearchResult;
use App\Library\Dto\MediaFileDto;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements HandlerInterface<LibrarySearchCommand,LibrarySearchHandler>
*/
class LibrarySearchHandler implements HandlerInterface
{
private array $searchTypes = [
'episode_by_title' => 'episodeByTitle',
'movie_by_title' => 'movieByTitle',
];
public function __construct(
private readonly MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$searchType = $this->getSearchType($command);
$function = $this->searchTypes[$searchType];
return $this->$function($command);
}
private function getSearchType(CommandInterface $command): ?string
{
if (!is_null($command->title) &&
is_null($command->imdbId) &&
is_null($command->season) &&
is_null($command->episode)
) {
return 'movie_by_title';
}
if ((!is_null($command->title) || is_null($command->imdbId)) &&
!is_null($command->season) &&
!is_null($command->episode)
) {
return 'episode_by_title';
}
return null;
}
private function episodeByTitle(CommandInterface $command): ?LibrarySearchResult
{
$result = $this->mediaFiles->episodeExists(
$command->title,
(int) $command->season,
(int) $command->episode,
);
$exists = $result instanceof \SplFileInfo;
return new LibrarySearchResult(
input: $command,
exists: $exists,
file: true === $exists ? MediaFileDto::fromSplFileInfo($result) : null,
ptn: true === $exists ? (object) new PTN()->parse($result->getFilename()) : null,
);
}
private function movieByTitle(CommandInterface $command): ?LibrarySearchResult
{
$result = $this->mediaFiles->movieExists($command->title);
$exists = $result instanceof \SplFileInfo;
return new LibrarySearchResult(
input: $command,
exists: $exists,
file: true === $exists ? MediaFileDto::fromSplFileInfo($result) : null,
ptn: true === $exists ? (object) new PTN()->parse($result->getFilename()) : null,
);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Library\Action\Input;
use App\Library\Action\Command\LibrarySearchCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<LibrarySearchInput, LibrarySearchCommand>
*/
class LibrarySearchInput implements InputInterface
{
public function __construct(
#[SourceQuery('term', nullify: true)]
private ?string $term = null,
#[SourceQuery('title', nullify: true)]
private ?string $title = null,
#[SourceQuery('imdbId', nullify: true)]
private ?string $imdbId = null,
#[SourceQuery('season', nullify: true)]
private ?string $season = null,
#[SourceQuery('episode', nullify: true)]
private ?string $episode = null,
) {}
public function toCommand(): CommandInterface
{
return new LibrarySearchCommand(
term: $this->term,
title: $this->title,
imdbId: $this->imdbId,
season: $this->season,
episode: $this->episode,
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Library\Action\Result;
use App\Library\Dto\MediaFileDto;
use OneToMany\RichBundle\Contract\ResultInterface;
class LibrarySearchResult implements ResultInterface
{
public function __construct(
public object|array $input,
public bool $exists,
public ?MediaFileDto $file = null,
public ?object $ptn = null,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Library\Dto;
readonly class MediaFileDto
{
public function __construct(
public string $path,
public string $filename,
public string $extension,
public string $size,
) {}
public static function fromSplFileInfo(\SplFileInfo $fileInfo): self
{
return new static(
path: $fileInfo->getRealPath(),
filename: $fileInfo->getFilename(),
extension: $fileInfo->getExtension(),
size: $fileInfo->getSize(),
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Library\Framework\Controller;
use App\Library\Action\Handler\LibrarySearchHandler;
use App\Library\Action\Input\LibrarySearchInput;
use App\Library\Action\Result\LibrarySearchResult;
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 Api extends AbstractController
{
#[Route('/api/library/search', name: 'api.library.search', methods: ['GET'])]
public function search(LibrarySearchInput $input, LibrarySearchHandler $handler, Request $request): Response
{
$result = $handler->handle($input->toCommand());
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($result, $request);
}
return $this->json($handler->handle($input->toCommand()));
}
private function sendFragmentResponse(LibrarySearchResult $result, Request $request): Response
{
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock(
'search/fragments.html.twig',
$request->query->get('block'),
[
'result' => $result,
'target' => $request->query->get('target')
]
);
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Monitor\Framework\Controller;
use App\Base\Util\Broadcaster;
use App\Base\Service\Broadcaster;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;

View File

@@ -2,7 +2,6 @@
namespace App\Search\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Result\GetMediaInfoResult;
use App\Tmdb\Tmdb;
@@ -15,19 +14,12 @@ class GetMediaInfoHandler implements HandlerInterface
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly MediaFiles $mediaFiles
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
if ("tvshows" === $command->mediaType) {
foreach ($media->episodes[$command->season] as $key => $episode) {
$media->episodes[$command->season][$key]['file'] = $this->mediaFiles->episodeExists($media->title, $command->season, $episode['episode_number']);
}
}
return new GetMediaInfoResult($media, $command->season);
}
}

View File

@@ -37,7 +37,8 @@ final class WebController extends AbstractController
public function result(
GetMediaInfoInput $input,
?int $season = null,
): Response {
): Response
{
$result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
@@ -52,32 +53,4 @@ final class WebController extends AbstractController
]
]);
}
private function warmDownloadOptionCache(TmdbResult $result)
{
if ($result->mediaType === 'tvshows') {
// dispatches a job to get the download options
// for each episode and load them in cache
foreach ($result->episodes as $season => $episodes) {
// Only do the first 2 seasons, so we reduce
// getting rate-limited by Torrentio
if ($season > 2) {
return;
}
foreach ($episodes as $episode) {
$this->bus->dispatch(new GetTvShowOptionsCommand(
tmdbId: $result->tmdbId,
imdbId: $result->imdbId,
season: $season,
episode: $episode['episode_number'],
));
}
}
} elseif ($result->mediaType === 'movies') {
$this->bus->dispatch(new GetMovieOptionsCommand(
$result->tmdbId,
$result->imdbId,
));
}
}
}

View File

@@ -301,6 +301,7 @@ class Tmdb
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
episodeAirDate: (new \DateTime($data['release_date']))->format('m/d/Y'),
);
}

View File

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

View File

@@ -2,7 +2,7 @@
namespace App\Torrentio\Result;
use App\Base\Util\CountryLanguages;
use App\User\Database\CountryLanguages;
use Nihilarr\PTN;
class ResultFactory

View File

@@ -3,7 +3,7 @@
namespace App\Twig\Components;
use Aimeos\Map;
use App\Base\Util\QualityList;
use App\User\Database\QualityList;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;

View File

@@ -47,7 +47,7 @@ class UtilExtension
}
#[AsTwigFilter('episode_id_from_results')]
public function episodeId($result): ?string
public function episodeIdFromResults($result): ?string
{
if (!$result instanceof GetTvShowOptionsResult) {
return null;
@@ -56,4 +56,11 @@ class UtilExtension
return "S". str_pad($result->season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($result->episode, 2, "0", STR_PAD_LEFT);
}
#[AsTwigFunction('episode_id')]
public function episodeId($season, $episode): ?string
{
return "S". str_pad($season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($episode, 2, "0", STR_PAD_LEFT);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Base\Util;
namespace App\User\Database;
class CountryCodes
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Base\Util;
namespace App\User\Database;
class CountryLanguages
{
@@ -137,4 +137,13 @@ class CountryLanguages
return $countryLanguages[$countryName] ?? null;
}
public static function asSelectOptions(): array
{
$result = [];
foreach (static::$languages as $language) {
$result[$language] = $language;
}
return $result;
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Base\Util;
namespace App\User\Database;
class ProviderList
{
@@ -23,4 +23,13 @@ class ProviderList
{
return self::$providers;
}
public static function asSelectOptions(): array
{
$result = [];
foreach (static::$providers as $provider) {
$result[$provider] = $provider;
}
return $result;
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Base\Util;
namespace App\User\Database;
class QualityList
{
@@ -100,6 +100,15 @@ class QualityList
return array_search($key, self::$qualities) ?? null;
}
public static function asSelectOptions(): array
{
$result = [];
foreach (array_keys(static::$qualities) as $quality) {
$result[$quality] = $quality;
}
return $result;
}
public static function getAsReverseMap(): array
{
$results = [];

View File

@@ -4,14 +4,16 @@ declare(strict_types=1);
namespace App\User\Framework\Controller\Web;
use App\Base\Util\Broadcaster;
use App\Base\Util\CountryLanguages;
use App\Base\Util\ProviderList;
use App\Base\Util\QualityList;
use App\Base\Service\Broadcaster;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Database\CountryLanguages;
use App\User\Database\ProviderList;
use App\User\Database\QualityList;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Form\GettingStartedFilterForm;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -41,6 +43,7 @@ class PreferencesController extends AbstractController
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
'filterForm' => $this->createForm(GettingStartedFilterForm::class, (array) UserPreferencesFactory::createFromUser($this->getUser())),
]
);
}
@@ -72,6 +75,7 @@ class PreferencesController extends AbstractController
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
'filterForm' => $this->createForm(GettingStartedFilterForm::class ?? null),
]
);
}

View File

@@ -5,30 +5,22 @@ namespace App\User\Framework\Controller\Web;
use App\User\Action\Command\RegisterUserCommand;
use App\User\Action\Handler\RegisterUserHandler;
use App\User\Framework\Entity\User;
use App\User\Framework\Form\GettingStartedFilterForm;
use App\User\Framework\Form\RegistrationFormType;
use App\User\Framework\Pipeline\GettingStarted\AddPreferencesToDatabase;
use App\User\Framework\Pipeline\GettingStarted\GettingStartedInput;
use App\User\Framework\Pipeline\GettingStarted\MigrateDatabase;
use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use League\Pipeline\Pipeline;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
public function __construct(private readonly RegisterUserHandler $registerUserHandler,
private readonly RequestStack $requestStack
)
{
}
private readonly RequestStack $requestStack,
) {}
#[Route('/register', name: 'app_register')]
public function register(
@@ -57,7 +49,7 @@ class RegistrationController extends AbstractController
}
#[Route(path: '/getting-started', name: 'app_getting_started')]
public function gettingStarted(Request $request, Security $security, UserRepository $userRepository, PreferencesRepository $preferencesRepository, KernelInterface $kernel, LoggerInterface $logger): Response
public function gettingStarted(Request $request, Security $security, UserRepository $userRepository): Response
{
if ((new ArrayCollection($userRepository->findAll()))->count() !== 0) {
return $this->redirectToRoute('app_index');
@@ -73,14 +65,42 @@ class RegistrationController extends AbstractController
password: $form->get('plainPassword')->getData(),
));
$security->login($user->user);
$security->login($user->user, 'form_login');
$this->requestStack->getCurrentRequest()->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->redirectToRoute('app_index');
return $this->redirectToRoute('app_getting_started_filter');
}
return $this->render('user/getting-started.html.twig', [
return $this->render('user/getting_started/register-user.html.twig', [
'registrationForm' => $form,
]);
}
#[Route(path: '/getting-started/filter', name: 'app_getting_started_filter')]
public function gettingStartedPreferences(Request $request, UserRepository $userRepository): Response
{
if ((new ArrayCollection($userRepository->findAll()))->count() !== 0) {
return $this->redirectToRoute('app_index');
}
$form = $this->createForm(GettingStartedFilterForm::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
foreach ($form->getData() as $preference => $value) {
if (null !== $value) {
$this->getUser()->updateUserPreference($preference, $value);
}
}
$userRepository->getEntityManager()->flush();
return $this->redirectToRoute('app_index');
}
return $this->render(
'user/getting_started/filter.html.twig',
[
'form' => $form,
]
);
}
}

View File

@@ -3,7 +3,7 @@
namespace App\User\Framework\EventListener;
use App\Base\ConfigResolver;
use App\Base\Util\Broadcaster;
use App\Base\Service\Broadcaster;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;

View File

@@ -0,0 +1,59 @@
<?php
namespace App\User\Framework\Form;
use Aimeos\Map;
use App\User\Database\CountryLanguages;
use App\User\Database\ProviderList;
use App\User\Database\QualityList;
use App\User\Framework\Repository\PreferenceOptionRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class GettingStartedFilterForm extends AbstractType
{
public function __construct(
private readonly PreferenceOptionRepository $preferenceOptionRepository,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addChoiceField($builder, 'language', CountryLanguages::asSelectOptions());
$this->addChoiceField($builder, 'quality', QualityList::asSelectOptions());
$this->addChoiceField($builder, 'provider', ProviderList::asSelectOptions());
$this->addChoiceField($builder, 'resolution', $this->getPreferenceChoices('resolution'));
$this->addChoiceField($builder, 'codec', $this->getPreferenceChoices('codec'));
}
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void
{
$question = [
'attr' => ['class' => 'w-full text-input mb-4'],
'label_attr' => ['class' => 'w-full block font-semibold'],
'choices' => $this->addDefaultChoice($choices),
];
$builder->add($fieldName, ChoiceType::class, $question);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
private function getPreferenceChoices(string $preference): array
{
$options = $this->preferenceOptionRepository->findBy(['preference' => $preference]);
$result = [];
foreach ($options as $item) {
$result[$item->getName()] = $item->getId();
}
return $result;
}
private function addDefaultChoice(array $choices): iterable
{
return ['n/a' => null] + $choices;
}
}

View File

@@ -27,7 +27,7 @@ class RegistrationFormType extends AbstractType
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'min' => 8,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,

View File

@@ -86,6 +86,9 @@
"phpstan.dist.neon"
]
},
"spomky-labs/pwa-bundle": {
"version": "1.2.5"
},
"stof/doctrine-extensions-bundle": {
"version": "1.14",
"recipe": {

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{ pwa() }}
<title>{% block title %}Welcome!{% 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 %}

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{ pwa() }}
<title>{% block title %}Welcome!{% 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 %}

View File

@@ -7,7 +7,7 @@
</a>
{% if download.mediaType == "tvshows" and download.episodeId != null %}
&mdash; <span class="ml-1">(S{{ download.episodeId }})</span>
&mdash; <span class="ml-1">({{ download.episodeId }})</span>
{% endif %}
</td>

View File

@@ -1,7 +1,7 @@
<header {{ attributes }} class="bg-cyan-950 z-40">
<div class="px-4 sm:px-6 lg:px-8">
<div class="h-16 flex flex-row items-center justify-between">
<a href="{{ path('app_index') }}" class="text-2xl text-orange-500 mr-4 md:hidden">T</a>
<a href="{{ path('app_index') }}" class="text-2xl font-extrabold text-orange-500 mr-4 md:hidden">T</a>
<twig:SearchBar />
<div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block">
@@ -32,6 +32,9 @@
{% for message in app.flashes('warning') %}
<twig:Alert :title="'Warning'" :message="message" :alert_id="''" type="warning" data-controller="alert" />
{% endfor %}
{% for message in app.flashes('success') %}
<twig:Alert :title="'Success'" :message="message" :alert_id="''" type="warning" data-controller="alert" />
{% endfor %}
</ul>
</div>
</div>

View File

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

View File

@@ -40,30 +40,17 @@
{{ episode['air_date']|date(null, 'UTC') }}
</small>
{% if episode['file'] != false %}
<span data-controller="popover">
<template data-popover-target="content">
<div data-popover-target="card" class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
<p class="font-bold text-sm text-left">Existing file(s) for this episode:</p>
<ul class="list-disc ml-3">
<li class="font-normal">{{ episode['file'].realPath|strip_media_path }} &mdash; <strong>{{ episode['file'].size|filesize }}</strong></li>
</ul>
</div>
</template>
<small
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
data-action="mouseenter->popover#show mouseleave->popover#hide"
>
exists
</small>
</span>
{% endif %}
{% if episode['file'] == false %}
<twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('api.library.search', {
title: this.title,
season: episode['season_number'],
episode: episode['episode_number'],
block: 'media_exists_badge',
target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode['season_number'], episode['episode_number'])
}) }}">
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
missing
</small>
{% endif %}
</twig:Turbo:Frame>
</div>
</div>
<div class="flex flex-col gap-4 justify-between">
@@ -87,8 +74,15 @@
</button>
</div>
</div>
<div {{ stimulus_target('tv-results', 'listContainer') }} class="inline-block overflow-hidden rounded-lg">
<div class="inline-block overflow-hidden rounded-lg">
<twig:Turbo:Frame id="results_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('app_torrentio_tvshows', {
tmdbId: this.tmdbId,
imdbId: this.imdbId,
season: episode['season_number'],
episode: episode['episode_number'],
target: 'results_' ~ episode_id(episode['season_number'], episode['episode_number']),
block: 'tvshow_results'
}) }}" />
</div>
</div>
</div>

View File

@@ -0,0 +1,32 @@
{% block media_exists_badge %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
{% if result.exists == true %}
<span data-controller="popover">
<template data-popover-target="content">
<div data-popover-target="card"
class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
<p class="font-bold text-sm text-left">Existing file(s) for this media:</p>
<ul class="list-disc ml-3">
<li class="font-normal">{{ result.file.filename|strip_media_path }} &mdash; <strong>{{ result.file.size|filesize }}</strong></li>
</ul>
</div>
</template>
<small
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
data-action="mouseenter->popover#show mouseleave->popover#hide"
>
exists
</small>
</span>
{% endif %}
{% if result.exists == false %}
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white"
title="Media has not been downloaded yet.">
missing
</small>
{% endif %}
</template>
</turbo-stream>
{% endblock %}

View File

@@ -49,9 +49,32 @@
</div>
<p class="text-gray-50">
{{ results.media.description }}
</p>
{% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow">
<span class="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 id="movie_results_count">-</span> results
</span>
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.episodeAirDate|date(null, 'UTC') }}
</small>
<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
}) }}">
<small 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
</small>
</twig:Turbo:Frame>
</div>
{% endif %}
</div>
</div>
@@ -62,6 +85,12 @@
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
data-movie-results-loading-icon-outlet=".loading-icon"
>
<twig:Turbo:Frame id="movie_results_frame" src="{{ path('app_torrentio_movies', {
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
target: 'movie_results_frame',
block: 'movie_results'
}) }}" />
</div>
{% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList

View File

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

View File

@@ -1,20 +0,0 @@
{% extends 'base.html.twig' %}
{% block title %}Hello TorrentioController!{% endblock %}
{% block body %}
<style>
.example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
.example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>
<div class="example-wrapper">
<h1>Hello {{ controller_name }}! ✅</h1>
This friendly message is coming from:
<ul>
<li>Your controller at <code>/var/www/src/Controller/TorrentioController.php</code></li>
<li>Your template at <code>/var/www/templates/torrentio/index.html.twig</code></li>
</ul>
</div>
{% endblock %}

View File

@@ -1,13 +1,4 @@
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
{% if results.file != false %}
<div class="p-3 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
<p class="font-bold text-sm text-left">Existing file(s) for this movie:</p>
<ul class="list-disc ml-3 overflow-scroll">
<li class="font-normal">{{ results.file.realPath|strip_media_path }} &mdash; <strong>{{ results.file.size|filesize }}</strong></li>
</ul>
</div>
{% endif %}
<div class="overflow-hidden rounded-md">
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
</div>

View File

@@ -0,0 +1,20 @@
{% extends 'bare.html.twig' %}
{% block title %}Getting Started &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
<h2 class="text-2xl text-bold text-center text-gray-50">Getting Started</h2>
<p class="mb-1">Now let's create your first Filter.</p>
{# <p class="mb-2 text-sm">Your filter will be pre-applied to your results, so you're only shown what you want to see. Don't worry, though, you can toggle each filter option afterwards, so you can see the rest of the results.</p>#}
{{ form_start(form) }}
{{ form_row(form.language) }}
{{ form_row(form.quality) }}
{{ form_row(form.provider) }}
{{ form_row(form.resolution) }}
{{ form_row(form.codec) }}
<button class="submit-button">Save</button>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %}Getting Started &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
<h2 class="text-2xl text-bold text-center text-gray-50">Getting Started</h2>
<p class="mb-2">Let's get started by creating your first User.</p>

View File

@@ -3,7 +3,7 @@
{% block title %}Log in &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
<h2 class="text-xl font-bold">Login</h2>
<form method="post" class="flex flex-col gap-2">
{% if error %}