Compare commits

...

19 Commits

Author SHA1 Message Date
a267bab86e chore: cleanup 2025-09-16 22:13:39 -05:00
9653189bff feat: lazy loads torrentio results on episodes, adds loading indicator for episodes and movies 2025-09-16 21:48:23 -05:00
36836c4d36 fix: search results not rendering automatically 2025-09-16 17:03:40 -05:00
61e4b25212 feat: makes events clickable 2025-09-16 13:17:29 -05:00
209266597e dev: uses base images in local dev 2025-09-16 12:19:40 -05:00
ca89eff236 fix: adds .dockerignore, includes .git in deployment.properties 2025-09-15 14:55:32 -05:00
53da7a746b fix: adds app version back to worker & scheduler images 2025-09-15 14:26:15 -05:00
981699bc13 chore: creates base worker image 2025-09-15 13:15:10 -05:00
52f460ff62 fix: missing imdb id on torrentio results 2025-09-15 12:34:04 -05:00
a42e0d4d1a fix: handles internal app version better 2025-09-15 12:33:16 -05:00
3e4a2d9bb1 fix: references correct base image tag 2025-09-15 10:06:35 -05:00
af8a30826c fix: monitors after tmdb updates 2025-09-15 09:41:20 -05:00
09e1c75826 fix: replaces test swarm file with dynamic node port 2025-09-14 23:00:40 -05:00
f1b8b34359 fix: adds deploy.test.compose.yml 2025-09-14 22:53:06 -05:00
6f9db68664 fix: installs wget into worker image 2025-09-14 22:11:58 -05:00
aeb706b5af build: creates base image to speed up build times 2025-09-14 22:10:04 -05:00
7918c260e5 fix: uses base docker image 2025-09-09 16:30:22 -05:00
38130ea0ec fix: typo 2025-09-09 12:11:40 -05:00
da403958dc fix: docker reference 2025-09-09 12:04:14 -05:00
33 changed files with 205 additions and 100 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.idea
.phpunit.cache
.php-cs-fixer.cache
.env.test
.gitignore
bolt.db
bash
build.xml
deploy.compose.yml
phpstan.dist.neon
phpunit.dist.xml
nomad.deploy.hcl
deployment.properties

View File

@@ -1,19 +0,0 @@
FROM dunglas/frankenphp:php8.4
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV APP_VERSION="0.0.1"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
RUN apt update && apt install -y wget
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY --chmod=0755 docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -13,7 +13,6 @@ export default class extends Controller {
}; };
static targets = ['list'] static targets = ['list']
static outlets = ['loading-icon']
options = [] options = []
optionsLoaded = false optionsLoaded = false
@@ -28,7 +27,6 @@ export default class extends Controller {
this.options = this.element.querySelectorAll('tbody tr'); this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue); this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.resultCountEl.innerText = this.options.length; this.resultCountEl.innerText = this.options.length;
this.loadingIconOutlet.toggleIcon();
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}})); document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}}));
} }
} }

View File

@@ -22,7 +22,7 @@ export default class extends Controller {
defaultOptions = '<option value="-">-</option>'; defaultOptions = '<option value="-">-</option>';
static outlets = ['tv-episode-list'] static outlets = ['tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'loadingIcon', 'selectAll', 'downloadSelected'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
static values = { static values = {
'imdbId': String, 'imdbId': String,
'media-type': String, 'media-type': String,
@@ -32,7 +32,6 @@ export default class extends Controller {
async connect() { async connect() {
await this.setInitialFilter(); await this.setInitialFilter();
this.setTimerToStopLoadingIcon();
this.element.filterResults = this.filter.bind(this); this.element.filterResults = this.filter.bind(this);
document.addEventListener('optionsLoaded', this.loadOptions.bind(this)); document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
} }
@@ -48,10 +47,6 @@ export default class extends Controller {
} }
} }
setTimerToStopLoadingIcon() {
setTimeout(() => this.loadingIconTarget.hideIcon(), 10000);
}
// Event is fired from movies/tvshows controllers to populate this data // Event is fired from movies/tvshows controllers to populate this data
async loadOptions({detail: { options }}) { async loadOptions({detail: { options }}) {
await options.forEach((option) => { await options.forEach((option) => {

View File

@@ -38,21 +38,21 @@ export default class extends Controller {
return ` return `
<span data-controller="loading-icon" data-loading-icon-total-value="52" data-loading-icon-count-value="20" class="loading-icon"> <span data-controller="loading-icon" data-loading-icon-total-value="52" data-loading-icon-count-value="20" class="loading-icon">
<svg viewBox="0 0 24 24" fill="currentColor" height="20" width="20" data-loading-icon-target="icon" class="text-end" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 6.99998C9.1747 6.99987 6.99997 9.24998 7 12C7.00003 14.55 9.02119 17 12 17C14.7712 17 17 14.75 17 12"><animateTransform attributeName="transform" attributeType="XML" dur="560ms" from="0,12,12" repeatCount="indefinite" to="360,12,12" type="rotate"></animateTransform></path></svg> <svg viewBox="0 0 24 24" fill="currentColor" height="20" width="20" data-loading-icon-target="icon" class="text-end" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 6.99998C9.1747 6.99987 6.99997 9.24998 7 12C7.00003 14.55 9.02119 17 12 17C14.7712 17 17 14.75 17 12"><animateTransform attributeName="transform" attributeType="XML" dur="560ms" from="0,12,12" repeatCount="indefinite" to="360,12,12" type="rotate"></animateTransform></path></svg>
</span> </span>`;
`;
} }
event.detail.options.render.option = (data, escape) => { event.detail.options.render.option = (data, escape) => {
if (data.data.description.length > 60) { console.log(data);
data.data.description = data.data.description.substring(0, 107) + "..."; if (data.data.overview.length > 60) {
data.data.overview = data.data.overview.substring(0, 107) + "...";
} }
return `<div class="flex flex-row"> return `<div class="flex flex-row">
<img src="${data.data.poster}" class="w-16 rounded-md"> <img src="${data.data.poster}" class="w-16 rounded-md">
<div class="p-2 flex flex-col"> <div class="p-2 flex flex-col">
<h2>${data.data.title}</h2> <h2>${data.data.title}</h2>
<p class="max-w-[60ch] text-wrap">${data.data.description}</p> <p class="max-w-[60ch] text-wrap">${data.data.overview}</p>
</div> </div>
</div> </div>`;
`
} }
} }

View File

@@ -19,7 +19,6 @@ export default class extends Controller {
}; };
static targets = ['list', 'count', 'episodeSelector',] static targets = ['list', 'count', 'episodeSelector',]
static outlets = ['loading-icon']
options = [] options = []
@@ -35,6 +34,5 @@ export default class extends Controller {
this.countTarget.innerText = 0; this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true; this.episodeSelectorTarget.disabled = true;
} }
this.loadingIconOutlet.increaseCount();
} }
} }

View File

@@ -199,6 +199,10 @@ dialog[data-dialog-target="dialog"][closing] {
@apply flex flex-col gap-1 justify-between; @apply flex flex-col gap-1 justify-between;
} }
/** FullCalendar **/
#upcoming_episodes_calendar .fc-event-main .fc-event-title-container {
cursor: pointer !important;
}
.fc-col-header-cell { .fc-col-header-cell {
@apply bg-orange-500/60 text-white; @apply bg-orange-500/60 text-white;
} }

14
bash/build_base.sh Executable file
View File

@@ -0,0 +1,14 @@
# torsearch-app is built from this base
export APP_FRANKENPHP_TAG=php8.4
docker buildx build --platform=linux/amd64 -f docker/Dockerfile.base.app -t code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base:latest --build-arg "FRANKENPHP_TAG=${APP_FRANKENPHP_TAG}" .
docker push code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG}
docker push code.caldwell.digital/home/torsearch-base:latest
# torsearch-worker & torsearch-scheduler are built from this base
export WORKER_FRANKENPHP_TAG=php8.4-alpine
docker buildx build --platform=linux/amd64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base-worker:latest --build-arg "FRANKENPHP_TAG=${WORKER_FRANKENPHP_TAG}" .
docker push code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG}
docker push code.caldwell.digital/home/torsearch-base-worker:latest

View File

@@ -15,7 +15,11 @@ services:
app: app:
build: . build:
dockerfile: docker/Dockerfile.base.app
context: .
args:
FRANKENPHP_TAG: php8.4
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
@@ -33,7 +37,11 @@ services:
worker: worker:
build: . build:
dockerfile: docker/Dockerfile.base.worker
context: .
args:
FRANKENPHP_TAG: php8.4-alpine
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
@@ -41,11 +49,15 @@ services:
tty: true tty: true
environment: environment:
TZ: America/Chicago TZ: America/Chicago
command: php /app/bin/console messenger:consume async -vv --time-limit=3600 command: php /app/bin/console messenger:consume async --time-limit=3600 -vv
scheduler: scheduler:
build: . build:
dockerfile: docker/Dockerfile.base.worker
context: .
args:
FRANKENPHP_TAG: php8.4-alpine
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app

View File

@@ -32,7 +32,7 @@ parameters:
app.cache.redis.host.default: 'redis://redis' app.cache.redis.host.default: 'redis://redis'
# Various configs # Various configs
app.default.version: '0.dev' app.default.version: '0.0.0-dev'
app.default.timezone: 'America/Chicago' app.default.timezone: 'America/Chicago'
# Auth # Auth

View File

@@ -2,7 +2,7 @@ services:
app: app:
image: registry.caldwell.digital/home/torsearch-app:${TAG} image: registry.caldwell.digital/home/torsearch-app:${TAG}
ports: ports:
- '8001:80' - "${SWARM_PORT}:80"
environment: environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'

View File

@@ -10,6 +10,7 @@ templates
var var
vendor vendor
build.xml build.xml
.git
.env .env
.env.local .env.local
composer.json composer.json

View File

@@ -1,19 +1,8 @@
FROM dunglas/frankenphp:php8.4 FROM code.caldwell.digital/home/torsearch-base:php8.4
ENV SERVER_NAME=":80" ARG APP_VERSION="0.0.0-dev"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}" ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app COPY . /app
COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -0,0 +1,14 @@
ARG FRANKENPHP_TAG
FROM dunglas/frankenphp:${FRANKENPHP_TAG}
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache

View File

@@ -0,0 +1,19 @@
ARG FRANKENPHP_TAG
FROM dunglas/frankenphp:${FRANKENPHP_TAG}
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
RUN apk add --no-cache wget

View File

@@ -1,6 +1,9 @@
ARG APP_VERSION FROM code.caldwell.digital/home/torsearch-base-worker:php8.4-alpine
FROM code.caldwell.digital/torsearch/torsearch-app:${APP_VERSION} ARG APP_VERSION="0.0.0-dev"
ENV APP_VERSION="${APP_VERSION}"
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ] ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ]

View File

@@ -1,6 +1,9 @@
ARG APP_VERSION FROM code.caldwell.digital/home/torsearch-base-worker:php8.4-alpine
FROM code.caldwell.digital/torsearch/torsearch-app:${APP_VERSION} ARG APP_VERSION="0.0.0-dev"
ENV APP_VERSION="${APP_VERSION}"
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ] ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]

View File

@@ -2,16 +2,25 @@
namespace App\Base; namespace App\Base;
use App\Base\Dto\AppVersionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class ConfigResolver final class ConfigResolver
{ {
const SEMVER_REGEX = '/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/';
private array $messages = []; private array $messages = [];
public function __construct( public function __construct(
private readonly DenormalizerInterface $denormalizer,
#[Autowire(param: 'app.url')] #[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null, private readonly ?string $appUrl = null,
#[Autowire(param: 'app.version')]
private readonly ?string $appVersion = null,
#[Autowire(param: 'app.debrid.real_debrid.key')] #[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null, private readonly ?string $realDebridApiKey = null,
@@ -92,6 +101,13 @@ final class ConfigResolver
return $this->authOidcBypassFormLogin; return $this->authOidcBypassFormLogin;
} }
public function getAppVersion(): AppVersionDto
{
$matches = [];
preg_match(self::SEMVER_REGEX, $this->appVersion, $matches);
return $this->denormalizer->denormalize($matches, AppVersionDto::class);
}
public function getAuthConfig(): array public function getAuthConfig(): array
{ {
return [ return [

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Base\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class AppVersionDto
{
#[SerializedPath('[1]')]
public string|int $major = 0;
#[SerializedPath('[2]')]
public string|int $minor = 0;
#[SerializedPath('[3]')]
public string|int $patch = 0;
#[SerializedPath('[4]')]
public ?string $pre = null;
#[SerializedPath('[5]')]
public ?string $build = null;
public function __toString()
{
return 'v' . $this->major . '.' . $this->minor . '.' . $this->patch . ($this->pre ? '-' . $this->pre : '') . ($this->build ? '+' . $this->build : '');
}
}

View File

@@ -2,12 +2,14 @@
namespace App\Base\Framework\Controller; namespace App\Base\Framework\Controller;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler; use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\TmdbClient; use App\Tmdb\TmdbClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -46,8 +48,9 @@ final class IndexController extends AbstractController
} }
#[Route('/test')] #[Route('/test')]
public function monitorTvShow(): Response public function monitorTvShow(MonitorTvShowHandler $handler): Response
{ {
// $handler->handle(new MonitorTvShowCommand(82));
return $this->render('index/test.html.twig', []); return $this->render('index/test.html.twig', []);
} }
} }

View File

@@ -42,7 +42,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$monitor = $this->monitorRepository->find($command->movieMonitorId); $monitor = $this->monitorRepository->find($command->movieMonitorId);
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode()); $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()); $episodeData = $this->tmdb->tvEpisodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (null === $monitor->getAirDate() && null !== $episodeData->episodeAirDate && "" !== $episodeData->episodeAirDate) { if (null === $monitor->getAirDate() && null !== $episodeData->episodeAirDate && "" !== $episodeData->episodeAirDate) {
$monitor->setAirDate(Carbon::parse($episodeData->episodeAirDate)); $monitor->setAirDate(Carbon::parse($episodeData->episodeAirDate));

View File

@@ -9,6 +9,7 @@ use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorTvShowResult; use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbClient; use App\Tmdb\TmdbClient;
use Carbon\Carbon; use Carbon\Carbon;
use DateTimeImmutable; use DateTimeImmutable;
@@ -61,8 +62,9 @@ readonly class MonitorTvShowHandler implements HandlerInterface
if ($downloadedEpisodes->count() !== $episodesInShow->count()) { if ($downloadedEpisodes->count() !== $episodesInShow->count()) {
// Dispatch Episode commands for each missing Episode // Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) { foreach ($episodesInShow as $episode) {
/** @var TmdbEpisodeDto $episode */
// Only monitor future episodes // Only monitor future episodes
$this->logger->info('> [MonitorTvShowHandler] Evaluating "' . $monitor->getTitle() . '", season "' . $episode['season_number'] . '" episode "' . $episode['episode_number'] . '"'); $this->logger->info('> [MonitorTvShowHandler] Evaluating "' . $monitor->getTitle() . '", season "' . $episode->seasonNumber . '" episode "' . $episode->episodeNumber . '"');
$episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode); $episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode);
$this->logger->info('> [MonitorTvShowHandler] ...Released after monitor started? ' . (true === $episodeInFuture ? 'YES' : 'NO')); $this->logger->info('> [MonitorTvShowHandler] ...Released after monitor started? ' . (true === $episodeInFuture ? 'YES' : 'NO'));
if (false === $episodeInFuture) { if (false === $episodeInFuture) {
@@ -94,9 +96,9 @@ readonly class MonitorTvShowHandler implements HandlerInterface
->setImdbId($monitor->getImdbId()) ->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle()) ->setTitle($monitor->getTitle())
->setMonitorType('tvepisode') ->setMonitorType('tvepisode')
->setSeason($episode['season_number']) ->setSeason($episode->seasonNumber)
->setEpisode($episode['episode_number']) ->setEpisode($episode->episodeNumber)
->setAirDate($episode['air_date'] !== null && $episode['air_date'] !== "" ? Carbon::parse($episode['air_date']) : null) ->setAirDate($episode->airDate !== null && $episode->airDate !== "" ? Carbon::parse($episode->airDate) : null)
->setCreatedAt(new DateTimeImmutable()) ->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0) ->setSearchCount(0)
->setStatus('New'); ->setStatus('New');
@@ -128,29 +130,29 @@ readonly class MonitorTvShowHandler implements HandlerInterface
); );
} }
private function episodeReleasedAfterMonitorCreated(string|DateTimeImmutable $monitorStartDate, array $episodeInShow): bool private function episodeReleasedAfterMonitorCreated(string|DateTimeImmutable $monitorStartDate, TmdbEpisodeDto $episodeInShow): bool
{ {
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0); $monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
$episodeAirDate = Carbon::parse($episodeInShow['air_date']); $episodeAirDate = Carbon::parse($episodeInShow->airDate);
return $episodeAirDate >= $monitorStartDate; return $episodeAirDate >= $monitorStartDate;
} }
private function episodeExists(array $episodeInShow, Map $downloadedEpisodes): bool private function episodeExists(TmdbEpisodeDto $episodeInShow, Map $downloadedEpisodes): bool
{ {
return $downloadedEpisodes->filter( return $downloadedEpisodes->filter(
fn (object $episode) => $episode->episode === $episodeInShow['episode_number'] fn (object $episode) => $episode->episode === $episodeInShow->episodeNumber
&& $episode->season === $episodeInShow['season_number'] && $episode->season === $episodeInShow->seasonNumber
)->count() > 0; )->count() > 0;
} }
private function monitorExists(Monitor $monitor, array $episode): bool private function monitorExists(Monitor $monitor, TmdbEpisodeDto $episode): bool
{ {
return $this->monitorRepository->findOneBy([ return $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(), 'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(), 'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode', 'monitorType' => 'tvepisode',
'season' => $episode['season_number'], 'season' => $episode->seasonNumber,
'episode' => $episode['episode_number'], 'episode' => $episode->episodeNumber,
'status' => ['New', 'Active', 'In Progress'] 'status' => ['New', 'Active', 'In Progress']
]) !== null; ]) !== null;
} }

View File

@@ -17,7 +17,7 @@ class Torrentio
$results = $this->client->get($imdbCode, $cacheTags); $results = $this->client->get($imdbCode, $cacheTags);
if (true === $parseResults) { if (true === $parseResults) {
return $this->parse($results); return $this->parse($results, $imdbCode);
} }
return $results; return $results;
@@ -33,13 +33,13 @@ class Torrentio
} }
if (true === $parseResults) { if (true === $parseResults) {
return $this->parse($results); return $this->parse($results, $imdbId);
} }
return $results; return $results;
} }
public function parse(array $data): array public function parse(array $data, string $imdbId): array
{ {
$results = []; $results = [];
foreach ($data['streams'] as $stream) { foreach ($data['streams'] as $stream) {
@@ -59,7 +59,8 @@ class Torrentio
$result = ResultFactory::map( $result = ResultFactory::map(
$stream['url'], $stream['url'],
$stream['title'], $stream['title'],
$bingeGroup $bingeGroup,
$imdbId
); );
$results[] = $result; $results[] = $result;

View File

@@ -18,7 +18,8 @@ class ResultFactory
public static function map( public static function map(
string $url, string $url,
string $title, string $title,
string $bingeGroup = "-" string $bingeGroup = "-",
string $imdbId = "-",
) { ) {
$ptn = (object) (new PTN())->parse($title); $ptn = (object) (new PTN())->parse($title);
return new TorrentioResult( return new TorrentioResult(
@@ -40,7 +41,8 @@ class ResultFactory
self::setLanguages($title), self::setLanguages($title),
self::setLanguageFlags($title), self::setLanguageFlags($title),
false, false,
uniqid() uniqid(),
$imdbId,
); );
} }

View File

@@ -23,6 +23,7 @@ class TorrentioResult
public ?array $languages = [], public ?array $languages = [],
public ?string $languageFlags = "-", public ?string $languageFlags = "-",
public ?bool $selected = false, public ?bool $selected = false,
public ?string $localId = "-" public ?string $localId = "-",
public ?string $imdbId = "-",
) {} ) {}
} }

View File

@@ -2,10 +2,13 @@
namespace App\Twig\Extensions; namespace App\Twig\Extensions;
use App\Base\ConfigResolver;
use App\Base\Dto\AppVersionDto;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Torrentio\Action\Result\GetTvShowOptionsResult; use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Twig\Dto\EpisodeIdDto; use App\Twig\Dto\EpisodeIdDto;
use ChrisUllyott\FileSize; use ChrisUllyott\FileSize;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Twig\Attribute\AsTwigFilter; use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction; use Twig\Attribute\AsTwigFunction;
@@ -13,9 +16,16 @@ class UtilExtension
{ {
public function __construct( public function __construct(
private readonly ConfigResolver $config,
private readonly MediaFiles $mediaFiles, private readonly MediaFiles $mediaFiles,
) {} ) {}
#[AsTwigFunction('app_version')]
public function app_version(): AppVersionDto
{
return $this->config->getAppVersion();
}
#[AsTwigFunction('uniqid')] #[AsTwigFunction('uniqid')]
public function uniqid(): string public function uniqid(): string
{ {

View File

@@ -20,7 +20,7 @@
{% block body %}{% endblock %} {% block body %}{% endblock %}
<div class="mt-2 inline-flex gap-4 justify-between text-white"> <div class="mt-2 inline-flex gap-4 justify-between text-white">
<a class="text-sm" href="{{ path('app_login') }}">Sign In</a> <a class="text-sm" href="{{ path('app_login') }}">Sign In</a>
<span class="text-sm">v{{ version }}</span> <span class="text-sm">{{ app_version() }}</span>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -40,14 +40,8 @@
{{ form_end(preferences_form) }} {{ form_end(preferences_form) }}
<div class="flex flex-col md:flex-row justify-between"> <div class="flex flex-col md:flex-row justify-between">
<span
{{ stimulus_target('result-filter', 'loadingIcon') }}
{{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
class="loading-icon">
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" />
</span>
{% if results.media.mediaType == "tvshows" %} {% if results.media.mediaType == "tvshows" %}
<p>Season {{ results.season }}</p>
<div class="flex flex-row gap-2 justify-end px-8"> <div class="flex flex-row gap-2 justify-end px-8">
<twig:Modal heading="Back up a sec!" button_text="Download Season" submit_action="{{ stimulus_action('result_filter', 'downloadSeason', 'click')|stimulus_action('dialog', 'close') }}" button_class="px-1.5 py-1 border border-green-500 bg-green-800/60 rounded-ms text-sm font-semibold" show_cancel show_submit> <twig:Modal heading="Back up a sec!" button_text="Download Season" submit_action="{{ stimulus_action('result_filter', 'downloadSeason', 'click')|stimulus_action('dialog', 'close') }}" button_class="px-1.5 py-1 border border-green-500 bg-green-800/60 rounded-ms text-sm font-semibold" show_cancel show_submit>
Downloading an entire season this way will use the filter from your Downloading an entire season this way will use the filter from your

View File

@@ -55,7 +55,7 @@
</div> </div>
</a> </a>
<p class="px-4 pt-1 inline-flex justify-center"> <p class="px-4 pt-1 inline-flex justify-center">
<small class="text-white text-xs">v{{ version|default('0.0') }}</small> <small class="text-white text-xs">{{ app_version() }}</small>
</p> </p>
</div> </div>
</nav> </nav>

View File

@@ -5,7 +5,6 @@
{% for episode in this.getEpisodes().items %} {% for episode in this.getEpisodes().items %}
<episode-container id="{{ episode_anchor(episode.seasonNumber, episode.episodeNumber) }}" class="results" <episode-container id="{{ episode_anchor(episode.seasonNumber, episode.episodeNumber) }}" class="results"
show-title="{{ this.title }}" show-title="{{ this.title }}"
data-tv-results-loading-icon-outlet=".loading-icon"
data-download-button-outlet=".download-btn" data-download-button-outlet=".download-btn"
{{ stimulus_controller('tv_results', { {{ stimulus_controller('tv_results', {
title: this.title, title: this.title,
@@ -32,7 +31,7 @@
<p>{{ episode.description|truncate }}</p> <p>{{ episode.description|truncate }}</p>
<div class="text-xs font-bold"> <div class="text-xs font-bold">
<button class="results-count-badge py-1 px-1.5 mr-1 grow-0 bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white" title="Click to expand the results table for season {{ episode.seasonNumber }} episode {{ episode.episodeNumber }}."> <button class="results-count-badge py-1 px-1.5 mr-1 grow-0 bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white" title="Click to expand the results table for season {{ episode.seasonNumber }} episode {{ episode.episodeNumber }}.">
<span class="results-count-number" {{ stimulus_target('tv-results', 'count') }}>-</span> results <span class="results-count-number" {{ stimulus_target('tv-results', 'count') }}></span> results
</button> </button>
<span class="py-1 px-1.5 mr-1 grow-0 bg-gray-700 rounded-lg text-white" title='"{{ episode.name }}" aired on {{ episode.airDate|date(null, 'UTC') }}.'> <span class="py-1 px-1.5 mr-1 grow-0 bg-gray-700 rounded-lg text-white" title='"{{ episode.name }}" aired on {{ episode.airDate|date(null, 'UTC') }}.'>
@@ -72,14 +71,16 @@
</div> </div>
</div> </div>
<div class="results-container inline-block overflow-hidden rounded-lg hidden"> <div class="results-container inline-block overflow-hidden rounded-lg hidden">
<twig:Turbo:Frame id="results_{{ episode_id(episode.seasonNumber, episode.episodeNumber) }}" src="{{ path('app_torrentio_tvshows', { <twig:Turbo:Frame loading="lazy" id="results_{{ episode_id(episode.seasonNumber, episode.episodeNumber) }}" src="{{ path('app_torrentio_tvshows', {
tmdbId: this.tmdbId, tmdbId: this.tmdbId,
imdbId: this.imdbId, imdbId: this.imdbId,
season: episode.seasonNumber, season: episode.seasonNumber,
episode: episode.episodeNumber, episode: episode.episodeNumber,
target: 'results_' ~ episode_id(episode.seasonNumber, episode.episodeNumber), target: 'results_' ~ episode_id(episode.seasonNumber, episode.episodeNumber),
block: 'tvshow_results' block: 'tvshow_results'
}) }}" /> }) }}">
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" />
</twig:Turbo:Frame>
</div> </div>
</div> </div>
</episode-container> </episode-container>

View File

@@ -19,7 +19,7 @@
<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"> <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" /> <twig:ux:icon name="lets-icons:calendar-add-light" width="24" class="text-orange-500" />
</a> </a>
<div id="calendar" class="text-white"> <div id="upcoming_episodes_calendar" class="text-white">
</div> </div>
</twig:Card> </twig:Card>
</div> </div>
@@ -33,15 +33,22 @@
} }
} }
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
const modal = document.getElementById('previewModal');
let data = await fetch('/api/monitor/upcoming-episodes'); let data = await fetch('/api/monitor/upcoming-episodes');
data = (await data.json())['data']; data = (await data.json())['data'];
const calendarEl = document.getElementById('calendar'); const calendarEl = document.getElementById('upcoming_episodes_calendar');
const calendar = new FullCalendar.Calendar(calendarEl, { const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: getView(), initialView: getView(),
events: data['episodes'], events: data['episodes'],
windowResize: function(arg) { windowResize: function(arg) {
this.changeView(getView()); this.changeView(getView());
},
eventClick: function (data) {
modal.display({
heading: data.event.title,
content: `<p>${data.event.startStr}</p>`
})
} }
}); });
calendar.render(); calendar.render();

View File

@@ -125,14 +125,15 @@
{% if "movies" == results.media.mediaType %} {% if "movies" == results.media.mediaType %}
<movie-container class="results" <movie-container class="results"
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }} {{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
data-movie-results-loading-icon-outlet=".loading-icon"
> >
<twig:Turbo:Frame id="movie_results_frame" src="{{ path('app_torrentio_movies', { <twig:Turbo:Frame id="movie_results_frame" src="{{ path('app_torrentio_movies', {
tmdbId: results.media.tmdbId, tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId, imdbId: results.media.imdbId,
target: 'movie_results_frame', target: 'movie_results_frame',
block: 'movie_results' block: 'movie_results'
}) }}" /> }) }}">
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" title="Loading download options for {{ results.media.title }}" />
</twig:Turbo:Frame>
</movie-container> </movie-container>
{% elseif "tvshows" == results.media.mediaType %} {% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList <twig:TvEpisodeList
@@ -151,8 +152,6 @@
</div> </div>
<twig:Card title="Related Media" contentClass="flex flex-col gap-4 text-white"> <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"> <div class="grid grid-cols-2 gap-4 md:flex flex-col md:flex-row justify-between w-full">
{% for media in results.relatedMedia %} {% for media in results.relatedMedia %}
<twig:Poster imdbId="{{ media.imdbId }}" <twig:Poster imdbId="{{ media.imdbId }}"

View File

@@ -52,7 +52,7 @@
provider="{{ result.provider }}" provider="{{ result.provider }}"
languages="{{ result.languages|json_encode }}" languages="{{ result.languages|json_encode }}"
media-type="{{ results.media.mediaType }}" media-type="{{ results.media.mediaType }}"
imdb-id="{{ results.media.imdbId }}" imdb-id="{{ result.imdbId }}"
filename="{{ result.filename }}" filename="{{ result.filename }}"
data-local-id="{{ result.localId }}" data-local-id="{{ result.localId }}"
{% if "tvshows" == results.media.mediaType %} {% if "tvshows" == results.media.mediaType %}