Compare commits

...

26 Commits

Author SHA1 Message Date
4506306377 fix: styles on monitoring and search buttons 2025-05-09 16:25:35 -05:00
4287b52bd4 fix: a few bugs after moving code to own directory 2025-05-09 16:03:01 -05:00
3724bcbb16 fix: moves monitor logic into own directory 2025-05-09 15:03:42 -05:00
6c2cd7510f fix: calls clearCache phing target 2025-05-09 12:33:32 -05:00
98bf8d2880 fix: uses default image in episode results if image is missing, reduces cache life to next hour, clears cache during build 2025-05-09 12:30:56 -05:00
20ade478b1 feat: adds episode air date to results 2025-05-08 23:47:08 -05:00
4eed5fef78 feat: deploys monitor container 2025-05-08 22:57:51 -05:00
5ff9842eaa wip-feat: adds functionality to Monitor button 2025-05-08 22:48:25 -05:00
b93da3df1d fix: MonitorDispatcher runs evey 10 mins 2025-05-07 22:41:19 -05:00
fe0ab2ef5a fix: missing status check in query 2025-05-07 22:40:19 -05:00
25ff3e726d wip-feat: working tv season/episode monitor 2025-05-07 22:13:38 -05:00
527adb73c1 wip-feat: dispatches monitor commands for episodes, seasons, & shows 2025-05-06 00:00:45 -05:00
9166b4bbc8 feat: movie monitoring 2025-05-03 23:55:31 -05:00
5688b3a0df feat: button to add movie monitor 2025-05-03 11:53:23 -05:00
babcb00440 feat: movie download monitor 2025-05-03 09:34:40 -05:00
993b34d668 patch: login/register styles 2025-05-01 23:09:18 -05:00
73b3e5179a patch: active/inactive styles on navbar 2025-05-01 23:01:15 -05:00
d3176baff2 patch: fixes missing null check 2025-05-01 22:35:54 -05:00
cc77cccf0b patch: copies .env.properties instead of passing each phing var 2025-05-01 22:04:24 -05:00
b0c10a028a patch: caches torrentio movie results 2025-05-01 21:58:02 -05:00
12bf90a2b4 patch: adds full page caching to TMBD & torrentio results 2025-05-01 21:46:14 -05:00
687b5ed873 Merge branch 'main' into dev-redis 2025-05-01 20:41:26 -05:00
e5f0f358b7 fix: adds redis phing var 2025-05-01 20:21:30 -05:00
fd84648100 patch: sets default download progress to 0, orders active downloads ASC 2025-05-01 16:37:08 -05:00
f3285ba60c patch: fixes extra ajax call on movie options page 2025-05-01 16:35:12 -05:00
4f6f8f43f1 wip: redis integration 2025-05-01 16:34:30 -05:00
70 changed files with 2512 additions and 396 deletions

View File

@@ -5,3 +5,4 @@ DOWNLOAD_DIR=./movies
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://dev.caldwell.digital/hub/.well-known/mercure
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
MONITOR_FREQUENCY="* * * * *"

View File

@@ -1,3 +1,4 @@
APP_ENV=%%app_env%%
APP_SECRET="%%app_secret%%"
DATABASE_URL="%%db_url%%"
DOWNLOAD_DIR=%%download_dir%%
@@ -8,3 +9,4 @@ MERCURE_PUBLIC_URL=%%mercure_public_url%%
MERCURE_JWT_SECRET="%%mercure_jwt_secret%%"
JELLYFIN_URL=%%jellyfin_url%%
JELLYFIN_TOKEN=%%jellyfin_token%%
REDIS_HOST="%%redis_host%%"

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###

View File

@@ -0,0 +1,93 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['button', 'options']
static outlets = ['result-filter']
static values = {
tmdbId: String,
imdbId: String,
title: String,
}
initialize() {
// Called once when the controller is first instantiated (per element)
// Here you can initialize variables, create scoped callables for event
// listeners, instantiate external libraries, etc.
// this._fooBar = this.fooBar.bind(this)
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
toggle() {
this.optionsTarget.classList.toggle('hidden');
}
async monitorSeries() {
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvshows',
});
}
async monitorSeason() {
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvseason',
season: this.resultFilterOutlet.activeFilter['season'],
});
}
async monitorEpisode() {
// ToDo: figure out how to set episode
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvepisode',
season: this.resultFilterOutlet.activeFilter['season'],
episode: '',
});
}
async makeMonitor(body) {
const response = await fetch('/api/monitor', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(body)
});
return await response.json();
}
}

View File

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

@@ -18,13 +18,15 @@ export default class extends Controller {
static targets = ['list']
options = []
optionsLoaded = false
async connect() {
await this.setOptions();
}
async setOptions() {
if (this.options.length === 0) {
if (false === this.optionsLoaded) {
this.optionsLoaded = true;
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
.then(res => res.text())
.then(response => {

View File

@@ -0,0 +1,25 @@
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 {
inactiveStyles = "block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-orange-500 hover:bg-opacity-80";
activeStyles = "block rounded-lg bg-orange-500 hover:bg-opacity-70 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-80 px-4 py-2 text-sm font-medium text-gray-50";
connect() {
console.log(window.location.pathname);
this.element.querySelectorAll('a:not(.nav-foot)').forEach(link => {
link.className = this.inactiveStyles;
if (window.location.pathname === (new URL(link.href)).pathname || window.location.pathname.startsWith( (new URL(link.href)).pathname + '/' ) ) {
link.className = this.activeStyles;
}
});
}
setActive() {
}
}

View File

@@ -47,6 +47,13 @@ export default class extends Controller {
}
}
//
// async clearCache() {
// await fetch(`/torrentio/tvshows/clear/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
// .then(res => res.text())
// .then(response => {});
// }
async setActive() {
this.activeValue = true;
this.element.classList.remove('hidden');

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M17.201 2H6.8c-1.458 0-2.737.985-2.795 2.404c-.074 1.785 1.182 2.97 2.5 4.083c1.825 1.54 2.737 2.31 2.832 3.284q.023.229 0 .458c-.095.975-1.007 1.744-2.832 3.284c-1.355 1.143-2.578 2.207-2.5 4.083C4.062 21.016 5.34 22 6.799 22H17.2c1.458 0 2.737-.985 2.796-2.404c.046-1.13-.373-2.254-1.262-3.036c-.405-.357-.826-.698-1.24-1.047c-1.824-1.54-2.736-2.31-2.831-3.284a2.3 2.3 0 0 1 0-.458c.095-.975 1.008-1.744 2.832-3.284c1.34-1.131 2.577-2.229 2.5-4.083C19.939 2.984 18.66 2 17.202 2"/><path d="M9 21.638c0-.442 0-.663.088-.856a1 1 0 0 1 .046-.09c.107-.183.288-.312.65-.571c1.006-.719 1.51-1.078 2.081-1.116q.135-.009.27 0c.572.038 1.075.397 2.08 1.116c.363.259.544.388.651.571q.026.045.046.09c.088.193.088.414.088.856V22H9z"/></g></svg>

After

Width:  |  Height:  |  Size: 928 B

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project name="Caldwell Digital Symfony Template" default="build">
<!-- build dev for dev envs -->
<target name="build" depends="setEnv,composer,compileAssets,migrateDb" />
<target name="build" depends="setEnv,composer,compileAssets,migrateDb,clearCache" />
<target name="composer" description="Run composer">
<exec executable="composer">
@@ -9,6 +9,11 @@
</exec>
</target>
<target name="setEnv" description="Set the database configuration">
<copy file="${project.basedir}/.env.properties" tofile="${project.basedir}/.env.local" overwrite="true">
</copy>
</target>
<target name="compileAssets" description="Run composer">
<exec executable="php">
<arg value="bin/console" />
@@ -20,25 +25,6 @@
</exec>
</target>
<target name="setEnv" description="Set the database configuration">
<copy file="${project.basedir}/.env.dist" tofile="${project.basedir}/.env.local" overwrite="true">
<filterchain>
<replacetokens begintoken="%%" endtoken="%%">
<token key="app_secret" value="${APP_SECRET}" />
<token key="db_url" value="${DATABASE_URL}" />
<token key="download_dir" value="${DOWNLOAD_DIR}" />
<token key="rd_key" value="${REAL_DEBRID_KEY}" />
<token key="tmdb_api" value="${TMDB_API}" />
<token key="mercure_url" value="${MERCURE_URL}" />
<token key="mercure_public_url" value="${MERCURE_PUBLIC_URL}" />
<token key="mercure_jwt_secret" value="${MERCURE_JWT_SECRET}" />
<token key="jellyfin_url" value="${JELLYFIN_URL}" />
<token key="jellyfin_token" value="${JELLYFIN_TOKEN}" />
</replacetokens>
</filterchain>
</copy>
</target>
<target name="migrateDb" description="Migrate the database">
<exec executable="php">
<arg value="bin/console" />
@@ -46,4 +32,12 @@
<arg value="doctrine:migrations:migrate" />
</exec>
</target>
<target name="clearCache" description="Clear the application cache">
<exec executable="php">
<arg value="bin/console" />
<arg value="cache:pool:clear" />
<arg value="cache.app" />
</exec>
</target>
</project>

View File

@@ -12,6 +12,13 @@ services:
- $PWD/bash/caddy:/etc/caddy
- $PWD/bash/certs:/etc/ssl
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
php:
build: .
volumes:
@@ -22,7 +29,14 @@ services:
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
command: php ./bin/console messenger:consume async -vv --time-limit=3600
scheduler:
build: .
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
mercure:
image: dunglas/mercure
@@ -63,3 +77,4 @@ volumes:
mysql:
mercure_data:
mercure_config:
redis_data:

View File

@@ -13,20 +13,26 @@
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4",
"nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"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/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/scheduler": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.2.*",
@@ -92,6 +98,7 @@
}
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"

926
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,8 @@ framework:
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_HOST)%'
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu

View File

@@ -30,6 +30,12 @@ doctrine:
dir: '%kernel.project_dir%/src/User/Framework/Entity'
prefix: 'App\User\Framework\Entity'
alias: User
Monitor:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Monitor/Framework/Entity'
prefix: 'App\Monitor\Framework\Entity'
alias: Download
controller_resolver:
auto_mapping: false

View File

@@ -2,6 +2,10 @@
framework:
secret: '%env(APP_SECRET)%'
serializer:
default_context:
enable_max_depth: true
trusted_proxies: 'private_ranges'
# trust *all* "X-Forwarded-*" headers
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]

View File

@@ -1,11 +1,19 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async: '%env(MESSENGER_TRANSPORT_DSN)%'
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 1
multiplier: 1
failed: 'doctrine://default?queue_name=failed'
default_bus: messenger.bus.default
@@ -17,6 +25,10 @@ framework:
# Route your messages to the transports
# 'App\Message\YourMessage': async
'App\Download\Action\Command\DownloadMediaCommand': async
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async
'App\Monitor\Action\Command\MonitorMovieCommand': async
# when@test:
# framework:

View File

@@ -0,0 +1,7 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
providers:

View File

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

View File

@@ -4,6 +4,10 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
media.default_movies_dir: movies
media.default_tvshows_dir: tvshows
media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%'
services:
# default configuration for services in *this* file

View File

@@ -14,6 +14,12 @@ services:
deploy:
replicas: 2
scheduler:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
mercure:
image: dunglas/mercure
restart: unless-stopped

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250503034641 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE movie_monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) DEFAULT NULL, imdb_id VARCHAR(255) NOT NULL, tmdb_id VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_C183DBABA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor ADD CONSTRAINT FK_C183DBABA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor DROP FOREIGN KEY FK_C183DBABA76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE movie_monitor
SQL);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250505211458 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) DEFAULT NULL, imdb_id VARCHAR(255) NOT NULL, tmdb_id VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', monitor_type VARCHAR(255) NOT NULL, season INT DEFAULT NULL, episode INT DEFAULT NULL, INDEX IDX_E1159985A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD CONSTRAINT FK_E1159985A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor DROP FOREIGN KEY FK_C183DBABA76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE movie_monitor
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE movie_monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tmdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, status VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_C183DBABA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor ADD CONSTRAINT FK_C183DBABA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP FOREIGN KEY FK_E1159985A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE monitor
SQL);
}
}

8
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,8 @@
parameters:
level: 6
paths:
- bin/
- config/
- public/
- src/
- tests/

View File

@@ -6,9 +6,12 @@ use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class SearchController extends AbstractController
{
@@ -30,20 +33,25 @@ final class SearchController extends AbstractController
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $getDownloadOptionsInput,
GetMediaInfoInput $input,
CacheInterface $cache
): Response {
$result = $this->getMediaInfoHandler->handle($getDownloadOptionsInput->toCommand());
$cacheId = sprintf("page.%s.%s", $input->mediaType, $input->tmdbId);
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [
'resolution' => '',
'codec' => '',
'provider' => '',
'language' => '',
'season' => 1,
'episode' => ''
]
]);
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [
'resolution' => '',
'codec' => '',
'provider' => '',
'language' => '',
'season' => 1,
'episode' => ''
]
]);
// });
}
}

View File

@@ -6,34 +6,89 @@ 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 Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
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;
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,
) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input): Response
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
{
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
$cacheId = sprintf(
"page.torrentio.movies.%s.%s",
$input->tmdbId,
$input->imdbId
);
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
});
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input): Response
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
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,
]);
});
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
$cache->delete($cacheId);
$this->hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Torrentio cache Cleared.',
])
));
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Download\Framework\MessageHandler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Handler\DownloadMediaHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(handles: DownloadMediaCommand::class)]
class DownloadMediaMessageHandler
{
public function __construct(
private DownloadMediaHandler $downloadMediaHandler,
) {}
public function __invoke(DownloadMediaCommand $command)
{
$this->downloadMediaHandler->handle($command);
}
}

View File

@@ -41,7 +41,7 @@ class DownloadRepository extends ServiceEntityRepository
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->orderBy('d.id', 'DESC')
->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
@@ -66,6 +66,7 @@ class DownloadRepository extends ServiceEntityRepository
->setImdbId($imdbId)
->setMediaType($mediaType)
->setBatchId($batchId)
->setProgress(0)
->setStatus($status);
$this->getEntityManager()->persist($download);

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class AddMonitorCommand implements CommandInterface
{
public function __construct(
public string $userEmail,
public string $title,
public string $imdbId,
public string $tmdbId,
public string $monitorType,
public ?int $season,
public ?int $episode,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorMovieCommand implements CommandInterface
{
public function __construct(
public int $movieMonitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvEpisodeCommand implements CommandInterface
{
public function __construct(
public int $movieMonitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvSeasonCommand implements CommandInterface
{
public function __construct(
public int $monitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvShowCommand implements CommandInterface
{
public function __construct(
public int $monitorId,
) {}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\AddMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult;
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<AddMonitorCommand> */
readonly class AddMonitorHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $movieMonitorRepository,
private UserRepository $userRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$user = $this->userRepository->findOneBy(['email' => $command->userEmail]);
$monitor = (new Monitor())
->setUser($user)
->setTmdbId($command->tmdbId)
->setImdbId($command->imdbId)
->setTitle($command->title)
->setMonitorType($command->monitorType)
->setSeason($command->season)
->setEpisode($command->episode)
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->movieMonitorRepository->getEntityManager()->persist($monitor);
$this->movieMonitorRepository->getEntityManager()->flush();
return new AddMonitorResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorMovieHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $movieMonitorRepository,
private GetMovieOptionsHandler $getMovieOptionsHandler,
private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler');
$monitor = $this->movieMonitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
$this->logger->info('> [MonitorMovieHandler] Searching for "' . $monitor->getTitle() . '" download options');
$results = $this->getMovieOptionsHandler->handle(
new GetMovieOptionsCommand($monitor->getTmdbId(), $monitor->getImdbId())
);
$this->logger->info('> [MonitorMovieHandler] Found ' . count($results->results) . ' download options');
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
if (null !== $result) {
$this->logger->info('> [MonitorMovieHandler] 1 result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand(
$result->url,
$monitor->getTitle(),
$result->filename,
'movies',
$monitor->getImdbId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeIMmutable());
} else {
$monitor->setStatus('Active');
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvEpisodeHandler implements HandlerInterface
{
public function __construct(
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler');
$monitor = $this->monitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$this->logger->info('> [MonitorTvEpisodeHandler] Searching for "' . $monitor->getTitle() . '" season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' download options');
$results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$monitor->getTmdbId(),
$monitor->getImdbId(),
$monitor->getSeason(),
$monitor->getEpisode()
)
);
$this->logger->info('> [MonitorTvEpisodeHandler] Found ' . count($results->results) . ' download options');
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
if (null !== $result) {
$this->logger->info('> [MonitorTvEpisodeHandler] 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand(
$result->url,
$monitor->getTitle(),
$result->filename,
'tvshows',
$monitor->getImdbId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable());
} else {
$this->logger->info('> [MonitorTvEpisodeHandler] 0 matching results found, monitor will run at next interval');
$monitor->setStatus('Active');
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Monitor\Action\Handler;
use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvSeasonHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Tmdb $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvSeasonHandler] Executing MonitorTvSeasonHandler');
$monitor = $this->monitorRepository->find($command->monitorId);
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode))
->rekey(fn($episode) => $episode->episode);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB
$episodesInSeason = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId())
->episodes[$monitor->getSeason()]
)->rekey(fn($episode) => $episode['episode_number']);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($episodesInSeason) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
// Dispatch Episode commands for each missing Episode
foreach ($episodesInSeason as $episode) {
$monitorCheck = $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $monitor->getSeason(),
'episode' => $episode['episode_number'],
'status' => ['New', 'Active', 'In Progress']
]);
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for season ' . $monitor->getSeason() . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (null !== $monitorCheck ? 'YES' : 'NO'));
if (!array_key_exists($episode['episode_number'], $downloadedEpisodes->toArray())
&& null === $monitorCheck
) {
$episodeMonitor = (new Monitor())
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvepisode')
->setSeason($monitor->getSeason())
->setEpisode($episode['episode_number'])
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($episodeMonitor);
$this->monitorRepository->getEntityManager()->flush();
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvSeasonHandler] Dispatching MonitorTvEpisodeCommand for season ' . $episodeMonitor->getSeason() . ' episode ' . $episodeMonitor->getEpisode() . ' for title: ' . $monitor->getTitle());
}
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Monitor\Action\Handler;
use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvShowHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Tmdb $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
$monitor = $this->monitorRepository->find($command->monitorId);
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode));
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB
$episodesInShow = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId())
->episodes
)->flat(1);
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
// Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
$episodeAlreadyDownloaded = $downloadedEpisodes->find(
fn($ep) => $ep->episode === $episode['episode_number'] && $ep->season === $episode['season_number']
);
$episodeAlreadyDownloaded = !is_null($episodeAlreadyDownloaded);
if (false === $episodeAlreadyDownloaded) {
$monitor = (new Monitor())
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvshow')
->setSeason($episode['season_number'])
->setEpisode($episode['episode_number'])
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($monitor);
$this->monitorRepository->getEntityManager()->flush();
$command = new MonitorTvEpisodeCommand($monitor->getId());
$this->bus->dispatch($command);
$this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' for title: ' . $monitor->getTitle());
}
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush();
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Monitor\Action\Input;
use App\Monitor\Action\Command\AddMonitorCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
class AddMonitorInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public string $userEmail,
#[SourceRequest('tmdbId')]
public string $tmdbId,
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('title')]
public string $title,
#[SourceRequest('monitorType')]
public string $monitorType,
#[SourceRequest('season', nullify: true)]
public ?int $season,
#[SourceRequest('episode', nullify: true)]
public ?int $episode,
) {}
public function toCommand(): CommandInterface
{
return new AddMonitorCommand(
$this->userEmail,
$this->title,
$this->imdbId,
$this->tmdbId,
$this->monitorType,
$this->season,
$this->episode,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Monitor\Framework\Controller;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
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 HubInterface $hub,
) {}
#[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])]
public function addMonitor(
AddMonitorInput $input,
AddMonitorHandler $handler,
HubInterface $hub,
) {
$response = $handler->handle($input->toCommand());
$hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$input->title}",
])
));
return $this->json([
'status' => 200,
'message' => $response
]);
}
}

View File

View File

@@ -0,0 +1,205 @@
<?php
namespace App\Monitor\Framework\Entity;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore;
#[ORM\Entity(repositoryClass: MonitorRepository::class)]
class Monitor
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'yes')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 255)]
private ?string $imdbId = null;
#[ORM\Column(length: 255)]
private ?string $tmdbId = null;
#[ORM\Column(length: 255)]
private ?string $monitorType = null;
#[ORM\Column(nullable: true)]
private ?int $season = null;
#[ORM\Column(nullable: true)]
private ?int $episode = null;
#[ORM\Column(length: 255)]
private ?string $status = null;
#[ORM\Column(nullable: true)]
private ?int $searchCount = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastSearch = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $downloadedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getImdbId(): ?string
{
return $this->imdbId;
}
public function setImdbId(string $imdbId): static
{
$this->imdbId = $imdbId;
return $this;
}
public function getTmdbId(): ?string
{
return $this->tmdbId;
}
public function setTmdbId(string $tmdbId): static
{
$this->tmdbId = $tmdbId;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getSearchCount(): ?int
{
return $this->searchCount;
}
public function setSearchCount(?int $searchCount): static
{
$this->searchCount = $searchCount;
return $this;
}
public function getLastSearch(): ?\DateTimeInterface
{
return $this->lastSearch;
}
public function setLastSearch(?\DateTimeInterface $lastSearch): static
{
$this->lastSearch = $lastSearch;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getDownloadedAt(): ?\DateTimeImmutable
{
return $this->downloadedAt;
}
public function setDownloadedAt(?\DateTimeImmutable $downloadedAt): static
{
$this->downloadedAt = $downloadedAt;
return $this;
}
public function getMonitorType(): ?string
{
return $this->monitorType;
}
public function setMonitorType(string $monitorType): static
{
$this->monitorType = $monitorType;
return $this;
}
public function getSeason(): ?int
{
return $this->season;
}
public function setSeason(?int $season): static
{
$this->season = $season;
return $this;
}
public function getEpisode(): ?int
{
return $this->episode;
}
public function setEpisode(?int $episode): static
{
$this->episode = $episode;
return $this;
}
}

View File

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Monitor\Framework\Repository;
use App\Monitor\Framework\Entity\Monitor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Monitor>
*/
class MonitorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Monitor::class);
}
// /**
// * @return MovieMonitor[] Returns an array of MovieMonitor objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?MovieMonitor
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Monitor\Framework\Scheduler;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Framework\Repository\MonitorRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('*/10 * * * *', schedule: 'monitor')]
class MonitorDispatcher
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly MonitorRepository $monitorRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke() {
$this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher');
$monitorHandlers = [
'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class,
'tvseason' => MonitorTvSeasonCommand::class,
'tvshow' => MonitorTvShowCommand::class,
];
$monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]);
foreach ($monitors as $monitor) {
$monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$command = $monitorHandlers[$monitor->getMonitorType()];
$this->logger->info('[MonitorDispatcher] Dispatching ' . $command . ' for ' . $monitor->getTitle());
$this->bus->dispatch(new $command($monitor->getId()));
}
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Monitor\Service;
use Aimeos\Map;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
class MediaFiles
{
private Finder $finder;
private string $moviesPath;
private string $tvShowsPath;
private Filesystem $filesystem;
public function __construct(
#[Autowire(param: 'media.movies_path')]
string $moviesPath,
#[Autowire(param: 'media.tvshows_path')]
string $tvShowsPath,
Filesystem $filesystem,
) {
$this->finder = new Finder();
$this->moviesPath = $moviesPath;
$this->tvShowsPath = $tvShowsPath;
$this->filesystem = $filesystem;
}
public function getMoviesPath(): string
{
return $this->moviesPath;
}
public function getTvShowsPath(): string
{
return $this->tvShowsPath;
}
public function getMovieDirs(): Map
{
$results = [];
foreach ($this->finder->in($this->moviesPath)->directories() as $file) {
$results[] = $file;
}
return Map::from($results);
}
public function getTvShowDirs(): Map
{
$results = [];
foreach ($this->finder->in($this->tvShowsPath)->directories() as $file) {
$results[] = $file;
}
return Map::from($results);
}
public function getEpisodes(string $path, bool $onlyFilenames = true): Map
{
if (!str_starts_with($path, $this->tvShowsPath)) {
$path = $this->tvShowsPath . DIRECTORY_SEPARATOR . $path;
}
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
$results = [];
foreach ($this->finder->in($path)->files() as $file) {
if ($onlyFilenames) {
$results[] = $file->getRelativePathname();
} else {
$results[] = $file;
}
}
return Map::from($results);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Monitor\Service;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\Torrentio\Result\TorrentioResult;
class MonitorOptionEvaluator
{
/**
* @param Monitor $monitor
* @param TorrentioResult[] $results
* @return TorrentioResult|null
* @throws \Throwable
*/
public function evaluateOptions(Monitor $monitor, array $results): ?TorrentioResult
{
$sizeLow = 000;
$sizeHigh = 4096;
$bestMatches = [];
$matches = [];
$userPreferences = $monitor->getUser()->getUserPreferenceValues();
foreach ($results as $result) {
if (!in_array($userPreferences['language'], $result->languages)) {
continue;
}
if ($result->resolution === $userPreferences['resolution']
&& $result->codec === $userPreferences['codec']
) {
$bestMatches[] = $result;
}
if ($userPreferences['resolution'] === '2160p'
&& $userPreferences['codec'] === $result->codec
&& $result->resolution === '1080p'
) {
$matches[] = $result;
}
if ($userPreferences['codec'] === 'h264'
&& $userPreferences['resolution'] === $result->resolution
&& $result->codec === 'h265'
) {
$matches[] = $result;
}
if (($userPreferences['codec'] === null )
&& ($userPreferences['resolution'] === null )) {
$matches[] = $result;
}
}
$sizeMatches = [];
foreach ($bestMatches as $result) {
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
$sizeMatches[] = $result;
}
}
if (!empty($sizeMatches)) {
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}
foreach ($matches as $result) {
$size = 0;
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
$sizeMatches[] = $result;
}
}
if (!empty($sizeMatches)) {
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}
return null;
}
}

29
src/Schedule.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App;
use App\Download\Framework\Scheduler\MonitorDispatcher;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
) {
}
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache) // ensure missed tasks are executed
->processOnlyLastMissedRun(true) // ensure only last missed task is run
// add your own tasks here
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Tmdb;
use App\Enum\MediaType;
use App\Tmdb\TmdbResult;
use App\ValueObject\ResultFactory;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -18,12 +17,8 @@ use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\Listener\RequestListener;
use Tmdb\Event\RequestEvent;
use Tmdb\Model\AbstractModel;
use Tmdb\Model\Image;
use Tmdb\Model\Movie;
use Tmdb\Model\Movie\QueryParameter\AppendToResponse;
use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery;
use Tmdb\Model\Search\SearchQuery\MovieSearchQuery;
use Tmdb\Model\Tv;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
@@ -210,55 +205,60 @@ class Tmdb
throw new \Exception("A media type must be set when parsing from an array.");
}
function parseTvShow(array $data, string $posterBasePath): TmdbResult {
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: $posterBasePath . $data['poster_path'],
description: $data['overview'],
year: (new \DateTime($data['first_air_date']))->format('Y'),
mediaType: "tvshows",
episodes: $data['episodes'],
);
}
function parseEpisode(array $data, string $posterBasePath): TmdbResult {
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: $posterBasePath . $data['still_path'],
description: $data['overview'],
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
);
}
function parseMovie(array $data, string $posterBasePath): TmdbResult {
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['title'],
poster: $posterBasePath . $data['poster_path'],
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
);
}
if ($mediaType === 'movie') {
$result = parseMovie($data, self::POSTER_IMG_PATH);
$result = $this->parseMovie($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'tvshow') {
$result = parseTvShow($data, self::POSTER_IMG_PATH);
$result = $this->parseTvShow($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'episode') {
$result = parseEpisode($data, self::POSTER_IMG_PATH);
$result = $this->parseEpisode($data, self::POSTER_IMG_PATH);
}
return $result;
}
private function parseTvShow(array $data, string $posterBasePath): TmdbResult
{
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['first_air_date']))->format('Y'),
mediaType: "tvshows",
episodes: $data['episodes'],
);
}
private function parseEpisode(array $data, string $posterBasePath): TmdbResult
{
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: (null !== $data['still_path']) ? $posterBasePath . $data['still_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
episodeAirDate: (new \DateTime($data['air_date']))->format('m/d/Y'),
);
}
private function parseMovie(array $data, string $posterBasePath): TmdbResult
{
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['title'],
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
);
}
private function parseFromObject($result): TmdbResult
{
$mediaType = $result instanceof Movie ? MediaType::Movie->value : MediaType::TvShow->value;

View File

@@ -13,5 +13,6 @@ class TmdbResult
public ?string $year = "",
public ?string $mediaType = "",
public ?array $episodes = null,
public ?string $episodeAirDate = null,
) {}
}

View File

@@ -6,6 +6,7 @@ use App\Torrentio\Client\Rule\DownloadOptionFilter\Resolution;
use App\Torrentio\Client\Rule\RuleEngine;
use App\Torrentio\MediaResult;
use App\Torrentio\Result\ResultFactory;
use Carbon\Carbon;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -32,7 +33,7 @@ class Torrentio
$cacheKey = "torrentio.{$imdbCode}";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode) {
$item->expiresAt(new \DateTimeImmutable("today 11:59 pm"));
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$response = file_get_contents(str_replace('{imdbCode}', $imdbCode, $this->searchUrl));
return json_decode(
$response,
@@ -67,7 +68,7 @@ class Torrentio
{
$cacheKey = "torrentio.$imdbId.$season.$episode";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
$item->expiresAt(new \DateTimeImmutable("today 11:59 pm"));
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$response = file_get_contents(str_replace('{imdbCode}', "$imdbId:$season:$episode", $this->searchUrl));
return json_decode(
$response,

View File

@@ -8,6 +8,14 @@ use Nihilarr\PTN;
class ResultFactory
{
public static $codecMap = [
'h264' => 'h264',
'h265' => 'h265',
'x264' => 'h264',
'x265' => 'h265',
'-' => '-'
];
public static function map(
string $url,
string $title,
@@ -25,7 +33,7 @@ class ResultFactory
$ptn->season ?? "-",
$bingeGroup,
$ptn->resolution ?? "-",
$ptn->codec ?? "-",
self::setCodec($ptn->codec ?? "-"),
$ptn,
substr(base64_encode($url), strlen($url) - 10),
$ptn->episode ?? "-",
@@ -101,6 +109,11 @@ class ResultFactory
}
}
public static function setCodec(string $codec): string
{
return self::$codecMap[strtolower($codec)] ?? $codec;
}
private static function setEpisode(string $title)
{
$value = [];

View File

@@ -3,7 +3,7 @@
namespace App\User\Framework\Entity;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use App\Monitor\Framework\Entity\Monitor;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -40,9 +40,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: UserPreference::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private Collection $userPreferences;
/**
* @var Collection<int, Monitor>
*/
#[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $yes;
public function __construct()
{
$this->userPreferences = new ArrayCollection();
$this->yes = new ArrayCollection();
}
public function getId(): ?int
@@ -204,4 +211,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
})
->toArray();
}
/**
* @return Collection<int, Monitor>
*/
public function getYes(): Collection
{
return $this->yes;
}
public function addYe(Monitor $ye): static
{
if (!$this->yes->contains($ye)) {
$this->yes->add($ye);
$ye->setUser($this);
}
return $this;
}
public function removeYe(Monitor $ye): static
{
if ($this->yes->removeElement($ye)) {
// set the owning side to null (unless already changed)
if ($ye->getUser() === $this) {
$ye->setUser(null);
}
}
return $this;
}
}

View File

@@ -38,7 +38,7 @@ class CountryLanguages
'Filipino'
];
public static function fromCountryCode(string $countryCode): string
public static function fromCountryCode(string $countryCode): ?string
{
$countryLanguages = [
'US' => 'English (US)',
@@ -85,10 +85,10 @@ class CountryLanguages
'KE' => 'English', // Also Swahili
];
return $countryLanguages[$countryCode];
return $countryLanguages[$countryCode] ?? null;
}
public static function fromCountryName(string $countryName): string
public static function fromCountryName(string $countryName): ?string
{
$countryLanguages = [
'United States' => 'English (US)',
@@ -135,6 +135,6 @@ class CountryLanguages
'Kenya' => 'English', // Also Swahili
];
return $countryLanguages[$countryName];
return $countryLanguages[$countryName] ?? null;
}
}

View File

@@ -41,6 +41,18 @@
"config/packages/http_discovery.yaml"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
},
"files": [
"phpstan.dist.neon"
]
},
"symfony/asset-mapper": {
"version": "7.2",
"recipe": {
@@ -158,6 +170,18 @@
"config/routes.yaml"
]
},
"symfony/scheduler": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "caea3c928ee9e1b21288fd76aef36f16ea355515"
},
"files": [
"src/Schedule.php"
]
},
"symfony/security-bundle": {
"version": "7.2",
"recipe": {
@@ -186,6 +210,19 @@
"assets/controllers/hello_controller.js"
]
},
"symfony/translation": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "e28e27f53663cc34f0be2837aba18e3a1bef8e7b"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": {
"version": "7.2",
"recipe": {

View File

@@ -15,11 +15,7 @@
</template>
</turbo-stream>
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert title="Success" message="{{ entity.title }} has been added to the Download queue" alert_id="{{ entity.id }}" data-controller="alert" />
</template>
</turbo-stream>
<twig:Alert title="Success" message="{{ entity.title }} has been added to the Download queue" alert_id="{{ entity.id }}" data-controller="alert" />
{% endblock %}
{% block update %}

View File

@@ -81,7 +81,7 @@
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row gap-2 justify-end px-8">
<button class="px-1.5 py-1 bg-green-600 rounded-md text-sm"
<button class="px-1.5 py-1 bg-green-600 hover:bg-green-700 rounded-md text-sm"
{{ stimulus_target('result_filter', 'downloadSelected') }}
{{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }}
>Download Selected</button>

View File

@@ -17,7 +17,7 @@
</div>
</div>
</div>
<div {{ turbo_stream_listen('alerts') }} class="absolute top-10 right-10 size-96">
<div {{ turbo_stream_listen('alerts') }} class="absolute top-10 right-10">
<div >
<ul id="alert_list">
</ul>

View File

@@ -1,24 +1,19 @@
<nav {{ attributes }} class="flex h-screen flex-col justify-between bg-cyan-950">
<nav {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950">
<div class="px-4 py-6">
<ul class="mt-6 space-y-1">
<li>
<a
href="{{ path('app_index') }}"
class="block rounded-lg
<a href="{{ path('app_index') }}"
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"
>
px-4 py-2 text-sm font-medium text-gray-50">
Dashboard
</a>
</li>
<li>
<details class="group [&_summary::-webkit-details-marker]:hidden">
<summary
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 text-gray-50 hover:bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60"
>
<span class="text-sm font-medium"> Downloads </span>
<summary class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 text-gray-50 hover:bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60">
<span class="text-sm font-medium">Downloads</span>
<span class="shrink-0 transition duration-300 group-open:-rotate-180">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -34,22 +29,17 @@
</svg>
</span>
</summary>
<ul class="mt-2 space-y-1 px-4">
<li>
<a
href="#"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700"
>
<a href="{{ path('app_search') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
In Progress
</a>
</li>
<li>
<a
href="#"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700"
>
<a href="{{ path('app_search') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
Complete
</a>
</li>
@@ -58,10 +48,8 @@
</li>
<li>
<a
href="{{ path('app_media_preferences') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700"
>
<a href="{{ path('app_media_preferences') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
Preferences
</a>
</li>
@@ -69,7 +57,7 @@
</div>
<div class="sticky inset-x-0 bottom-0 border-t border-orange-500">
<a href="#" class="flex items-center gap-2 p-4 bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60">
<a href="#" class="nav-foot flex items-center gap-2 p-4 bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60">
<span class="rounded-full p-2 border-orange-500 border-2">
<twig:ux:icon name="ri:user-line" width="30" class="text-gray-50"/>
</span>

View File

@@ -11,7 +11,16 @@
placeholder="TV Show, Movie..."
/>
<button
class="absolute top-1 right-1 flex items-center rounded bg-green-600 py-1 px-2.5 border border-transparent text-center text-sm text-white transition-all shadow-sm hover:shadow focus:bg-green-700 focus:shadow-none active:bg-green-700 hover:bg-green-700 active:shadow-none disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
class="absolute top-1 right-1 flex items-center rounded
bg-green-600 py-1 px-2.5 border border-transparent text-center
text-sm text-white transition-all shadow-sm hover:shadow
focus:bg-green-700 focus:shadow-none active:bg-green-700
hover:bg-green-700 active:shadow-none disabled:pointer-events-none
disabled:opacity-50 disabled:shadow-none
text-white bg-green-800 bg-opacity-60 text-sm
hover:bg-green-900 border border-green-500
"
type="submit"
>
Search

View File

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

View File

@@ -8,11 +8,62 @@
<div class="flex flex-row w-full gap-2">
<twig:Card title="" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
<div class="p-4 flex flex-row gap-6">
<img class="w-40" src="{{ results.media.poster }}" />
{% if results.media.poster != null %}
<img class="w-40 rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-40 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="24" name="hugeicons:loading-01" />
</div>
{% endif %}
<div class="w-full flex flex-col">
<h3 class="mb-4 text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} - {{ results.media.year }}
</h3>
<div class="mb-4 flex flex-row gap-2 justify-between">
<h3 class="text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} - {{ results.media.year }}
</h3>
<div {{ stimulus_controller('monitor_button', {
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
title: results.media.title,
})}}
data-monitor-button-result-filter-outlet="#filter"
>
<button data-monitor-button-target="button" {{ stimulus_action('monitor_button', 'toggle', 'click') }}
class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm
px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2
border-green-500"
type="button"
>
Monitor
<svg class="w-2.5 h-2.5 ms-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<!-- Dropdown menu -->
<div data-monitor-button-target="options"
class="absolute mt-1 right-12 z-40 hidden divide-y rounded-md shadow-sm
w-44 bg-green-800 backdrop-filter bg-opacity-100 border-2 border-green-500"
>
<ul class="py-2 text-sm text-gray-100" aria-labelledby="dropdownDefaultButton">
<li {{ stimulus_action('monitor_button', 'monitorSeries', 'click') }}>
<button class="px-4 py-2 hover:bg-green-950 w-full text-left">
Entire Series
</button>
</li>
<li {{ stimulus_action('monitor_button', 'monitorSeason', 'click') }}>
<button class="px-4 py-2 hover:bg-green-950 w-full text-left">
Season
</button>
</li>
</ul>
</div>
</div>
</div>
<p class="text-gray-50">
{{ results.media.description }}
</p>

View File

@@ -1,14 +1,25 @@
<div class="p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<div class="flex flex-row gap-4">
<img class="w-64 rounded-lg" src="{{ results.media.poster }}" />
{% if results.media.poster != null %}
<img class="w-64 rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-64 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="32" name="hugeicons:loading-01" />
</div>
{% endif %}
<div class="flex flex-col gap-4 grow">
<h4 class="text-md font-bold">{{ results.episode }}. {{ results.media.title }}</h4>
<p>{{ results.media.description }}</p>
<span>
<div>
<small class="py-1 px-1.5 grow-0 font-bold bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white"
{{ stimulus_action('tv-results', 'toggleList', 'click') }}
{{ stimulus_action('tv-results', 'toggleList', 'click') }}
><span {{ stimulus_target('tv-results', 'count') }}>{{ results.results|length }}</span> results</small>
</span>
<small class="py-1 px-1.5 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Air date {{ results.media.episodeAirDate }}"
>{{ results.media.episodeAirDate }}</small>
{# <small class="py-1 px-1.5 grow-0 font-bold bg-red-600 hover:bg-red-700 rounded-lg font-normal text-white cursor-pointer" title="Clear cache for {{ results.media.title }}"#}
{# {{ stimulus_action('tv-results', 'clearCache', 'click') }}#}
{# >Clear Cache</small>#}
</div>
</div>
<div class="flex flex-col gap-4 justify-between">
<input type="checkbox"

View File

@@ -3,8 +3,8 @@
{% block title %}Log in &mdash; Torsearch{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500 p-4 rounded-lg gap-2 min-w-96">
<h2 class="text-xl text-gray-50">Login</h2>
<div class="flex flex-col bg-orange-500 bg-opacity-60 border-orange-500 border-2 p-4 rounded-lg gap-2 min-w-96 text-gray-50">
<h2 class="text-xl font-bold">Login</h2>
<form method="post" class="flex flex-col gap-2">
{% if error %}
<div class="bg-red-400 border-red-600 rounded p-2 text-red-600">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
@@ -16,7 +16,7 @@
</div>
{% endif %}
<label for="username" class="mb-2 flex flex-col text-gray-950">
<label for="username" class="mb-2 flex flex-col">
Email
<input type="email"
value="{{ last_username }}"
@@ -27,7 +27,7 @@
required autofocus>
</label>
<label for="password" class="mb-2 flex flex-col text-gray-950">
<label for="password" class="mb-2 flex flex-col">
Password
<input type="password"
name="_password"

View File

@@ -3,7 +3,7 @@
{% block title %}Register{% endblock %}
{% block body %}
<div class="flex flex-col bg-orange-500 p-4 rounded-lg gap-2 min-w-96">
<div class="flex flex-col bg-orange-500 bg-opacity-60 p-4 rounded-lg gap-2 min-w-96 border-orange-500 border-2 text-gray-50">
<h2 class="text-xl text-gray-50">Register</h2>
{{ form_errors(registrationForm) }}
@@ -37,7 +37,7 @@
class="bg-gray-50 text-gray-950 p-1 rounded-md" />
</label>
<button type="submit" class="bg-green-600 px-1.5 py-1 rounded-md text-gray-50">Register</button>
<button type="submit" class="bg-green-600 px-1.5 py-1 w-full rounded-md text-gray-50">Register</button>
{{ form_end(registrationForm) }}
</div>
{% endblock %}

0
translations/.gitignore vendored Normal file
View File