Compare commits

..

8 Commits

Author SHA1 Message Date
Brock H Caldwell
939b059872 fix: adds download url check for monitors
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m0s
2026-02-06 19:41:09 -06:00
Brock H Caldwell
f968e7e622 feat: allows configuring whether to cache torrentio results
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-02-06 15:23:39 -06:00
Brock H Caldwell
7958f50ff7 fix: includes missing files from last commit 2026-02-06 15:23:18 -06:00
Brock H Caldwell
f4644d40ef feat: notifies user of bad RD download (failed for copyright, etc.)
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-02-06 09:55:19 -06:00
Brock H Caldwell
37516c7f02 fix: closes modal when clicking dismiss button
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -59s
2026-02-06 09:51:07 -06:00
Brock H Caldwell
c7956f5f0b fix: pushes alert dismiss button to end of div 2026-02-06 09:47:14 -06:00
Brock H Caldwell
fdf8714033 fix: supports random mysql root password 2026-02-05 18:51:06 -06:00
Brock H Caldwell
0e667fc7aa chore: updates example compose
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 14s
2026-01-25 12:57:25 -06:00
16 changed files with 262 additions and 121 deletions

3
.env
View File

@@ -68,3 +68,6 @@ SENTRY_JS_URL=
# - only include media originally
# produced in this language
TMDB_ORIGINAL_LANGUAGE=en
# Cache Torrentio Results
TORRENTIO_CACHE_RESULTS=true

View File

@@ -14,5 +14,6 @@ export default class extends Controller {
"3000"
));
this.element.addEventListener('mouseover', () => clearTimeout(timer));
this.element.querySelector('.modal-close').addEventListener('click', () => this.element.remove());
}
}

View File

@@ -52,6 +52,9 @@ parameters:
sentry.dsn: '%env(SENTRY_DSN)%'
sentry.javascript_url: '%env(SENTRY_JS_URL)%'
# Torrentio
torrentio.cache_results: '%env(bool:TORRENTIO_CACHE_RESULTS)%'
services:
# default configuration for services in *this* file
_defaults:

View File

@@ -1,58 +1,99 @@
# App must be served over HTTPS (requirement of Mercure)
# Either serve behind an SSL terminating reverse proxy
# or pass your certificates into the 'app' container.
# Please omit any trailing slashes. The APP_URL is
# used to generate the Mercure URL behind the scenes.
APP_URL="https://dev.caldwell.digital"
###################
# Torsearch #
###################
# The version of Torsearch to run. Docker will this tag.
TAG=0.38.0
# The port for which the web server (app container) will
# serve the application on the host.
WEB_PORT=8004
# The host directories where your media is stored.
# If you would like to use a docker driver for a network
# share, update the compose.yml file to reflect that.
LOCAL_MOVIES_DIR="<enter movies dir>"
LOCAL_TVSHOWS_DIR="<enter tvshows dir>"
# Set the timezone you're in. This helps render monitored items correctly on the calendar
TZ=America/Chicago
###################
# Symfony #
###################
# The external URL of the application where it can be reached by a browser.
APP_URL="<enter url>"
# Requried by Symfony Framework. Feel free to change.
APP_SECRET="70169beadfbc8101c393cbfbba27a313"
# Change to 'dev' to show logs in the browser.
APP_ENV=prod
###################
# Mercure #
###################
# Mercure is a Caddy module built into the webserver
# that facilitates the usage of websockets to transmit
# real time data (download progress, etc.)
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
###################
# Database #
###################
# Use the DATABASE_URL below to use the MariaDB container
# provided in the example.compose.yml file, or remove this
# line and fill in the details of your own MySQL/MariaDB server
DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
MYSQL_RANDOM_ROOT_PASSWORD=true
MYSQL_DATABASE=app
MYSQL_USER=app
MYSQL_PASSWORD=password
MYSQL_HOST=database
DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}:3306/${MYSQL_DATABASE}?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# Fill in your MySQL/MariaDB connection details
#DATABASE_URL="mysql://<mysql user>:<mysql pass>@<mysql host>:3306/<mysql db name>?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
# Enter your Real Debrid API key
# This key is never saved anywhere
# else and is passed to Torrentio
# to retrieve download options
REAL_DEBRID_KEY=""
###################
# Real Debrid #
###################
# Enter your Real Debrid API key is passed to Torrentio to retrieve download options
REAL_DEBRID_KEY="<enter real debrid api key>"
###################
# TMDB #
###################
# Enter your TMDB API key
# This is used to provide rich search results
# when searching for media and rendering the
# Popular Movies and TV Shows section.
TMDB_API=""
# This is used to provide rich search results when searching
# for media and rendering the Popular Movies and TV Shows section.
TMDB_API="<enter tmdb api key>"
# Use your own Redis instance or use the
# below value to use the container included
# in the example compose.yml file.
###################
# Redis #
###################
# Use your own Redis instance or use the below value to use the
# container included in the example compose.yml file.
REDIS_HOST="redis://redis"
### Auth ###
# Change to "oidc" to and provide the required
# environment variables below to use OIDC auth.
###################
# Auth #
###################
# Options: form_login, oidc, or ldap (experimental)
# Fill the rest of the configuration based on your choice here
# No additional config is required for form_login
AUTH_METHOD=form_login
# OIDC
OIDC_WELL_KNOWN_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# Allows you to skip the login page and directly
# rely on your IdP for auth.
OIDC_BYPASS_FORM_LOGIN=
### OIDC ###
#OIDC_WELL_KNOWN_URL=
#OIDC_CLIENT_ID=
#OIDC_CLIENT_SECRET=
# Allows you to skip the login page and directly rely on your IdP for auth.
#OIDC_BYPASS_FORM_LOGIN=
# LDAP Config: To use LDAP, enter the below fields
# and run 'php bin/console config:set auth.method ldap'
### LDAP (*** Experimental! ***) ###
# LDAP Config: To use LDAP, enter the below fields and run 'php bin/console config:set auth.method ldap'
# (LDAP is still in progress and not ready for use)
#LDAP_HOST=
#LDAP_PORT=

View File

@@ -1,78 +1,55 @@
services:
### The app contains the application and web server ###
app:
image: code.caldwell.digital/home/torsearch-app:latest
image: code.caldwell.digital/home/torsearch-app:${TAG}
ports:
- '8006:80'
env_file:
- .env
volumes:
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
- "${WEB_PORT}:80"
env_file: .env
depends_on:
database:
condition: service_healthy
- database
volumes:
- ${LOCAL_MOVIES_DIR}:/var/download/movies
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
- mercure_data:/data
- mercure_config:/config
# Downloads happen in this container. Replicate this
# container to run multiple downloads simultaneously.
# Map your "movies" folder to /var/download/movies
# Map your "TV shows" folder to /var/download/tvshows
# If your folders are on another machine, use an NFS volume.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
### The worker handles downloads and async jobs ###
worker:
image: code.caldwell.digital/home/torsearch-worker:latest
image: code.caldwell.digital/home/torsearch-worker:${TAG}
volumes:
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
command: -vv --time-limit=3600 --limit=10
env_file:
- .env
restart: always
- ${LOCAL_MOVIES_DIR}:/var/download/movies
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
env_file: .env
depends_on:
app:
condition: service_healthy
- app
# This container handles the monitoring for new media. When new
# monitors are added, jobs are periodically dispatched to this
# container, and the desired media is searched for and downloaded.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
### The scheduler processes monitored media ###
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest
image: code.caldwell.digital/home/torsearch-scheduler:${TAG}
volumes:
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
env_file:
- .env
command: -vv
environment:
TZ: America/Chicago
restart: always
- ${LOCAL_MOVIES_DIR}:/var/download/movies
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
env_file: .env
depends_on:
app:
condition: service_healthy
- app
#!! If using your own database, this can be omitted !!#
database:
image: mariadb:10.11.2
volumes:
- mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
env_file: .env
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
test: [ "CMD", "mysqladmin", "-u", "${MYSQL_USER}", "-p", "${MYSQL_PASSWORD}" ,"ping", "-h", "localhost" ]
interval: 5s
timeout: 5s
retries: 10
#!! If using your own redis, this can be omitted !!#
redis:
image: redis:latest
volumes:
@@ -80,12 +57,6 @@ services:
command: redis-server --maxmemory 512MB
restart: unless-stopped
# **Optional**
# Provides a simple method of viewing the database
adminer:
image: adminer
ports:
- "8081:8080"
volumes:
mysql:

View File

@@ -4,6 +4,7 @@ namespace App\Base;
use App\Base\Dto\AppVersionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class ConfigResolver
@@ -14,6 +15,7 @@ final class ConfigResolver
public function __construct(
private readonly DenormalizerInterface $denormalizer,
private readonly RequestStack $requestStack,
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
@@ -62,6 +64,9 @@ final class ConfigResolver
#[Autowire(param: 'sentry.javascript_url')]
private ?string $sentryJavascriptUrl = null,
#[Autowire(param: 'torrentio.cache_results')]
private ?bool $torrentioCacheResults = null,
) {}
public function validate(): bool
@@ -110,6 +115,11 @@ final class ConfigResolver
return $this->authOidcBypassFormLogin;
}
public function isTorrentioCacheEnabled(): bool
{
return $this->torrentioCacheResults;
}
public function getAppVersion(): AppVersionDto
{
$matches = [];

View File

@@ -24,10 +24,10 @@ readonly class Broadcaster
private LoggerInterface $logger,
) {}
public function alert(string $title, string $message, string $type = "success", bool $sendPush = false): void
public function alert(string $title, string $message, string $type = "success", bool $sendPush = false, ?string $mercureAlertTopic = null): void
{
try {
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$userAlertTopic = $mercureAlertTopic ?? $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
@@ -39,7 +39,7 @@ readonly class Broadcaster
);
$this->hub->publish($update);
} catch (\Throwable $exception) {
// ToDo: look for better handling to get message to end user
$this->logger->error('Unable to publish alert: ' . $exception->getMessage());
}
if (true === $sendPush && in_array($this->notificationTransport, ['ntfy'])) {

View File

@@ -17,5 +17,6 @@ class DownloadMediaCommand implements CommandInterface
public string $imdbId,
public int $userId,
public ?int $downloadId = null,
public ?string $mercureAlertTopic = null,
) {}
}

View File

@@ -2,9 +2,12 @@
namespace App\Download\Action\Handler;
use App\Base\Enum\MediaType;
use App\Base\Service\Broadcaster;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\DownloadEvents;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface;
use App\EventLog\Action\Command\AddEventLogCommand;
@@ -21,8 +24,8 @@ readonly class DownloadMediaHandler implements HandlerInterface
public function __construct(
private MessageBusInterface $bus,
private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository, private Broadcaster $broadcaster,
) {}
public function handle(CommandInterface $command): ResultInterface
@@ -49,6 +52,16 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId);
}
try {
$this->validateDownloadUrl($download->getUrl());
} catch (\Throwable $exception) {
$download->setProgress(100);
$download->setStatus('Failed');
$this->downloadRepository->getEntityManager()->flush();
$this->sendFailedDownloadAlert($download, $command->mercureAlertTopic, $exception->getMessage());
return new DownloadMediaResult(400, $exception->getMessage());
}
try {
if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
@@ -77,4 +90,30 @@ readonly class DownloadMediaHandler implements HandlerInterface
));
return new DownloadMediaResult(200, "Success.");
}
public function validateDownloadUrl(string $downloadUrl)
{
$badFileSizes = [
2119075, // copyright infringement
];
$badFileLocations = [
'https://torrentio.strem.fun/videos/failed_infringement_v2.mp4' => 'Removed for Copyright Infringement.',
];
$headers = get_headers($downloadUrl, true);
if (array_key_exists($headers['Location'], $badFileLocations)) {
throw new \Exception($badFileLocations[$headers['Location']]);
}
}
private function sendFailedDownloadAlert(Download $download, string $mercureAlertTopic, ?string $message = null): void
{
$this->broadcaster->alert(
title: 'Download Failed',
message: $message ?? "{$download->getTitle()} failed to download.",
type: 'warning',
mercureAlertTopic: $mercureAlertTopic
);
}
}

View File

@@ -10,6 +10,8 @@ use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DownloadMediaInput> */
class DownloadMediaInput implements InputInterface
{
public ?string $mercureAlertTopic = null;
public function __construct(
#[SourceRequest('url')]
public string $url,
@@ -44,6 +46,7 @@ class DownloadMediaInput implements InputInterface
$this->imdbId,
$this->userId,
$this->downloadId,
$this->mercureAlertTopic,
);
}
}

View File

@@ -25,6 +25,10 @@ class DownloadOptionEvaluator
return false;
}
if (false === $this->validateDownloadUrl($result->url)) {
return false;
}
return true;
});
@@ -79,4 +83,18 @@ class DownloadOptionEvaluator
return false;
}
public function validateDownloadUrl(string $downloadUrl)
{
$badFileLocations = [
'https://torrentio.strem.fun/videos/failed_infringement_v2.mp4' => 'Removed for Copyright Infringement.',
];
$headers = get_headers($downloadUrl, true);
if (array_key_exists($headers['Location'], $badFileLocations)) {
return false;
}
return true;
}
}

View File

@@ -15,6 +15,7 @@ use App\Download\DownloadEvents;
use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -22,9 +23,9 @@ use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly Broadcaster $broadcaster,
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly Broadcaster $broadcaster, private readonly RequestStack $requestStack,
) {}
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
@@ -42,6 +43,7 @@ class ApiController extends AbstractController
);
$input->downloadId = $download->getId();
$input->userId = $this->getUser()->getId();
$input->mercureAlertTopic = $this->requestStack->getSession()->get('mercure_alert_topic');
$this->bus->dispatch(new AddEventLogCommand(
$this->getUser(),

View File

@@ -98,6 +98,14 @@ class DownloadRepository extends ServiceEntityRepository
return $download;
}
public function updateProgress(int $id, int $progress): Download
{
$download = $this->find($id);
$download->setProgress($progress);
$this->getEntityManager()->flush();
return $download;
}
public function delete(int $id)
{
$entity = $this->find($id);
@@ -115,4 +123,16 @@ class DownloadRepository extends ServiceEntityRepository
->getQuery()
->getResult();
}
public function badDownloadUrls()
{
return $this->createQueryBuilder('d')
->select('d.url')
->andWhere('d.status = :status')
->andWhere('d.progress = 100')
->setParameter('status', 'Failed')
->distinct()
->getQuery()
->getResult();
}
}

View File

@@ -2,14 +2,25 @@
namespace App\Torrentio\Client;
use Aimeos\Map;
use App\Download\Framework\Repository\DownloadRepository;
use App\Torrentio\Result\ResultFactory;
use App\Torrentio\Exception\TorrentioRateLimitException;
use Psr\Log\LoggerInterface;
class Torrentio
{
private array $badDownloadUrls = [];
public function __construct(
private readonly HttpClient $client,
) {}
private readonly HttpClient $client,
private readonly DownloadRepository $downloadRepository, private readonly LoggerInterface $logger,
) {
$badDownloadUrls = $this->downloadRepository->badDownloadUrls();
$this->badDownloadUrls = Map::from($badDownloadUrls)
->map(fn ($url) => $url['url'])
->toArray();
}
public function search(string $imdbCode, string $type, bool $parseResults = true): array
{
@@ -47,6 +58,16 @@ class Torrentio
continue;
}
$url = explode('/', $stream['url']);
$filename = urldecode(end($url));
$url[count($url) - 1] = $filename;
$url = implode('/', $url);
if (in_array($stream['url'], $this->badDownloadUrls)) {
$this->logger->warning($stream['url'] . ' was skipped because it was identified as a bad download url.');
continue;
}
if (
array_key_exists('behaviorHints', $stream) &&
array_key_exists('bingeGroup', $stream['behaviorHints'])

View File

@@ -2,6 +2,7 @@
namespace App\Torrentio\Framework\Controller;
use App\Base\ConfigResolver;
use App\Base\Service\Broadcaster;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
@@ -27,6 +28,7 @@ final class WebController extends AbstractController
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly ConfigResolver $configResolver,
private readonly Broadcaster $broadcaster,
) {}
@@ -40,10 +42,14 @@ final class WebController extends AbstractController
$input->imdbId
);
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getMovieOptionsHandler->handle($input->toCommand());
});
if (true === $this->configResolver->isTorrentioCacheEnabled()) {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getMovieOptionsHandler->handle($input->toCommand());
});
} else {
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
}
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
@@ -66,10 +72,14 @@ final class WebController extends AbstractController
);
try {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getTvShowOptionsHandler->handle($input->toCommand());
});
if (true === $this->configResolver->isTorrentioCacheEnabled()) {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getTvShowOptionsHandler->handle($input->toCommand());
});
} else {
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
}
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);

View File

@@ -2,16 +2,14 @@
class="alert alert-{{ type|default('success') }}"
role="alert"
>
<div class="flex items-center">
<svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
</svg>
<h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3>
<twig:ux:icon name="ic:twotone-cancel" style="text-align:right" width="16.75px" height="16.75px" class="modal-close rounded-full align-end text-red-600 hover:text-red-700" />
<span class="sr-only">Info</span>
<div class="flex justify-between items-center">
<div class="flex items-center">
<svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
</svg>
<h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3>
</div>
<twig:ux:icon name="ic:twotone-cancel" width="16.75px" height="16.75px" class="modal-close rounded-full align-end text-red-600 hover:text-red-700" />
</div>
<div class="mt-2 text-sm w-[300px] font-bold overflow-hidden text-wrap">
{{ message }}