Compare commits

...

19 Commits

Author SHA1 Message Date
f9ddd70668 wip: apparently torrentio doesn't need configuration 2025-08-31 09:06:29 -05:00
33bfe40b11 fix: increases columns size 2025-08-31 00:23:27 -05:00
f3a2f35571 fix: adds favicon 2025-08-31 00:20:35 -05:00
9eef567974 feat: simple related media block on results page 2025-08-29 16:29:20 -05:00
070723581a fix: view all monitors button color 2025-08-29 15:13:30 -05:00
f3a5c2012e fix: calendar icon on mobile 2025-08-29 01:08:47 -05:00
5581a82554 fix: ical subscription not loading 2025-08-28 20:19:31 -05:00
3703272f59 fix: makes ical url publicly accessible if user has option enabled 2025-08-27 22:58:24 -05:00
b587302b30 wip: ical calendar export 2025-08-26 22:24:23 -05:00
e5bab8e6fd fix: adds calendar button to link to upcoming episodes, adds titles to A tags 2025-08-25 23:06:30 -05:00
502b85dda4 fix: typo in default NTFY_DSN env var 2025-08-24 13:08:20 -05:00
9c430290e9 fix: makes calendar responsive 2025-08-23 22:18:01 -05:00
583591bf4f fix: applies colors to calendar events 2025-08-23 21:43:44 -05:00
182708b8f0 fix: links to upcoming episodes page 2025-08-23 14:54:04 -05:00
d6ba4d7d2a fix: updates episode air date for existing monitors 2025-08-23 14:37:24 -05:00
e5c5ec93a8 feat: /api/upcoming-episodes 2025-08-23 14:14:37 -05:00
942911d8ef fix: removes fullcalendar from importmap and references from cdn 2025-08-23 12:26:26 -05:00
2f7d406d12 wip: renders calendar with demo data 2025-08-23 09:22:02 -05:00
4e1adc576c fix: disables pull to refresh 2025-08-09 00:37:13 -05:00
54 changed files with 1160 additions and 95 deletions

2
.env
View File

@@ -57,4 +57,4 @@ OIDC_BYPASS_FORM_LOGIN=false
###< symfony/ntfy-notifier ###
NOTIFICATION_TRANSPORT=
NTFY_DNS=
NTFY_DSN=

View File

@@ -18,11 +18,3 @@ var observer = new MutationObserver(function(mutations) {
});
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});
const ptr = PullToRefresh.init({
mainElement: 'body',
onRefresh() {
window.location.reload();
}
});

View File

@@ -0,0 +1,57 @@
import { Controller } from '@hotwired/stimulus';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
calendar = null;
initialize() {
}
connect() {
this.calendar = new Calendar(this.element, {
plugins: [ dayGridPlugin, timeGridPlugin ],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
editable: true, // Allow events to be dragged and resized
events: '/api/events', // Symfony route to fetch events
eventDrop: function(info) {
// Handle event drop (e.g., update event in database via AJAX)
},
eventResize: function(info) {
// Handle event resize (e.g., update event in database via AJAX)
}
});
this.calendar.render();
// this.calendar = new Calendar(this.element, {
// plugins: [ dayGridPlugin, timeGridPlugin, listPlugin ],
// initialView: 'dayGridMonth',
// headerToolbar: {
// left: 'prev,next today',
// center: 'title',
// right: 'dayGridMonth,timeGridWeek,listWeek'
// }
// });
// this.calendar.render();
// calendar.render();
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor"><path d="M19.5 9.5v-.8c0-1.12 0-1.68-.218-2.108a2 2 0 0 0-.874-.874C17.98 5.5 17.42 5.5 16.3 5.5H7.7c-1.12 0-1.68 0-2.108.218a2 2 0 0 0-.874.874C4.5 7.02 4.5 7.58 4.5 8.7v.8m15 0v6.8c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874c-.428.218-.988.218-2.108.218H7.7c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874C4.5 17.98 4.5 17.42 4.5 16.3V9.5m15 0h-15"/><path stroke-linecap="round" d="M8.5 3.5v4m7-4v4M12 17v-5m2.5 2.5h-5"/></g></svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none"><path stroke="currentColor" stroke-width="1.5" d="M2 12c0-3.771 0-5.657 1.172-6.828S6.229 4 10 4h4c3.771 0 5.657 0 6.828 1.172S22 8.229 22 12v2c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14z"/><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M7 4V2.5M17 4V2.5M2.5 9h19"/><path fill="currentColor" d="M18 17a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-5 4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-5 4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0"/></g></svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -27,6 +27,11 @@
}
}
:root {
--fc-border-color: #a65b27;
--fc-page-bg-color: #a65b27;
}
/* Prevent scrolling while dialog is open */
body:has(dialog[data-dialog-target="dialog"][open]) {
overflow: hidden;
@@ -163,6 +168,7 @@ dialog[data-dialog-target="dialog"][closing] {
background: transparent;
}
form[name="torrentio_preferences_form"],
#filter {
.ts-wrapper {
box-shadow: none !important;
@@ -193,3 +199,7 @@ dialog[data-dialog-target="dialog"][closing] {
.filter-label {
@apply flex flex-col gap-1 justify-between;
}
.fc-col-header-cell {
@apply bg-orange-500/60 text-white;
}

View File

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

61
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6f57ba35ae317ec6370836bda0012db8",
"content-hash": "9ffd10f98137e8975de2c04ac2412ed5",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -4757,6 +4757,65 @@
],
"time": "2023-12-12T12:06:11+00:00"
},
{
"name": "spatie/icalendar-generator",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/icalendar-generator.git",
"reference": "32797f6e5afa3142d073f38d5f22ab377f4d8f90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/icalendar-generator/zipball/32797f6e5afa3142d073f38d5f22ab377f4d8f90",
"reference": "32797f6e5afa3142d073f38d5f22ab377f4d8f90",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^8.1"
},
"require-dev": {
"ext-json": "*",
"larapack/dd": "^1.1",
"nesbot/carbon": "^3.5",
"pestphp/pest": "^2.34",
"phpstan/phpstan": "^2.0",
"spatie/pest-plugin-snapshots": "^2.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\IcalendarGenerator\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ruben Van Assche",
"email": "ruben@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Build calendars in the iCalendar format",
"homepage": "https://github.com/spatie/icalendar-generator",
"keywords": [
"calendar",
"iCalendar",
"ical",
"ics",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/icalendar-generator/issues",
"source": "https://github.com/spatie/icalendar-generator/tree/3.0.0"
},
"time": "2025-04-17T14:50:03+00:00"
},
{
"name": "spomky-labs/pki-framework",
"version": "1.3.0",

View File

@@ -45,6 +45,7 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/monitors/ical/, roles: PUBLIC_ACCESS }
- { path: ^/reset-password, roles: PUBLIC_ACCESS }
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }

View File

@@ -1,20 +1,6 @@
FROM dunglas/frankenphp:php8.4-alpine
ARG APP_VERSION
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app
FROM code.caldwell.digital/torsearch/torsearch-app:${APP_VERSION}
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ]

View File

@@ -0,0 +1,35 @@
<?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 Version20250823173128 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'
ALTER TABLE monitor ADD air_date DATETIME DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP air_date
SQL);
}
}

View File

@@ -0,0 +1,47 @@
<?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 Version20250831013403 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'
DROP TABLE sessions
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DATETIME NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(1024) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
SQL);
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -4,7 +4,6 @@ namespace App\Base\Framework\Command;
use App\User\Framework\Entity\Preference;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferenceOptionRepository;
use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -135,6 +134,20 @@ class SeedDatabaseCommand extends Command
'enabled' => true,
'type' => 'download'
],
[
'id' => 'enable_ical_up_ep',
'name' => 'Enable a publicly available iCal calendar?',
'description' => 'Enable a publicly accessible iCal URL for your upcoming episodes.',
'enabled' => true,
'type' => 'calendar'
],
[
'id' => 'torrentio_url',
'name' => 'A custom Torrentio URL',
'description' => 'If you want to use a custom Torrentio URL, enter it here. Otherwise, leave it blank to use the one provided as an environment variable.',
'enabled' => true,
'type' => 'torrentio'
],
];
}
}

View File

@@ -53,9 +53,6 @@ final class IndexController extends AbstractController
#[Route('/test')]
public function monitorTvShow(): Response
{
$this->monitorTvShowHandler->handle(new MonitorTvShowCommand(96));
return $this->json([
'Success' => 'Monitor added'
]);
return $this->render('index/test.html.twig', []);
}
}

View File

@@ -5,7 +5,6 @@ namespace App\Monitor\Action\Handler;
use App\Base\Util\EpisodeId;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
@@ -44,6 +43,11 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (null === $monitor->getAirDate() && null !== $episodeData->episodeAirDate && "" !== $episodeData->episodeAirDate) {
$monitor->setAirDate(Carbon::parse($episodeData->episodeAirDate));
}
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now');
return new MonitorTvEpisodeResult(

View File

@@ -11,6 +11,7 @@ use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use App\Base\Util\PTN;
@@ -96,6 +97,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
->setMonitorType('tvepisode')
->setSeason($episode['season_number'])
->setEpisode($episode['episode_number'])
->setAirDate($episode['air_date'] !== null && $episode['air_date'] !== "" ? Carbon::parse($episode['air_date']) : null)
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Monitor\Dto;
use Carbon\Carbon;
class UpcomingEpisode
{
public function __construct(
public string $title,
public string $airDate {
get => Carbon::parse($this->airDate)->format('m/d/Y');
},
public string $episodeTitle,
public int $episodeNumber,
) {}
}

View File

@@ -2,11 +2,13 @@
namespace App\Monitor\Framework\Controller;
use Aimeos\Map;
use App\Base\Service\Broadcaster;
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 App\Monitor\Framework\Scheduler\MonitorDispatcher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -65,4 +67,45 @@ class ApiController extends AbstractController
'message' => 'Manually dispatched MonitorDispatcher'
]);
}
#[Route('/api/monitor/upcoming-episodes', name: 'api.monitor.upcoming-episodes', methods: ['GET'])]
public function upcomingEpisodes(MonitorRepository $repository): Response
{
$colors = [
'blue' => '#007bff',
'indigo' => '#6610f2',
'purple' => '#6f42c1',
'pink' => '#e83e8c',
'red' => '#dc3545',
'orange' => '#fd7e14',
'yellow' => '#ffc107',
'green' => '#28a745',
'teal' => '#20c997',
'cyan' => '#17a2b8',
];
$eventColors = [];
$monitors = $repository->whereAirDateNotNull();
$monitors = Map::from($monitors)->map(function ($monitor) use (&$eventColors, $colors) {
if (!array_key_exists($monitor->getImdbId(), $eventColors)) {
$eventColors[$monitor->getImdbId()] = $colors[array_rand($colors)];
}
return [
'id' => $monitor->getId(),
'title' => $monitor->getTitle() . ' (S' . str_pad($monitor->getSeason(), 2, '0', STR_PAD_LEFT) . 'E' . str_pad($monitor->getEpisode(), 2, '0', STR_PAD_LEFT) . ')',
'start' => $monitor->getAirDate()->format('Y-m-d H:i:s'),
'groupId' => $monitor->getImdbId(),
'allDay' => true,
'backgroundColor' => $eventColors[$monitor->getImdbId()],
'borderColor' => $eventColors[$monitor->getImdbId()],
];
});
return $this->json([
'status' => 200,
'data' => [
'episodes' => $monitors->toArray(),
]
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Monitor\Framework\Controller;
use Aimeos\Map;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User;
use Spatie\IcalendarGenerator\Components\Calendar;
use Spatie\IcalendarGenerator\Components\Event;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class CalendarController extends AbstractController
{
#[IsGranted('PUBLIC_ACCESS')]
#[Route('/monitors/ical/{email:user}/upcoming-episodes.ics', name: 'app.monitors.ical')]
public function icalAction(MonitorRepository $monitorRepository, User $user)
{
if (false === $user->hasICalEnabled()) {
return new Response('Calendar not found.', 404);
}
$calendar = Calendar::create()
->name('Upcoming Episodes')
->refreshInterval(10);
$monitors = $monitorRepository->whereAirDateNotNull();
$calendar->event(Map::from($monitors)->map(function ($monitor) {
return new Event($monitor->getTitle())
->startsAt($monitor->getAirDate())
->fullDay();
})->toArray());
return new Response($calendar->get(), 200, [
'Content-Type' => 'text/calendar',
'Content-Disposition' => 'inline; filename="upcoming-episodes.ics"',
]);
}
}

View File

@@ -28,4 +28,10 @@ class WebController extends AbstractController
{
return $this->render('monitor/index.html.twig');
}
#[Route('/monitors/upcoming-episodes', name: 'app.monitor.upcoming-episodes', methods: ['GET'])]
public function upcomingEpisodes()
{
return $this->render('monitor/upcoming-episodes.html.twig');
}
}

View File

@@ -56,6 +56,9 @@ class Monitor
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastSearch = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $airDate = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
@@ -257,6 +260,17 @@ class Monitor
return $this;
}
public function getAirDate(): ?\DateTimeInterface
{
return $this->airDate;
}
public function setAirDate(?\DateTimeInterface $airDate): static
{
$this->airDate = $airDate;
return $this;
}
public function removeChild(self $child): static
{
if ($this->children->removeElement($child)) {

View File

@@ -33,4 +33,12 @@ class MonitorRepository extends ServiceEntityRepository
return $this->paginator->paginate($query, $page, $perPage);
}
public function whereAirDateNotNull()
{
$query = $this->createQueryBuilder('m')
->andWhere('m.airDate IS NOT NULL')
->getQuery();
return $query->getResult();
}
}

View File

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

View File

@@ -10,6 +10,7 @@ class GetMediaInfoResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public array $relatedMedia,
public ?int $season,
public ?int $episode,
) {}

View File

@@ -7,9 +7,6 @@ use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;

View File

@@ -247,6 +247,21 @@ class Tmdb
return $series;
}
public function relatedMedia(string $tmdbId, string $mediaType, int $maxResults = 6)
{
$repos = [
'movies' => $this->movieRepository,
'tvshows' => $this->tvRepository,
];
$results = $repos[$mediaType]->getRecommendations($tmdbId);
return Map::from(array_values($results->toArray()))
->slice(0, 6)
->map(function ($result) use ($mediaType) {
return $this->parseResult($result, $mediaType);
})->toArray();
}
public function mediaDetails(string $id, string $type)
{
$id = $this->find($id);
@@ -286,13 +301,18 @@ class Tmdb
private function parseTvShow(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['first_air_date'], ['', null,])) {
$airDate = (new \DateTime($data['first_air_date']))->format('Y-m-d');
} else {
$airDate = null;
}
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'),
year: $airDate,
mediaType: "tvshows",
episodes: $data['episodes'],
);
@@ -300,6 +320,11 @@ class Tmdb
private function parseEpisode(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['air_date'], ['', null,])) {
$airDate = (new \DateTime($data['air_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
@@ -309,12 +334,17 @@ class Tmdb
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
episodeAirDate: (new \DateTime($data['air_date']))->format('m/d/Y'),
episodeAirDate: $airDate,
);
}
private function parseMovie(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['release_date'], ['', null,])) {
$airDate = (new \DateTime($data['release_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
@@ -323,7 +353,7 @@ class Tmdb
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
episodeAirDate: (new \DateTime($data['release_date']))->format('m/d/Y'),
episodeAirDate: $airDate,
);
}

View File

@@ -9,13 +9,14 @@ use Carbon\Carbon;
use App\Torrentio\Exception\TorrentioRateLimitException;
use GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class Torrentio
{
private string $baseUrl = 'https://torrentio.strem.fun/providers%253Dyts%252Ceztv%252Crarbg%252C1337x%252Cthepiratebay%252Ckickasstorrents%252Ctorrentgalaxy%252Cmagnetdl%252Chorriblesubs%252Cnyaasi%7Csort%253Dqualitysize%7Cqualityfilter%253D480p%252Cscr%252Ccam%7Crealdebrid={realDebridKey}/stream/movie';
private string $baseUrl = 'https://torrentio.strem.fun/realdebrid={realDebridKey}/stream/movie';
private string $searchUrl;
@@ -25,8 +26,15 @@ class Torrentio
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private TagAwareCacheInterface $cache,
private LoggerInterface $logger,
private Security $security,
) {
$this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl);
// $this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl);
$user = $this->security->getUser();
$this->searchUrl = $user->getUserPreference('torrentio_url')->getPreferenceValue() . '/stream/movie';
// dd($this->searchUrl);
$this->client = new Client([
'base_uri' => $this->searchUrl,
]);
@@ -36,25 +44,26 @@ class Torrentio
{
$cacheKey = "torrentio.{$imdbCode}";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $type) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$item->tag(['torrentio', $type, $imdbCode]);
// $results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $type) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
// $item->tag(['torrentio', $type, $imdbCode]);
try {
$response = $this->client->get("$this->searchUrl/$imdbCode.json");
return json_decode(
$results = json_decode(
$response->getBody()->getContents(),
true
);
// dd($results);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
throw $exception;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
});
// return [];
// });
if (true === $parseResults) {
return $this->parse($results);
@@ -65,26 +74,26 @@ class Torrentio
public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array
{
$cacheKey = "torrentio.$imdbId.$season.$episode";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$item->tag(['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"]);
// $cacheKey = "torrentio.$imdbId.$season.$episode";
// $results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
// $item->tag(['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"]);
try {
$response = $this->client->get("$this->searchUrl/$imdbId:$season:$episode.json");
return json_decode(
$results = json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
throw $exception;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
});
// return [];
// });
if (null === $results) {
throw new TorrentioRateLimitException();

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Torrentio\Client;
use App\Values\TorrentioDebridProviderChoices;
class TorrentioUrl
{
const BASE_URL = "https://torrentio.strem.fun/";
public ?array $providers = null;
public ?array $language = null;
public ?array $qualityfilter = null;
public ?string $sorting = null;
public ?string $debridProvider = null;
public ?string $limit = null;
public ?string $sizefilter = null;
public ?string $debridToken = null;
public function __toString(): string
{
$result = "";
if (null !== $this->debridProvider && null !== $this->debridToken) {
$result .= "$this->debridProvider=$this->debridToken";
}
return static::BASE_URL . $result;
}
public static function fromString(string $url): self
{
$arrayData = ['providers', 'language', 'qualityfilter'];
$data = explode('|', str_replace('/', '', urldecode(urldecode(parse_url($url)['path']))));
$result = new self();
foreach ($data as $item) {$item = explode('=', $item);
if (count($item) !== 2) continue;
if (in_array($item[0], array_keys(TorrentioDebridProviderChoices::$providers))) {
$result->debridProvider = $item[0];
$result->debridToken = $item[1];
} elseif (in_array($item[0], $arrayData)) {
$result->{$item[0]} = explode(',', $item[1]);
} else {
$result->{$item[0]} = $item[1];
}
}
return $result;
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Torrentio\Framework\Form;
use Aimeos\Map;
use App\User\Database\ProviderList;
use App\Values\TorrentioDebridProviderChoices;
use App\Values\TorrentioExcludeQualityChoices;
use App\Values\TorrentioLanguageChoices;
use App\Values\TorrentioProviderChoices;
use App\Values\TorrentioSortChoices;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TorrentioPreferencesForm extends AbstractType
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addChoiceField($builder, 'providers', TorrentioProviderChoices::asSelectOptions());
$this->addChoiceField($builder, 'sorting', TorrentioSortChoices::asSelectOptions(), 1);
$this->addChoiceField($builder, 'language', TorrentioLanguageChoices::asSelectOptions());
$this->addChoiceField($builder, 'qualityfilter', TorrentioExcludeQualityChoices::asSelectOptions());
$this->addTextField($builder, 'limit');
$this->addTextField($builder, 'sizefilter');
$this->addChoiceField($builder, 'debridProvider', TorrentioDebridProviderChoices::asSelectOptions(), 1);
$this->addTextField($builder, 'debridToken');
}
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices, ?int $maxItems = null): void
{
$question = [
'attr' => [
'class' => 'min-w-24 text-input mb-4',
],
'row_attr' => [
'class' => 'filter-label text-white'
],
'label_attr' => ['class' => 'block font-semibold mb-2'],
'choices' => $choices,
'required' => false,
];
if (null === $maxItems) {
$question['multiple'] = true;
$question['attr'] += [
'data-result-filter-target' => $fieldName,
'data-controller' => 'symfony--ux-autocomplete--autocomplete',
'data-symfony--ux-autocomplete--autocomplete-tom-select-options-value' => json_encode([
'highlight' => false,
'maxItems' => $maxItems,
]),
];
} else {
$question += [
'multiple' => false,
'expanded' => false,
];
}
$builder->add($fieldName, ChoiceType::class, $question);
}
public function addTextField(FormBuilderInterface $builder, string $fieldName, ?array $options = null): void
{
$optinos = $options ?? [
'required' => false,
'attr' => [
'method' => 'post',
'action' => $this->urlGenerator->generate('app.torrentio-preferences.save'),
'class' => 'min-w-24 text-input mb-4 block',
]
];
$builder->add(
$fieldName,
TextType::class,
$optinos,
);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'id' => 'torrentio-preferences-form',
// 'action' => $this->urlGenerator->generate('app_user_media_preferences_submit'),
'attr' => [
'class' => 'filter-items w-full p-4 text-md dark:text-gray-50 rounded-lg',
]
]);
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Twig\Components;
use Aimeos\Map;
use App\Monitor\Dto\UpcomingEpisode;
use App\Monitor\Factory\UpcomingEpisodeDto;
use App\Monitor\Framework\Entity\Monitor;
use App\Tmdb\Tmdb;
use Carbon\CarbonImmutable;
@@ -70,7 +70,7 @@ final class UpcomingEpisodes extends AbstractController
}
return $episodes->map(function (array $episode) use ($monitor) {
return new UpcomingEpisode(
return new UpcomingEpisodeDto(
$monitor->getTitle(),
$episode['air_date'],
$episode['name'],

View File

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

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Action\Command;
use App\Torrentio\Client\TorrentioUrl;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
class SaveUserTorrentioPreferencesCommand implements CommandInterface
{
public function __construct(
public TorrentioUrl $torrentioUrl,
) {}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Result\SaveUserDownloadPreferencesResult;
use App\User\Action\Result\SaveUserMediaPreferencesResult;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Symfony\Bundle\SecurityBundle\Security;
/** @implements HandlerInterface<SaveUserMediaPreferencesCommand> */
class SaveUserCalendarPreferencesHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly Security $token,
) {}
public function handle(C $command): R
{
/** @var User $user */
$user = $this->token->getUser();
foreach ($command as $preference => $value) {
if ($user->hasUserPreference($preference)) {
$user->updateUserPreference($preference, $value);
$this->entityManager->flush();
continue;
}
$preference = $this->preferenceRepository->find($preference);
$user->addUserPreference(
(new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue($value)
);
}
$this->entityManager->flush();
return new SaveUserDownloadPreferencesResult($user->getDownloadPreferences());
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Result\SaveUserDownloadPreferencesResult;
use App\User\Action\Result\SaveUserMediaPreferencesResult;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Symfony\Bundle\SecurityBundle\Security;
/** @implements HandlerInterface<SaveUserMediaPreferencesCommand> */
class SaveUserTorrentioPreferencesHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly Security $token,
) {}
public function handle(C $command): R
{
/** @var User $user */
$user = $this->token->getUser();
if ($user->hasUserPreference('torrentio_url')) {
$user->updateUserPreference('torrentio_url', (string) $command->torrentioUrl);
$this->entityManager->flush();
} else {
$preference = $this->preferenceRepository->find('torrentio_url');
$user->addUserPreference(
(new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue((string) $command->torrentioUrl)
);
}
$this->entityManager->flush();
return new SaveUserDownloadPreferencesResult($user->getDownloadPreferences());
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\User\Action\Input;
use App\User\Action\Command\SaveUserCalendarPreferencesCommand;
use App\User\Action\Command\SaveUserDownloadPreferencesCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<SaveUserDownloadPreferencesInput, SaveUserDownloadPreferencesCommand> */
class SaveUserCalendarPreferencesInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public mixed $userId,
#[SourceRequest('enable_ical_up_ep', nullify: true)]
public bool $enableIcalUpcomingEpisodes,
) {}
public function toCommand(): C
{
return new SaveUserCalendarPreferencesCommand(
$this->enableIcalUpcomingEpisodes,
);
}
}

View File

@@ -5,20 +5,31 @@ namespace App\User\Database;
class ProviderList
{
public static $providers = [
'1337x',
'Comando',
'YTS',
'EZTV',
'ilCorSaRoNeRo',
'MagnetDL',
'MejorTorrent',
'RARBG',
'Rutor',
'Rutracker',
'1337x',
'ThePirateBay',
'Torrent9',
'KickassTorrents',
'TorrentGalaxy',
'MagnetDL',
'HorribleSubs',
'NyaaSi',
'TokyoTosho',
'AniDex',
'🇷🇺 Rutor',
'🇷🇺 Rutracker',
'🇵🇹 Comando',
'🇵🇹 BluDV',
'🇫🇷 Torrent9',
'🇮🇹 ilCorSaRoNeRo',
'🇪🇸 MejorTorrent',
'🇪🇸 Wolfmax4k',
'🇲🇽 Cinecalidad',
'🇵🇱 BestTorrents'
];
public static function getProviders()
{
return self::$providers;

View File

@@ -5,9 +5,15 @@ declare(strict_types=1);
namespace App\User\Framework\Controller\Web;
use App\Base\Service\Broadcaster;
use App\Torrentio\Client\TorrentioUrl;
use App\Torrentio\Framework\Form\TorrentioPreferencesForm;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Command\SaveUserTorrentioPreferencesCommand;
use App\User\Action\Handler\SaveUserCalendarPreferencesHandler;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Handler\SaveUserTorrentioPreferencesHandler;
use App\User\Action\Input\SaveUserCalendarPreferencesInput;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Database\CountryLanguages;
@@ -33,16 +39,20 @@ class PreferencesController extends AbstractController
public function mediaPreferences(): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
// dd($form);
$torrentioForm = $this->createForm(TorrentioPreferencesForm::class, TorrentioUrl::fromString(
$this->getUser()->getUserPreference('torrentio_url')->getPreferenceValue()
));
return $this->render(
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
'torrentio_form' => $torrentioForm,
]
);
}
@@ -54,8 +64,8 @@ class PreferencesController extends AbstractController
): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$form = $this->createForm(UserMediaPreferencesForm::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -69,6 +79,7 @@ class PreferencesController extends AbstractController
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
@@ -81,6 +92,7 @@ class PreferencesController extends AbstractController
): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
@@ -95,6 +107,56 @@ class PreferencesController extends AbstractController
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
}
#[Route('/user/preferences/calendar', 'app.save.calendar-preferences', methods: ['POST'])]
public function saveCalendarPreferences(
SaveUserCalendarPreferencesInput $input,
SaveUserCalendarPreferencesHandler $handler,
): Response
{
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
$handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: 'Your calendar preferences have been saved.'
);
return $this->render(
'user/preferences.html.twig',
[
'downloadPreferences' => $this->getUser()->getDownloadPreferences(),
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
}
#[Route('/user/preferences/torrentio', 'app.torrentio-preferences.save', methods: ['POST'])]
public function saveTorrentioPreferences(Request $request, SaveUserTorrentioPreferencesHandler $handler)
{
$form = $this->createForm(TorrentioPreferencesForm::class, new TorrentioUrl());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$command = new SaveUserTorrentioPreferencesCommand(
$form->getData(),
);
$handler->handle($command);
$this->broadcaster->alert('Success', 'Your Torrentio preferences have been saved.');
}
return $this->render(
'user/preferences.html.twig',
[
'preferences_form' => $form,
]
);

View File

@@ -5,6 +5,7 @@ namespace App\User\Framework\Entity;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use App\Monitor\Framework\Entity\Monitor;
use App\Torrentio\Client\TorrentioUrl;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -327,4 +328,26 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
}
return [];
}
public function getCalendarPreferences(): array
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->filter(fn(UserPreference $userPreference) => $userPreference->getPreference()->getType() === 'calendar')
->toArray()
;
}
public function hasICalEnabled(): bool
{
return $this->hasUserPreference('enable_ical_up_ep') &&
(bool) $this->getUserPreference('enable_ical_up_ep')->getPreferenceValue() === true;
}
public function getTorrentioUrl()
{
return $this->hasUserPreference('torrentio_url')
? TorrentioUrl::fromString($this->getUserPreference('torrentio_url')->getPreferenceValue())
: null;
}
}

View File

@@ -21,7 +21,7 @@ class UserPreference
#[ORM\JoinColumn(nullable: false)]
private ?Preference $preference = null;
#[ORM\Column(length: 255, nullable: true)]
#[ORM\Column(length: 1024, nullable: true)]
private ?string $preference_value = null;
public function getId(): ?int

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Values;
class TorrentioDebridProviderChoices
{
public static array $providers = [
'realdebrid' => 'RealDebrid',
'premiumize' => 'Premiumize',
'alldebrid' => 'AllDebrid',
'debridlink' => 'DebridLink',
'easydebrid' => 'EasyDebrid',
'offcloud' => 'Offcloud',
'torbox' => 'TorBox',
'putio' => 'Put.io',
];
public static function getProviders(): array
{
return self::$providers;
}
public static function asSelectOptions(): array
{
return array_flip(self::$providers);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Values;
class TorrentioExcludeQualityChoices
{
public static array $choices = [
'brremux' => 'BluRay REMUX',
'hdrall' => 'HDR/HDR10+/Dolby Vision',
'dolbyvision' => 'Dolby Vision',
'dolbyvisionwithhdr' => 'Dolby Vision + HDR',
'threed' => '3D',
'nonthreed' => 'Non 3D (DO NOT SELECT IF NOT SURE)',
'4k' => '4k',
'1080p' => '1080p',
'720p' => '720p',
'480p' => '480p',
'other' => 'Other (DVDRip/HDRip/BDRip...)',
'scr' => 'Screener',
'cam' => 'Cam',
'unknown' => 'Unknown'
];
public static function getChoices(): array
{
return self::$choices;
}
public static function asSelectOptions(): array
{
return array_flip(self::$choices);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Values;
class TorrentioLanguageChoices
{
public static array $languages = [
'japanese' => '🇯🇵 Japanese',
'russian' => '🇷🇺 Russian',
'italian' => '🇮🇹 Italian',
'portuguese' => '🇵🇹 Portuguese',
'spanish' => '🇪🇸 Spanish',
'latino' => '🇲🇽 Latino',
'korean' => '🇰🇷 Korean',
'chinese' => '🇨🇳 Chinese',
'taiwanese' => '🇹🇼 Taiwanese',
'french' => '🇫🇷 French',
'german' => '🇩🇪 German',
'dutch' => '🇳🇱 Dutch',
'hindi' => '🇮🇳 Hindi',
'telugu' => '🇮🇳 Telugu',
'tamil' => '🇮🇳 Tamil',
'polish' => '🇵🇱 Polish',
'lithuanian' => '🇱🇹 Lithuanian',
'latvian' => '🇱🇻 Latvian',
'estonian' => '🇪🇪 Estonian',
'czech' => '🇨🇿 Czech',
'slovakian' => '🇸🇰 Slovakian',
'slovenian' => '🇸🇮 Slovenian',
'hungarian' => '🇭🇺 Hungarian',
'romanian' => '🇷🇴 Romanian',
'bulgarian' => '🇧🇬 Bulgarian',
'serbian' => '🇷🇸 Serbian',
'croatian' => '🇭🇷 Croatian',
'ukrainian' => '🇺🇦 Ukrainian',
'greek' => '🇬🇷 Greek',
'danish' => '🇩🇰 Danish',
'finnish' => '🇫🇮 Finnish',
'swedish' => '🇸🇪 Swedish',
'norwegian' => '🇳🇴 Norwegian',
'turkish' => '🇹🇷 Turkish',
'arabic' => '🇸🇦 Arabic',
'persian' => '🇮🇷 Persian',
'hebrew' => '🇮🇱 Hebrew',
'vietnamese' => '🇻🇳 Vietnamese',
'indonesian' => '🇮🇩 Indonesian',
'malay' => '🇲🇾 Malay',
'thai' => '🇹🇭 Thai'
];
public static function getLanguages(): array
{
return self::$languages;
}
public static function asSelectOptions(): array
{
return array_flip(self::$languages);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Values;
class TorrentioProviderChoices
{
public static array $choices = [
'yts' => 'YTS',
'eztv' => 'EZTV',
'rarbg' => 'RARBG',
'1337x' => '1337x',
'thepiratebay' => 'ThePirateBay',
'kickasstorrents' => 'KickassTorrents',
'torrentgalaxy' => 'TorrentGalaxy',
'magnetdl' => 'MagnetDL',
'horriblesubs' => 'HorribleSubs',
'nyaasi' => 'NyaaSi',
'tokyotosho' => 'TokyoTosho',
'anidex' => 'AniDex',
'rutor' => '🇷🇺 Rutor',
'rutracker' => '🇷🇺 Rutracker',
'comando' => '🇵🇹 Comando',
'bludv' => '🇵🇹 BluDV',
'torrent9' => '🇫🇷 Torrent9',
'ilcorsaronero' => '🇮🇹 ilCorSaRoNeRo',
'mejortorrent' => '🇪🇸 MejorTorrent',
'wolfmax4k' => '🇪🇸 Wolfmax4k',
'cinecalidad' => '🇲🇽 Cinecalidad',
'besttorrents' => '🇵🇱 BestTorrents'
];
public static function getChoices(): array
{
return self::$choices;
}
public static function asSelectOptions(): array
{
return array_flip(self::$choices);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Values;
class TorrentioSortChoices
{
public static array $choices = [
'quality' => 'By quality then seeders',
'qualitysize' => 'By quality then size',
'seeders' => 'By seeders',
'size' => 'By size',
];
public static function getChoices(): array
{
return self::$choices;
}
public static function asSelectOptions(): array
{
return array_flip(self::$choices);
}
}

View File

@@ -12,6 +12,7 @@
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
{% endblock %}
</head>
<body class="flex flex-col bg-stone-700">

View File

@@ -5,12 +5,17 @@
<twig:SearchBar />
<div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block">
<ul class="flex items-center gap-6 text-sm">
<ul class="ml-4 flex items-end md:items-center md:gap-6 text-sm">
<li>
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false" title="View upcoming episodes of the shows you're subscribed to.">
<twig:ux:icon name="solar:calendar-linear" width="25px" class="text-orange-500" />
</a>
</li>
<li class="hidden">
<twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/>
</li>
<li class="hidden md:block">
<a href="{{ path('app_logout') }}">
<a href="{{ path('app_logout') }}" title="Log out of Torsearch.">
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" />
</a>
</li>

View File

@@ -47,7 +47,7 @@
{% endfor %}
{% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all">
<td colspan="100%" class="py-2 whitespace-nowrap bg-gray-400 dark:bg-gray-700 uppercase text-xs font-medium text-center text-black dark:text-white min-w-[50ch] max-w-[50ch] truncate">
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black">
<a href="{{ path('app_monitors') }}">View All Monitors</a>
</td>
</tr>

View File

@@ -1,4 +1,4 @@
<button class="submit-button flex flex-row gap-2 items-center">
<button class="submit-button {{ class|default('flex flex-row gap-2 items-center') }}">
{% if show_icon|default %}
<twig:ux:icon name="zondicons:checkmark" width=".8rem" class="text-green-500" />
{% endif %}

View File

@@ -0,0 +1,9 @@
{% extends 'base.html.twig' %}
{% block h2 %}Test Test{% endblock %}
{% block body %}
<div>
<!-- Well what are you doing here? -->
</div>
{% endblock %}

View File

@@ -4,6 +4,10 @@
{% block h2 %}Monitors{% endblock %}
{% block action_buttons %}
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false"
class="h-6 bg-orange-500/80 hover:bg-orange-600/80 px-2 text-white rounded-ms text-sm font-semibold">
Upcoming Episodes
</a>
<twig:ActionButton action="monitorDispatch" text="Run Monitors" />
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends 'base.html.twig' %}
{% block h2 %}Upcoming Episodes{% endblock %}
{% block action_buttons %}
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false"
class="h-6 bg-orange-500/80 hover:bg-orange-600/80 px-2 text-white rounded-ms text-sm font-semibold">
Upcoming Episodes
</a>
<twig:ActionButton action="monitorDispatch" text="Run Monitors" />
{% endblock %}
{% block body %}
{{ turbo_page_requires_reload() }}
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
<div class="p-4">
<twig:Card title="Upcoming episodes of shows your monitoring">
<a href="{{ path('app.monitors.ical', {email: app.user.email}) }}" title="Subscribe to the 'Upcoming Episodes' calendar via iCal. Click to export the events to a .ics file or copy the link and use it to subscribe in a calendar app that supports iCal/ics calendars." class="mb-2 self-end dark:text-white decoration-underline">
<twig:ux:icon name="lets-icons:calendar-add-light" width="24" class="text-orange-500" />
</a>
<div id="calendar" class="text-white">
</div>
</twig:Card>
</div>
<script>
function getView() {
if (window.innerWidth < 768) {
return 'listWeek';
} else {
return 'dayGridMonth';
}
}
document.addEventListener('DOMContentLoaded', async function() {
let data = await fetch('/api/monitor/upcoming-episodes');
data = (await data.json())['data'];
const calendarEl = document.getElementById('calendar');
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: getView(),
events: data['episodes'],
windowResize: function(arg) {
this.changeView(getView());
}
});
calendar.render();
});
</script>
{% endblock %}

View File

@@ -2,9 +2,10 @@
{% block title %}{{ results.media.title }} &mdash; Download Options &mdash; Torsearch{% endblock %}
{% block h2 %}Media Results{% endblock %}
{% block body %}
<div class="p-4 flex flex-col grow gap-4">
<h2 class="mb-2 text-3xl font-bold text-gray-50">Media Results</h2>
<div class="flex flex-row w-full gap-2">
<twig:Card title="" class="w-full" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6">
@@ -19,7 +20,7 @@
<div class="w-full flex flex-col">
<div class="mb-4 flex flex-row gap-2 justify-between">
<h3 class="text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} ({{ results.media.year }})
{{ results.media.title }} ({{ results.media.year|date('Y') }})
</h3>
{% if results.media.mediaType == "tvshows" %}
@@ -95,12 +96,35 @@
{% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList
results="results"
:imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1"
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer" :episodeNumber="results.episode"
loading="defer"
:imdbId="results.media.imdbId"
:season="results.season"
:perPage="20"
:pageNumber="1"
:tmdbId="results.media.tmdbId"
:title="results.media.title"
:episodeNumber="results.episode"
/>
{% endif %}
</twig:Card>
</div>
<twig:Card title="Related Media" contentClass="flex flex-col gap-4 text-white">
<p>Results similar to "{{ results.media.title }}" that you may be interested in.</p>
<div class="grid grid-cols-2 gap-4 md:flex flex-col md:flex-row justify-between w-full">
{% for media in results.relatedMedia %}
<twig:Poster imdbId="{{ media.imdbId }}"
tmdbId="{{ media.tmdbId }}"
title="{{ media.title }}"
description="{{ media.description }}"
image="{{ media.poster }}"
year="{{ media.year }}"
mediaType="{{ media.mediaType }}"
/>
{% endfor %}
</div>
</twig:Card>
</div>
<style>
html,

View File

@@ -36,4 +36,51 @@
</form>
</twig:Card>
</div>
<div class="p-4 flex flex-col md:flex-row gap-2">
<twig:Card title="Calendar Preferences" class="w-full">
<p class="text-gray-50 mb-4">Manage your Upcoming Episodes calendar.</p>
<form id="calendar_preferences" class="flex flex-col" name="calendar_preferences" method="post" action="{{ path('app.save.calendar-preferences') }}">
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 mb-1">
<input type="hidden" name="enable_ical_up_ep" id="enable_ical_up_ep_hidden" value="0" />
<input type="checkbox" name="enable_ical_up_ep" id="enable_ical_up_ep" value="1" {{ calendarPreferences['enable_ical_up_ep'].getPreferenceValue() == true ? 'checked' }} />
<label class="text-gray-50" for="enable_ical_up_ep">Enable a publicly available iCal calendar?</label>
</div>
<small class="text-gray-50 mb-4">Enabling the iCal calendar will allow you to subscribe from iCal
supporting clients. This endpoint will be publicly available with no authentication required.
Disabling this option will disable the calendar and public endpoint for your user.
This will not affect the calendar within the app.
</small>
</div>
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
</form>
</twig:Card>
</div>
<div class="p-4 flex flex-col md:flex-row gap-2">
<twig:Card title="Torrentio Preferences" class="w-full">
<p class="text-gray-50 mb-4">Configure your Torrentio client.</p>
{{ form_start(torrentio_form, {
action: path('app.torrentio-preferences.save')
}) }}
<div class="flex flex-col md:flex-row gap-2">
<div class="self-end mb-4">
{{ form_row(torrentio_form.providers) }}
{{ form_row(torrentio_form.sorting) }}
{{ form_row(torrentio_form.language) }}
{{ form_row(torrentio_form.qualityfilter) }}
{{ form_row(torrentio_form.limit) }}
{{ form_row(torrentio_form.sizefilter) }}
{{ form_row(torrentio_form.debridProvider) }}
{{ form_row(torrentio_form.debridToken) }}
{{ form_widget(torrentio_form._token) }}
<div class="w-[5rem]">
<twig:SubmitButton show_icon text="Save"/>
</div>
</div>
</div>
{{ form_end(preferences_form) }}
</twig:Card>
</div>
{% endblock %}