Compare commits

...

33 Commits

Author SHA1 Message Date
6858d2d722 fix: cleans up download list search bar 2025-06-07 15:31:22 -05:00
8cd004db4a fix: hides search bar when download list is widget 2025-06-07 14:43:38 -05:00
6cc8985c4d feat: adds search to download list component 2025-06-07 14:25:56 -05:00
9bfd92a011 feat: links to movies/series download results page from download list component 2025-06-07 13:18:20 -05:00
ac6276f444 fix: links to media by imdb id 2025-06-07 10:13:40 -05:00
6d4bbf2e72 fix: shows season/episode in download list 2025-06-07 09:14:47 -05:00
8004de1dc8 fix: undoes last change to Caddyfile 2025-06-06 14:20:46 -05:00
2d9a2a1f14 fix: sets num_threads=20 2025-06-05 23:36:17 -05:00
b6bc1645b4 fix: centers download progress in progress bar 2025-06-03 21:52:49 -05:00
16023f1a26 fix: uses frankingphp:8.4-alpine image locally 2025-06-03 11:15:01 -05:00
725034dd4e fix: shows download progress 2025-06-03 11:14:38 -05:00
234aeab120 fix: monitor row being styled incorrectly on update 2025-06-02 10:42:56 -05:00
044df00982 fix: broad implementation of broadcaster 2025-06-01 20:34:39 -05:00
92015feaac fix: missing monitor alert 2025-06-01 20:14:34 -05:00
5b704d9b36 fix: missing monitor alert 2025-06-01 20:14:25 -05:00
4f72481829 fix: orders monitor desc 2025-06-01 16:16:01 -05:00
4ee338e397 upgrade to Symfony 7.3, style updates 2025-06-01 15:58:04 -05:00
08d28d9a4f fix: splits monitor widget types to complete and active 2025-06-01 14:32:15 -05:00
b17313c8fb chore: adds 'asPagiator' utility function 2025-06-01 14:13:53 -05:00
f2b50b4f60 fix: uses paginate trait on download list 2025-06-01 14:08:29 -05:00
393e3ef41f fix: monitors page & pagination 2025-06-01 14:07:29 -05:00
b1ccf3bf00 feat: delete monitors 2025-06-01 09:25:12 -05:00
785794790c fix: adds delete button to broadcasted element 2025-05-31 22:46:31 -05:00
c0f67a32ff feat: delete downloads from DB 2025-05-31 22:21:11 -05:00
325e4e14a7 fix: removes default movies & tvshows paths from .env 2025-05-31 11:46:36 -05:00
f9017297f3 feat: adds nomad deployment file 2025-05-28 18:59:32 -05:00
0c4def42a2 fix: proper flag passed to worker 2025-05-26 09:51:31 -05:00
3100912927 fix: disabled caddy worker mode 2025-05-26 09:39:17 -05:00
07553a172f fix: sets limits on worker 2025-05-25 23:30:31 -05:00
a88da4f637 fix(local deploy): uncomments line 2025-05-25 22:52:51 -05:00
a88f128ccb fix(local deploy): uses correct Caddyfile 2025-05-25 21:22:07 -05:00
b1ec344f42 fix(local deploy): correct bind mounts on scheduler 2025-05-25 20:50:26 -05:00
1ee6db4668 fix(local deploy): monitor/scheduler container 2025-05-25 20:31:15 -05:00
62 changed files with 1344 additions and 638 deletions

3
.env
View File

@@ -38,6 +38,3 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
REDIS_HOST=redis://redis
MOVIES_PATH=/var/download/movies
TVSHOWS_PATH=/var/download/tvshows

View File

@@ -1,4 +1,4 @@
FROM dunglas/frankenphp
FROM dunglas/frankenphp:php8.4-alpine
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
@@ -11,6 +11,8 @@ RUN install-php-extensions \
zip \
opcache
RUN apk add --no-cache wget
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -29,6 +29,12 @@ export default class extends Controller {
}
}
deleteDownload(data) {
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }

View File

@@ -1,24 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static values = {
title: String,
tmdbId: String,
imdbId: String,
mediaType: String,
}
addMovieMonitor() {
console.log(`/monitor/movies/${this.tmdbIdValue}/${this.imdbIdValue}/${encodeURI(this.titleValue)}`)
fetch(`/monitor/movies/${this.tmdbIdValue}/${this.imdbIdValue}/${encodeURI(this.titleValue)}`)
.then(res => res.json())
.then(json => {
console.log(json)
})
}
}

View File

@@ -0,0 +1,14 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
deleteMonitor(data) {
fetch(`/api/monitor/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 4c-4.41 0-8 3.59-8 8s3.59 8 8 8s8-3.59 8-8s-3.59-8-8-8m5 11.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41L8.41 7L12 10.59L15.59 7L17 8.41L13.41 12z" opacity=".3"/><path fill="currentColor" d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m3.59-13L12 10.59L8.41 7L7 8.41L10.59 12L7 15.59L8.41 17L12 13.41L15.59 17L17 15.59L13.41 12L17 8.41z"/></svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -35,7 +35,16 @@ services:
volumes:
- $PWD:/app
tty: true
command: php /app/bin/console messenger:consume async -vv
command: php /app/bin/console messenger:consume async -vv --time-limit=3600 --limit=10
scheduler:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
command: php /app/bin/console messenger:consume scheduler_monitor -vv
tty: true
redis:

View File

@@ -23,28 +23,28 @@
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/filesystem": "7.2.*",
"symfony/finder": "7.2.*",
"symfony/asset": "7.3.*",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/filesystem": "7.3.*",
"symfony/finder": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/ldap": "7.2.*",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/ldap": "7.3.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/scheduler": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/messenger": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.2.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-icons": "^2.24",
"symfony/ux-live-component": "^2.24",
"symfony/ux-turbo": "^2.24",
"symfony/ux-twig-component": "^2.24",
"symfony/yaml": "7.2.*",
"symfony/yaml": "7.3.*",
"symfonycasts/tailwind-bundle": "^0.10.0",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
@@ -98,13 +98,13 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*"
"require": "7.3.*"
}
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
}
}

769
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@@ -8,10 +8,6 @@ services:
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
deploy:
replicas: 2
placement:
constraints:
- node.hostname != web03
- node.hostname != web03.caldwell.local
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
@@ -27,9 +23,9 @@ services:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
command: -vv --time-limit=3600 --limit=10
deploy:
replicas: 4
replicas: 2
depends_on:
- app
@@ -37,8 +33,10 @@ services:
scheduler:
image: registry.caldwell.digital/home/torsearch-scheduler:${TAG}
volumes:
- ./downloads:/var/download
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
depends_on:
- app

View File

@@ -17,6 +17,6 @@ COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile
ENTRYPOINT [ "/usr/local/bin/docker-entrypoint" ]
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]

View File

@@ -1,4 +1,4 @@
FROM dunglas/frankenphp
FROM dunglas/frankenphp:php8.4-alpine
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
@@ -13,4 +13,6 @@ RUN install-php-extensions \
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "schedule_monitor" ]
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0

View File

@@ -11,6 +11,8 @@ RUN install-php-extensions \
zip \
opcache
RUN apk add --no-cache wget
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]

View File

@@ -3,13 +3,6 @@
frankenphp {
{$FRANKENPHP_CONFIG}
worker {
file ./public/index.php
num 20
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
{$FRANKENPHP_WORKER_CONFIG}
}
}
}

129
nomad.deploy.hcl Normal file
View File

@@ -0,0 +1,129 @@
variable "image_tag" {
type = string
description = "Docker image tag to deploy."
default = "latest"
}
job "torsearch" {
datacenters = [ "home" ]
type = "service"
group "app" {
count = 2
update {
max_parallel = 4
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
network {
port "app" {
to = 80
}
}
task "app" {
driver = "docker"
config {
image = "registry.caldwell.digital/home/torsearch-app:${var.image_tag}"
ports = ["app"]
}
env {
MERCURE_PUBLISHER_JWT_KEY = "!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY = "!ChangeThisMercureHubJWTSecretKey!"
}
service {
name = "torsearch-app"
provider = "nomad"
port = "app"
meta {
nomad_ingress_enabled = true
nomad_ingress_hostname = "torsearch-nomad.caldwell.digital"
}
}
}
}
group "worker" {
count = 2
update {
max_parallel = 4
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
volume "media" {
type = "host"
source = "media"
read_only = false
}
task "worker" {
driver = "docker"
volume_mount {
volume = "media"
destination = "/var/download"
read_only = false
}
config {
image = "registry.caldwell.digital/home/torsearch-worker:${var.image_tag}"
args = [
"-vv"
]
}
service {
name = "torsearch-worker"
provider = "nomad"
}
}
}
group "scheduler" {
count = 1
update {
max_parallel = 2
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
volume "media" {
type = "host"
source = "media"
read_only = false
}
task "scheduler" {
driver = "docker"
volume_mount {
volume = "media"
destination = "/var/download"
read_only = false
}
config {
image = "registry.caldwell.digital/home/torsearch-scheduler:${var.image_tag}"
args = [
"-vv"
]
}
service {
name = "torsearch-scheduler"
provider = "nomad"
}
}
}
}

View File

@@ -2,34 +2,25 @@
namespace App\Controller;
use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
final class AlertController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')] private readonly Environment $renderer,
private readonly HubInterface $hub,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/alert', name: 'app_alert')]
public function index(): Response
{
$update = new Update(
'alerts',
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => 1,
'title' => 'Added to queue',
'message' => 'This is a testy test!',
])
$this->broadcaster->alert(
'Added to queue',
'This is a testy test!'
);
$this->hub->publish($update);
return $this->json([
'Success' => 'Published'
]);

View File

@@ -33,11 +33,12 @@ final class SearchController extends AbstractController
]);
}
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
#[Route('/result/{mediaType}/{imdbId}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $input,
): Response {
$result = $this->getMediaInfoHandler->handle($input->toCommand());
$this->warmDownloadOptionCache($result->media);
return $this->render('search/result.html.twig', [

View File

@@ -6,12 +6,11 @@ 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\Util\Broadcaster;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -21,8 +20,7 @@ final class TorrentioController extends AbstractController
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly HubInterface $hub,
private readonly \Twig\Environment $renderer,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
@@ -75,14 +73,10 @@ final class TorrentioController extends AbstractController
);
$cache->delete($cacheId);
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Torrentio cache Cleared.',
])
));
$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));

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DeleteDownloadCommand>
*/
class DeleteDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\DeleteDownloadCommand;
use App\Download\Action\Result\DeleteDownloadResult;
use App\Download\Framework\Repository\DownloadRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<DeleteDownloadCommand, DeleteDownloadResult> */
readonly class DeleteDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$download = $this->downloadRepository->find($command->downloadId);
$this->downloadRepository->delete($command->downloadId);
return new DeleteDownloadResult(200, 'Success', $download);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\DeleteDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DeleteDownloadInput> */
class DeleteDownloadInput implements InputInterface
{
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
) {}
public function toCommand(): CommandInterface
{
return new DeleteDownloadCommand(
$this->downloadId,
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Download\Action\Result;
use App\Download\Framework\Entity\Download;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<DownloadMediaResult> */
class DeleteDownloadResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
public Download $download,
) {}
}

View File

@@ -2,13 +2,13 @@
namespace App\Download\Framework\Controller;
use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -17,12 +17,11 @@ class ApiController extends AbstractController
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly HubInterface $hub,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
public function download(
Request $request,
DownloadMediaInput $input,
): Response {
$download = $this->downloadRepository->insert(
@@ -45,15 +44,25 @@ class ApiController extends AbstractController
return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderView('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => '"' . $input->title . '" added to Queue',
])
));
$this->broadcaster->alert(
title: 'Success',
message: "$input->title added to Queue."
);
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
}
#[Route('/api/download/{downloadId}', name: 'api_download_delete', methods: ['DELETE'])]
public function deleteDownload(
DeleteDownloadInput $input,
DeleteDownloadHandler $handler,
): Response {
$result = $handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: "{$result->download->getTitle()} has been deleted.",
);
return $this->json(['status' => 200, 'message' => 'Download Deleted']);
}
}

View File

@@ -3,8 +3,11 @@
namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository;
use App\Torrentio\Result\ResultFactory;
use App\Torrentio\Result\TorrentioResult;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Nihilarr\PTN;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
@@ -162,4 +165,16 @@ class Download
return $this;
}
public function getPtn(): object
{
$ptn = (object) (new PTN())->parse($this->filename);
if ($this->mediaType === "tvshows") {
$ptn->season = str_pad($ptn->season, 2, "0", STR_PAD_LEFT);
$ptn->episode = str_pad($ptn->episode, 2, "0", STR_PAD_LEFT);
}
return $ptn;
}
}

View File

@@ -25,27 +25,31 @@ class DownloadRepository extends ServiceEntityRepository
$this->paginator = $paginator;
}
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10)
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10, string $term = ""): Paginator
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->andWhere('(d.title LIKE :term OR d.imdbId LIKE :term)')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete'])
->setParameter('user', $user)
->setParameter('term', '%' . $term . '%')
->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5)
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5, string $term = ""): Paginator
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->andWhere('(d.title LIKE :term OR d.imdbId LIKE :term)')
->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('user', $user)
->setParameter('term', '%' . $term . '%')
->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage);

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<DeleteMonitorCommand> */
class DeleteMonitorCommand implements CommandInterface
{
public function __construct(
public string $monitorId,
) {}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\AddMonitorCommand;
use App\Monitor\Action\Command\DeleteMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult;
use App\Monitor\Action\Result\DeleteMonitorResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<DeleteMonitorCommand, DeleteMonitorResult> */
readonly class DeleteMonitorHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$monitor = $this->monitorRepository->find($command->monitorId);
$this->monitorRepository->getEntityManager()->remove($monitor);
$this->monitorRepository->getEntityManager()->flush();
return new DeleteMonitorResult(
status: 'OK',
result: [],
monitor: $monitor
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Monitor\Action\Input;
use App\Monitor\Action\Command\DeleteMonitorCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DeleteMonitorInput, DeleteMonitorCommand> */
class DeleteMonitorInput implements InputInterface
{
public function __construct(
#[SourceRoute('monitorId')]
public int $monitorId,
) {}
public function toCommand(): CommandInterface
{
return new DeleteMonitorCommand(
$this->monitorId
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Monitor\Action\Result;
use App\Monitor\Framework\Entity\Monitor;
use OneToMany\RichBundle\Contract\ResultInterface;
class DeleteMonitorResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
public Monitor $monitor,
) {}
}

View File

@@ -2,21 +2,23 @@
namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
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\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class ApiController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')]
private readonly Environment $renderer,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])]
@@ -29,12 +31,31 @@ class ApiController extends AbstractController
$command->userId = $this->getUser()->getId();
$response = $handler->handle($command);
$this->broadcaster->alert(
title: 'Success',
message: "New monitor added for {$input->title}",
);
return $this->json([
'status' => 200,
'message' => $response
]);
}
#[Route('/api/monitor/{monitorId}', name: 'api_monitor_delete', methods: ['DELETE'])]
public function deleteMonitor(
DeleteMonitorInput $input,
DeleteMonitorHandler $handler,
HubInterface $hub,
) {
$response = $handler->handle($input->toCommand());
$hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$input->title}",
'message' => "New monitor added for {$response->monitor->getTitle()}",
])
));

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
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\Repository\MonitorRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class WebController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')]
private readonly Environment $renderer,
) {}
#[Route('/monitors', name: 'app_monitors', methods: ['GET'])]
public function addMonitor()
{
return $this->render('monitor/index.html.twig');
}
}

View File

@@ -7,7 +7,9 @@ use App\User\Framework\Entity\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[Broadcast(template: 'broadcast/Monitor.stream.html.twig')]
#[ORM\Entity(repositoryClass: MonitorRepository::class)]
class Monitor
{

View File

@@ -21,7 +21,7 @@ class MonitorRepository extends ServiceEntityRepository
$this->paginator = $paginator;
}
public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage)
public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage): Paginator
{
$query = $this->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')

View File

@@ -8,7 +8,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class GetMediaInfoCommand implements CommandInterface
{
public function __construct(
public string $tmdbId,
public string $imdbId,
public string $mediaType,
) {}
}

View File

@@ -18,7 +18,7 @@ class GetMediaInfoHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->mediaDetails($command->tmdbId, $command->mediaType);
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
return new GetMediaInfoResult($media);
}

View File

@@ -12,8 +12,8 @@ use OneToMany\RichBundle\Contract\InputInterface;
class GetMediaInfoInput implements InputInterface
{
public function __construct(
#[SourceRoute('tmdbId')]
public string $tmdbId,
#[SourceRoute('imdbId')]
public string $imdbId,
#[SourceRoute('mediaType')]
public string $mediaType,
@@ -21,6 +21,6 @@ class GetMediaInfoInput implements InputInterface
public function toCommand(): CommandInterface
{
return new GetMediaInfoCommand($this->tmdbId, $this->mediaType);
return new GetMediaInfoCommand($this->imdbId, $this->mediaType);
}
}

View File

@@ -133,9 +133,12 @@ class Tmdb
if (!$result instanceof Movie && !$result instanceof Tv) {
continue;
}
$results[] = $this->parseResult($result);
}
$results = array_filter($results, fn ($result) => null !== $result->imdbId);
return $results;
}
@@ -143,7 +146,16 @@ class Tmdb
{
$finder = new Find($this->client);
$result = $finder->findBy($id, ['external_source' => 'imdb_id']);
return $this->parseResult($result);
if (count($result['movie_results']) > 0) {
return $result['movie_results'][0]['id'];
} elseif (count($result['tv_results']) > 0) {
return $result['tv_results'][0]['id'];
} elseif (count($result['tv_episode_results']) > 0) {
return $result['tv_episode_results'][0]['show_id'];
}
throw new \Exception("No results found for $id");
}
public function movieDetails(string $id)
@@ -183,6 +195,8 @@ class Tmdb
public function mediaDetails(string $id, string $type)
{
$id = $this->find($id);
if ($type === "movies") {
return $this->movieDetails($id);
} else {

View File

@@ -19,7 +19,7 @@ class GetMovieOptionsHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface
{
return new GetMovieOptionsResult(
media: $this->tmdb->mediaDetails($command->tmdbId, 'movies'),
media: $this->tmdb->mediaDetails($command->imdbId, 'movies'),
results: $this->torrentio->search($command->imdbId, 'movies'),
);
}

View File

@@ -16,15 +16,14 @@ final class DownloadList extends AbstractController
{
use DefaultActionTrait;
use PaginateTrait;
#[LiveProp(writable: true)]
public string $term = "";
#[LiveProp(writable: true)]
public string $type;
#[LiveProp(writable: true)]
public int $pageNumber = 1;
#[LiveProp(writable: true)]
public int $perPage = 5;
#[LiveProp(writable: true)]
public bool $isWidget = true;
@@ -35,17 +34,11 @@ final class DownloadList extends AbstractController
public function getDownloads()
{
if ($this->type === "active") {
return $this->downloadRepository->getActivePaginated($this->getUser(), $this->pageNumber, $this->perPage);
return $this->downloadRepository->getActivePaginated($this->getUser(), $this->pageNumber, $this->perPage, $this->term);
} elseif ($this->type === "complete") {
return $this->downloadRepository->getCompletePaginated($this->getUser(), $this->pageNumber, $this->perPage);
return $this->downloadRepository->getCompletePaginated($this->getUser(), $this->pageNumber, $this->perPage, $this->term);
}
return [];
}
#[LiveAction]
public function paginate(#[LiveArg] int $page)
{
$this->pageNumber = $page;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Twig\Components;
use App\Download\Framework\Entity\Download;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class DownloadListRow
{
public Download $download;
public bool $isWidget = true;
}

View File

@@ -3,14 +3,24 @@
namespace App\Twig\Components;
use App\Monitor\Framework\Repository\MonitorRepository;
use Doctrine\ORM\Query\Expr\OrderBy;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsTwigComponent]
#[AsLiveComponent]
final class MonitorList extends AbstractController
{
use DefaultActionTrait;
use PaginateTrait;
#[LiveProp(writable: true)]
public string $type;
#[LiveProp(writable: true)]
public bool $isWidget = true;
public function __construct(
@@ -18,8 +28,36 @@ final class MonitorList extends AbstractController
) {}
#[LiveAction]
public function getUserMonitors(#[LiveArg] int $page = 1, #[LiveArg] int $perPage = 5)
public function getMonitors()
{
return $this->monitorRepository->getUserMonitorsPaginated($this->getUser(), $page, $perPage);
if ($this->type === "active") {
return $this->getActiveUserMonitors();
} elseif ($this->type === "complete") {
return $this->getCompleteUserMonitors();
}
return [];
}
#[LiveAction]
public function getActiveUserMonitors()
{
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')
->setParameter('statuses', ['New', 'In Progress'])
->orderBy('m.id', 'DESC')
->getQuery()
);
}
#[LiveAction]
public function getCompleteUserMonitors()
{
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status = :status')
->setParameter('status', 'Complete')
->orderBy('m.id', 'DESC')
->getQuery()
);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Twig\Components;
use App\Monitor\Framework\Entity\Monitor;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class MonitorListRow
{
public Monitor $monitor;
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Twig\Components;
use App\Util\Paginator;
use Doctrine\ORM\Query;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
trait PaginateTrait
{
#[LiveProp(writable: true)]
public int $pageNumber = 1;
#[LiveProp(writable: true)]
public int $perPage = 5;
#[LiveAction]
public function paginate(#[LiveArg] int $page)
{
$this->pageNumber = $page;
}
private function asPaginator(Query $query): Paginator
{
return (new Paginator())
->paginate($query, $this->pageNumber, $this->perPage);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Twig\Extensions;
use App\Monitor\Framework\Entity\Monitor;
use Twig\Attribute\AsTwigFilter;
class MonitorExtension
{
#[AsTwigFilter('monitor_type')]
public function type(string $type)
{
$types = [
'tvshows' => 'Show',
'tvseason' => 'Season',
'tvepisode' => 'Episode',
];
return $types[$type] ?? '-';
}
#[AsTwigFilter('monitor_media_id')]
public function mediaId(Monitor $monitor)
{
if ($monitor->getMonitorType() === "tvseason") {
return "S". str_pad($monitor->getSeason(), 2, "0", STR_PAD_LEFT);
}
if ($monitor->getMonitorType() === "tvepisode") {
return "S". str_pad($monitor->getSeason(), 2, "0", STR_PAD_LEFT) .
"E". str_pad($monitor->getEpisode(), 2, "0", STR_PAD_LEFT);
}
return "-";
}
}

View File

@@ -9,23 +9,20 @@ use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Framework\Repository\PreferencesRepository;
use App\Util\Broadcaster;
use App\Util\CountryLanguages;
use App\Util\ProviderList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
class PreferencesController extends AbstractController
{
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
private readonly HubInterface $hub,
private readonly SaveUserDownloadPreferencesHandler $saveUserDownloadPreferencesHandler,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/user/preferences', 'app_user_preferences', methods: ['GET'])]
public function mediaPreferences(): Response
{
@@ -48,25 +45,21 @@ class PreferencesController extends AbstractController
#[Route('/user/preferences/media', 'app_save_media_preferences', methods: ['POST'])]
public function saveMediaPreferences(
Request $request,
SaveUserMediaPreferencesInput $input,
SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
): Response
{
$this->saveUserMediaPreferencesHandler->handle($input->toCommand());
$saveUserMediaPreferencesHandler->handle($input->toCommand());
$mediaPreferences = $this->getUser()->getMediaPreferences();
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$languages = CountryLanguages::$languages;
sort($languages);
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderView('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Your media preferences have been saved.',
])
));
$this->broadcaster->alert(
title: 'Success',
message: 'Your media preferences have been saved.'
);
return $this->render(
'user/preferences.html.twig',
@@ -82,24 +75,20 @@ class PreferencesController extends AbstractController
#[Route('/user/preferences/download', 'app_save_download_preferences', methods: ['POST'])]
public function saveDownloadPreferences(
Request $request,
SaveUserDownloadPreferencesInput $input,
SaveUserDownloadPreferencesHandler $saveUserDownloadPreferencesHandler,
): Response
{
$downloadPreferences = $this->saveUserDownloadPreferencesHandler->handle($input->toCommand())->downloadPreferences;
$downloadPreferences = $saveUserDownloadPreferencesHandler->handle($input->toCommand())->downloadPreferences;
$mediaPreferences = $this->getUser()->getMediaPreferences();
$languages = CountryLanguages::$languages;
sort($languages);
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderView('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Your download preferences have been saved.',
])
));
$this->broadcaster->alert(
title: 'Success',
message: 'Your download preferences have been saved.'
);
return $this->render(
'user/preferences.html.twig',

33
src/Util/Broadcaster.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Util;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Twig\Environment;
readonly class Broadcaster
{
public function __construct(
#[Autowire(service: 'twig')]
private Environment $renderer,
private HubInterface $hub,
private RequestStack $requestStack,
) {}
public function alert(string $title, string $message): void
{
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
])
);
$this->hub->publish($update);
}
}

View File

@@ -2,6 +2,15 @@
"1tomany/rich-bundle": {
"version": "v1.8.3"
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.14",
"recipe": {
@@ -169,6 +178,18 @@
"config/packages/messenger.yaml"
]
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.2",
"recipe": {

View File

@@ -4,6 +4,14 @@ module.exports = {
"./assets/**/*.js",
"./templates/**/*.html.twig",
],
safelist: [
"bg-blue-300",
"bg-orange-300",
"bg-fuchsia-300",
"bg-green-400",
"bg-purple-400",
"bg-orange-400",
],
theme: {
extend: {},
},

View File

@@ -5,36 +5,19 @@
<turbo-stream action="append" target="active_downloads">
<template>
<tr data-download-list-target="download" id="ad_download_{{ entity.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-50">
{% if entity.progress < 100 %}
<div class="w-[3.25ch] h-[3.25ch] bg-purple-600 rounded-full block text-center table-cell align-middle text-xs text-gray-50">
{{ entity.progress }}
</div>
{% else %}
<twig:StatusBadge color="green" status="Complete" />
{% endif %}
</td>
</tr>
<twig:DownloadListRow download="{{ entity }}" />
</template>
</turbo-stream>
{% endblock %}
{% block update %}
{% if entity.status != "Complete" %}
<turbo-stream action="update" target="ad_download_{{ id }}">
<turbo-stream action="update" target="download_progress_{{ id }}">
<template>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<div class="border-2 border-green-700 rounded-md w-full h-6 align-middle overflow-hidden">
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5" style="width:{{ entity.progress }}%">{{ entity.progress }}%</div>
</div>
</td>
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5"
style="width:{{ entity.progress }}%">
<span>{{ entity.progress }}%</span>
</div>
</template>
</turbo-stream>
{% else %}
@@ -52,14 +35,7 @@
<turbo-stream action="prepend" target="complete_downloads">
<template>
<tr id="ad_download_{{ entity.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-50">
<twig:StatusBadge color="green" status="Complete" />
</td>
</tr>
<twig:DownloadListRow download="{{ entity }}" />
</template>
</turbo-stream>
{% endif %}

View File

@@ -0,0 +1,29 @@
{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #}
{% block create %}
<turbo-stream action="remove" target="active_monitors_no_monitors">
</turbo-stream>
<turbo-stream action="prepend" target="monitors">
<template>
<twig:MonitorListRow monitor="{{ entity }}" />
</template>
</turbo-stream>
{% endblock %}
{% block update %}
<turbo-stream action="replace" target="monitor_{{ id }}">
<template>
<twig:MonitorListRow monitor="{{ entity }}" />
</template>
</turbo-stream>
{% endblock %}
{% block remove %}
<turbo-stream action="remove" target="monitor_{{ id }}"></turbo-stream>
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert title="Success" message="Your Monitor for '{{ entity.title }}' has been removed." alert_id="monitor_alert_{{ entity.id }}" data-controller="alert" />
</template>
</turbo-stream>
{% endblock %}

View File

@@ -1,5 +1,12 @@
<div{{ attributes.defaults(stimulus_controller('download_list')) }} class="min-w-48" >
{% set table_body_id = (type == "complete") ? "complete_downloads" : "active_downloads" %}
{% if this.isWidget == false %}
<div class="flex flex-row mb-2 justify-end">
<twig:DownloadSearch search_path="app_search" placeholder="Find one of your downloads..." />
</div>
{% endif %}
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
@@ -23,42 +30,19 @@
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
Progress
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
</th>
</tr>
</thead>
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50">
{% if this.downloads.items|length > 0 %}
{% for download in this.downloads.items %}
<tr id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 {% if this.isWidget == true %}min-w-[45ch] max-w-[45ch]{% endif %} truncate">
{{ download.title }}
</td>
{% if this.isWidget == false %}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.filename }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.mediaType }}
</td>
{% endif %}
<td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %}
<div class="border-2 border-green-700 rounded-md w-full h-6 align-middle overflow-hidden">
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5" style="width:{{ download.progress }}%">{{ download.progress }}%</div>
</div>
{# <div class="w-[3.25ch] h-[3.25ch] bg-purple-600 rounded-full block text-center table-cell align-middle text-xs text-gray-50">#}
{# {{ download.progress }}#}
{# </div>#}
{% else %}
<twig:StatusBadge color="green" status="Completed" />
{% endif %}
</td>
</tr>
<twig:DownloadListRow download="{{ download }}" isWidget="{{ this.isWidget }}" />
{% endfor %}
{% if this.isWidget == true and this.downloads.items|length > this.perPage %}
<tr id="download_view_all">
<td class="py-2 whitespace-nowrap bg-orange-500 uppercase text-sm font-medium text-center text-white truncate" colspan="100%">
<td class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black truncate" colspan="100%">
<a href="{{ path('app_downloads') }}">View All Downloads</a>
</td>
</tr>

View File

@@ -0,0 +1,44 @@
<tr{{ attributes }} id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}"
class="hover:underline mr-1"
>
{{ download.title }}
</a>
{% if download.mediaType == "tvshows" %}
&mdash; <span class="ml-1">(S{{ download.ptn.season }}E{{ download.ptn.episode }})</span>
{% endif %}
</td>
{% if isWidget == false %}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] truncate">
{{ download.filename }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.mediaType }}
</td>
{% endif %}
<td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %}
<div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-full h-6 align-middle overflow-hidden">
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
style="width:{{ download.progress }}%">
</div>
<div class="absolute text-black text-center" style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">{{ download.progress }}%</div>
</div>
{% else %}
<twig:StatusBadge color="green" status="Complete" />
{% endif %}
</td>
<td class="px-6 py-4 flex flex-row align-middle justify-center">
<button {{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}>
<twig:ux:icon
name="ic:twotone-cancel" width="18px"
class="rounded-full align-middle text-red-600"
title="Remove {{ download.title }} from your Download list. This will not delete the file."
/>
</button>
</td>
</tr>

View File

@@ -0,0 +1,29 @@
<div {{ attributes }} class="w-full max-w-sm min-w-[200px]">
<div class="relative">
<form>
<input
data-model="term"
class="w-full bg-transparent
placeholder:text-slate-200 text-gray-50
text-sm border-b border-orange-500 pl-3 pr-28 py-2 transition
duration-300 ease focus:outline-none focus:border-orange-400 hover:border-orange-300
shadow-sm focus:shadow"
placeholder="{{ placeholder ?? 'TV Show, Movie...' }}"
/>
<button
class="absolute top-1 right-1 flex items-center
bg-green-600 py-1 px-2 text-center
text-md text-white transition-all
focus:bg-green-700 active:bg-green-700 hover:bg-green-700
text-white bg-green-600 text-xs
rounded-sm
bg-opacity-80
"
onclick="return false;"
>
Search
</button>
</form>
</div>
</div>

View File

@@ -1,12 +1,15 @@
<div{{ attributes }} class="min-w-48">
<p class="text-white mb-1">The items you're currently monitoring to automatically download.</p>
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-fixed" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<div{{ attributes.defaults(stimulus_controller('monitor_list')) }}>
<table id="monitor_list" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Monitor\\Framework\\Entity\\Monitor') }}>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase min-w-[45ch] max-w-[45ch] truncate">
class="px-6 py-3 text-start text-xs font-medium uppercase truncate">
Title
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
ID
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Search Count
@@ -19,49 +22,32 @@
class="px-6 py-3 text-start text-xs font-medium uppercase">
Last Search Date
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Type
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Status
</th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% if this.userMonitors.items|length > 0 %}
{% for monitor in this.userMonitors.items %}
<tr id="monitor_{{ monitor.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 min-w-[50ch] max-w-[50ch] truncate">
{{ monitor.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.searchCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.createdAt|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.lastSearch|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.status == "New" %}
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}
<twig:StatusBadge color="purple" status="{{ monitor.status }}" />
{% else %}
<twig:StatusBadge color="green" status="{{ monitor.status }}" />
{% endif %}
</td>
</tr>
<tbody id="monitors" class="divide-y divide-gray-50">
{% if this.monitors.items|length > 0 %}
{% for monitor in this.monitors.items %}
<twig:MonitorListRow :monitor="monitor" />
{% endfor %}
{% if this.userMonitors.items|length > 5 %}
{% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all">
<td colspan="5" class="py-2 whitespace-nowrap bg-orange-500 uppercase text-sm font-medium text-center text-white min-w-[50ch] max-w-[50ch] truncate">
<a href="#">View All Monitors</a>
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black min-w-[50ch] max-w-[50ch] truncate">
<a href="{{ path('app_monitors') }}">View All Monitors</a>
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-stone-800" colspan="2">
<tr id="active_monitors_no_monitors">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-stone-800" colspan="100%">
No monitors
</td>
</tr>
@@ -70,8 +56,8 @@
</table>
{% if this.isWidget == false %}
{% if this.userMonitors.items|length > 0 %}
{% set paginator = this.userMonitors %}
{% if this.monitors.items|length > 0 %}
{% set paginator = this.monitors %}
{% include 'partial/paginator.html.twig' %}
{% endif %}
{% endif %}

View File

@@ -0,0 +1,44 @@
<tr{{ attributes }} id="monitor_{{ monitor.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 truncate">
{{ monitor.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor|monitor_media_id }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.searchCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.createdAt|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.lastSearch|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.monitorType == "tvshow" %}
<twig:StatusBadge color="blue" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% elseif monitor.monitorType == "tvseason" %}
<twig:StatusBadge color="orange" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% else %}
<twig:StatusBadge color="fuchsia" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.status == "New" %}
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}
<twig:StatusBadge color="purple" status="{{ monitor.status }}" />
{% else %}
<twig:StatusBadge color="green" status="{{ monitor.status }}" />
{% endif %}
</td>
<td class="px-6 py-4 flex flex-row align-middle justify-center">
<button {{ stimulus_action('monitor_list', 'deleteMonitor', 'click', {id: monitor.id}) }}>
<twig:ux:icon
name="ic:twotone-cancel" width="18px"
class="rounded-full align-middle text-red-600"
title="Remove {{ monitor.title }} from your Monitor list."
/>
</button>
</td>
</tr>

View File

@@ -20,6 +20,15 @@
</a>
</li>
<li>
<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">
Monitors
</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,13 +1,13 @@
<div{{ attributes }}>
<a href="{{ path('app_search_result', {
mediaType: mediaType,
tmdbId: tmdbId
imdbId: imdbId
}) }}">
<img src="{{ image }}" class="w-40 rounded-md" />
</a>
<a href="{{ path('app_search_result', {
mediaType: mediaType,
tmdbId: tmdbId
imdbId: imdbId
}) }}">
<h3 class="text-center text-gray-50 max-w-[16ch] text-extrabold">{{ title }}</h3>
</a>

View File

@@ -17,7 +17,7 @@
</p>
</div>
<a class="h-9 rounded-md py-1 px-2 bg-green-600 text-gray-50"
href="{{ path('app_search_result', {mediaType: mediaType, tmdbId: tmdbId}) }}"
href="{{ path('app_search_result', {mediaType: mediaType, imdbId: imdbId}) }}"
>choose</a>
</div>
</div>

View File

@@ -1,3 +1,8 @@
<span {{ attributes }} class="py-[3px] px-[7px] bg-{{ color|default('green') }}-600 rounded-lg inline-block text-center text-xs text-white">
<span {{ attributes }}
class="py-[3px] px-[7px]
bg-{{ color|default('green') }}-{{ number|default(400) }}
text-{{ text|default('black') }}
rounded-lg inline-block text-center text-xs"
>
{{ status }}
</span>

View File

@@ -16,13 +16,13 @@
</div>
<div class="flex flex-row gap-4">
<twig:Card title="Monitors" class="w-full">
<twig:MonitorList />
<twig:MonitorList :type="'active'" :isWidget="true" />
</twig:Card>
</div>
<div class="flex flex-col gap-4">
<twig:Card title="Popular Movies" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_movies %}
<twig:Poster imdbId=""
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"
title="{{ movie.title }}"
description="{{ movie.description }}"
@@ -34,7 +34,7 @@
</twig:Card>
<twig:Card title="Popular TV Shows" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_tvshows %}
<twig:Poster imdbId=""
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"
title="{{ movie.title }}"
description="{{ movie.description }}"

View File

@@ -0,0 +1,18 @@
{% extends 'base.html.twig' %}
{% block title %}Monitors &mdash; Torsearch{% endblock %}
{% block h2 %}Monitors{% endblock %}
{% block body %}
<div class="p-4">
<twig:Card title="Active Monitors">
<twig:MonitorList :type="'active'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>
<div class="p-4">
<twig:Card title="Complete Monitors">
<twig:MonitorList :type="'complete'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>
{% endblock %}