Compare commits

...

26 Commits

Author SHA1 Message Date
662e2600f6 fix: torrentio client 2025-08-31 19:33:37 -05:00
aa042e8275 fix: creates dedicated http client for torrentio 2025-08-31 18:01:40 -05:00
57498b1abf fix: increases column size 2025-08-31 13:41:07 -05:00
fed1e1e122 fix: adds favicon 2025-08-31 13:40:53 -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
575fc08f24 fix: cleanup 2025-08-09 00:24:31 -05:00
87bdde801d fix: updates config resolver 2025-08-08 23:48:30 -05:00
7d35b6266b feat: ntfy integration 2025-08-08 23:38:53 -05:00
caeda625fd fix: ships logs to graylog 2025-08-08 23:29:50 -05:00
d710e31d2b fix(MonitorList): no monitors row 2025-08-08 23:05:45 -05:00
39a64faa74 fix: styles 2025-08-06 15:10:47 -05:00
52 changed files with 978 additions and 148 deletions

7
.env
View File

@@ -51,3 +51,10 @@ OIDC_CLIENT_ID="Enter your OIDC client id"
OIDC_CLIENT_SECRET="Enter your OIDC client secret"
OIDC_BYPASS_FORM_LOGIN=false
###< drenso/symfony-oidc-bundle ###
###> symfony/ntfy-notifier ###
# NTFY_DSN=ntfy://default/TOPIC
###< symfony/ntfy-notifier ###
NOTIFICATION_TRANSPORT=
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;
@@ -193,3 +198,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.*",
@@ -43,6 +44,8 @@
"symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.3.*",
"symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",

207
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": "f4953f7b233a466d94269f35096f3385",
"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",
@@ -7578,6 +7637,152 @@
],
"time": "2025-02-19T08:51:26+00:00"
},
{
"name": "symfony/notifier",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/notifier.git",
"reference": "9e68a3266c8b0381f8756022b1c1ba3c0264416e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/notifier/zipball/9e68a3266c8b0381f8756022b1c1ba3c0264416e",
"reference": "9e68a3266c8b0381f8756022b1c1ba3c0264416e",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/log": "^1|^2|^3"
},
"conflict": {
"symfony/event-dispatcher": "<6.4",
"symfony/event-dispatcher-contracts": "<2.5",
"symfony/http-client-contracts": "<2.5",
"symfony/http-kernel": "<6.4"
},
"require-dev": {
"symfony/event-dispatcher-contracts": "^2.5|^3",
"symfony/http-client-contracts": "^2.5|^3",
"symfony/http-foundation": "^6.4|^7.0",
"symfony/messenger": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Notifier\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Sends notifications via one or more channels (email, SMS, ...)",
"homepage": "https://symfony.com",
"keywords": [
"notification",
"notifier"
],
"support": {
"source": "https://github.com/symfony/notifier/tree/v7.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-05-01T12:12:53+00:00"
},
{
"name": "symfony/ntfy-notifier",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ntfy-notifier.git",
"reference": "094154ba36eac54078a71076effe46feaec59036"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ntfy-notifier/zipball/094154ba36eac54078a71076effe46feaec59036",
"reference": "094154ba36eac54078a71076effe46feaec59036",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/clock": "^6.4|^7.0",
"symfony/http-client": "^6.4|^7.0",
"symfony/notifier": "^7.3"
},
"type": "symfony-notifier-bridge",
"autoload": {
"psr-4": {
"Symfony\\Component\\Notifier\\Bridge\\Ntfy\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mickael Perraud",
"email": "mikaelkael.fr@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony Ntfy Notifier Bridge",
"homepage": "https://symfony.com",
"keywords": [
"Ntfy",
"notifier"
],
"support": {
"source": "https://github.com/symfony/ntfy-notifier/tree/v7.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-02-13T10:27:54+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v7.3.0",

View File

@@ -0,0 +1,13 @@
framework:
notifier:
chatter_transports:
texter_transports:
ntfy: '%notification.ntfy.dsn%'
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }

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

@@ -44,6 +44,10 @@ parameters:
auth.oidc.client_secret: '%env(OIDC_CLIENT_SECRET)%'
auth.oidc.bypass_form_login: '%env(bool:OIDC_BYPASS_FORM_LOGIN)%'
# Notifications
notification.transport: '%env(NOTIFICATION_TRANSPORT)%'
notification.ntfy.dsn: '%env(NTFY_DSN)%'
services:
# default configuration for services in *this* file
_defaults:

View File

@@ -6,7 +6,6 @@ services:
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
tty: true
deploy:
replicas: 2
volumes:
@@ -16,6 +15,10 @@ services:
- mercure_config:/config
depends_on:
- database
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12202"
worker:
@@ -29,6 +32,10 @@ services:
replicas: 2
depends_on:
- app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12203"
scheduler:
@@ -40,6 +47,11 @@ services:
command: -vv
depends_on:
- app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12204"
redis:

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

@@ -38,6 +38,12 @@ final class ConfigResolver
#[Autowire(param: 'auth.oidc.bypass_form_login')]
private ?bool $authOidcBypassFormLogin = null,
#[Autowire(param: 'notification.transport')]
private ?string $notificationTransport = null,
#[Autowire(param: 'notification.ntfy.dsn')]
private ?string $notificationNtfyDsn = null,
) {}
public function validate(): bool
@@ -54,6 +60,12 @@ final class ConfigResolver
$valid = false;
}
if (null !== $this->notificationTransport) {
if (null === $this->notificationNtfyDsn || "" === $this->notificationNtfyDsn) {
$this->messages[] = "Your NOTIFICATION_TRANSPORT is set to 'ntfy' but you don't have the NTFY_DSN environment variable set.";
}
}
return $valid;
}

View File

@@ -135,6 +135,13 @@ 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' => false,
'type' => 'calendar'
],
];
}
}

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

@@ -2,33 +2,53 @@
namespace App\Base\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Twig\Environment;
readonly class Broadcaster
{
public function __construct(
#[Autowire(param: 'notification.transport')]
private string $notificationTransport,
#[Autowire(service: 'twig')]
private Environment $renderer,
private HubInterface $hub,
private RequestStack $requestStack,
private NotifierInterface $notifier,
private LoggerInterface $logger,
) {}
public function alert(string $title, string $message, string $type = "success"): void
public function alert(string $title, string $message, string $type = "success", bool $sendPush = false): void
{
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);
try {
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);
} catch (\Throwable $exception) {
// ToDo: look for better handling to get message to end user
}
if (true === $sendPush && in_array($this->notificationTransport, ['ntfy'])) {
try {
$notification = new Notification($title, ['push'])->content($message);
$this->notifier->send($notification);
} catch (\Throwable $exception) {
$this->logger->error('Unable to send push notification: ' . $exception->getMessage());
}
}
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Download\Downloader;
use App\Base\Service\Broadcaster;
use App\Base\Service\MediaFiles;
use App\Download\Framework\Entity\Download;
use Doctrine\ORM\EntityManagerInterface;
@@ -19,6 +20,7 @@ class ProcessDownloader implements DownloaderInterface
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private CacheInterface $cache,
private readonly Broadcaster $broadcaster,
) {}
/**
@@ -82,6 +84,7 @@ class ProcessDownloader implements DownloaderInterface
});
if ($downloadEntity->getStatus() !== 'Paused') {
$downloadEntity->setProgress(100);
$this->alertComplete($downloadEntity);
}
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
@@ -105,4 +108,15 @@ class ProcessDownloader implements DownloaderInterface
throw new \Exception("There is no download path for media type: $mediaType");
}
private function alertComplete(Download $download): void
{
if ("tvshows" === $download->getMediaType()) {
$message = '"' . $download->getTitle() . '" - ' . $download->getEpisodeId() . ' has finished downloading.';
} else {
$message = '"' . $download->getTitle() . '" has finished downloading.';
}
$this->broadcaster->alert('Success', $message, sendPush: true);
}
}

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

@@ -0,0 +1,55 @@
<?php
namespace App\Torrentio\Client;
use Carbon\Carbon;
use GuzzleHttp\Client as GuzzleClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class HttpClient
{
private GuzzleClient $client;
private string $baseUrl = 'https://torrentio.strem.fun/realdebrid=%s/';
public function __construct(
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private TagAwareCacheInterface $cache,
private LoggerInterface $logger,
) {
$this->client = new GuzzleClient([
'base_uri' => sprintf($this->baseUrl, $this->realDebridKey),
]);
}
public function get(string $imdbId, array $cacheTags = []): array
{
$cacheKey = str_replace(":", ".", "torrentio.{$imdbId}");
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $cacheTags) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
if (count($cacheTags) > 0) {
$item->tag($cacheTags);
}
try {
$response = $this->client->get("stream/movie/$imdbId.json");
return json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
dd($exception);
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
});
}
}

View File

@@ -2,59 +2,19 @@
namespace App\Torrentio\Client;
use App\Torrentio\Client\Rule\DownloadOptionFilter\Resolution;
use App\Torrentio\Client\Rule\RuleEngine;
use App\Torrentio\Result\ResultFactory;
use Carbon\Carbon;
use App\Torrentio\Exception\TorrentioRateLimitException;
use GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
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 $searchUrl;
private Client $client;
public function __construct(
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private TagAwareCacheInterface $cache,
private LoggerInterface $logger,
) {
$this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl);
$this->client = new Client([
'base_uri' => $this->searchUrl,
]);
}
private readonly HttpClient $client,
) {}
public function search(string $imdbCode, string $type, bool $parseResults = true): array
{
$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]);
try {
$response = $this->client->get("$this->searchUrl/$imdbCode.json");
return json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
});
$cacheTags = ['torrentio', $type, $imdbCode];
$results = $this->client->get($imdbCode, $cacheTags);
if (true === $parseResults) {
return $this->parse($results);
@@ -65,26 +25,8 @@ 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"]);
try {
$response = $this->client->get("$this->searchUrl/$imdbId:$season:$episode.json");
return json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
});
$cacheTags = ['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"];
$results = $this->client->get("$imdbId:$season:$episode", $cacheTags);
if (null === $results) {
throw new TorrentioRateLimitException();

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,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,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

@@ -6,8 +6,10 @@ namespace App\User\Framework\Controller\Web;
use App\Base\Service\Broadcaster;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Handler\SaveUserCalendarPreferencesHandler;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserCalendarPreferencesInput;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Database\CountryLanguages;
@@ -33,15 +35,15 @@ 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);
return $this->render(
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
@@ -54,8 +56,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 +71,7 @@ class PreferencesController extends AbstractController
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
@@ -81,6 +84,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 +99,34 @@ 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,
]
);

View File

@@ -327,4 +327,19 @@ 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;
}
}

View File

@@ -232,6 +232,27 @@
"config/packages/messenger.yaml"
]
},
"symfony/notifier": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.0",
"ref": "178877daf79d2dbd62129dd03612cb1a2cb407cc"
},
"files": [
"config/packages/notifier.yaml"
]
},
"symfony/ntfy-notifier": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "0d5e496659d7361bb4e6648eb8332f8cf097533d"
}
},
"symfony/property-info": {
"version": "7.3",
"recipe": {

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

@@ -55,7 +55,11 @@
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert title="Finished downloading" message="{{ entity.title }}" alert_id="{{ entity.id }}" data-controller="alert" />
{% if entity.mediaType == "tvshows" %}
<twig:Alert title="Success" message="{{ entity.title }} - ({{ entity.episodeId }}) has finished downloading." alert_id="{{ entity.id }}" data-controller="alert" />
{% else %}
<twig:Alert title="Success" message="{{ entity.title }} has finished downloading." alert_id="{{ entity.id }}" data-controller="alert" />
{% endif %}
</template>
</turbo-stream>

View File

@@ -9,7 +9,7 @@
<table id="downloads" class="divide-y divide-gray-200 dark:divide-gray-800 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<thead>
<tr class="bg-orange-500/80 text-stone-500 dark:text-stone-800 text-xs font-medium uppercase">
<tr class="bg-orange-500/80 text-gray-800 dark:text-stone-800 text-xs font-medium uppercase">
<th scope="col"
class="px-6 py-3 truncate text-start">
Title
@@ -44,7 +44,7 @@
</tr>
{% endif %}
{% else %}
<tr id="{{ table_body_id }}_no_downloads">
<tr id="{{ table_body_id }}_no_downloads" class="text-black dark:text-white dark:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium" colspan="100%">
No downloads
</td>

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

@@ -8,7 +8,7 @@
{{ stimulus_target('monitor_list', 'monitorList') }}
>
<thead>
<tr class="bg-orange-500/80 text-stone-500 dark:text-stone-800 text-xs font-medium uppercase">
<tr class="bg-orange-500/80 text-gray-800 dark:text-stone-800 text-xs font-medium uppercase">
<th scope="col"
class="px-6 py-3 text-start truncate">
Title
@@ -47,14 +47,14 @@
{% endfor %}
{% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all">
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-500 uppercase text-xs font-medium text-center text-black 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>
{% endif %}
{% else %}
<tr id="active_monitors_no_monitors">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium" colspan="100%">
<td class="px-6 py-4 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900 text-center text-xs uppercase font-medium" colspan="100%">
No monitors
</td>
</tr>

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,25 @@
</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>
{% endblock %}