Compare commits

...

11 Commits

31 changed files with 336 additions and 33 deletions

3
assets/bootstrap.js vendored
View File

@@ -1,5 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover'
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
app.register('popover', Popover);

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0" stroke="currentColor" stroke-dasharray="64" stroke-dashoffset="64" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12c0 -4.97 4.03 -9 9 -9c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9c-4.97 0 -9 -4.03 -9 -9Z"><animate fill="freeze" attributeName="fill-opacity" begin="0.6s" dur="0.15s" values="0;0.3"/><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="64;0"/></path></svg>

After

Width:  |  Height:  |  Size: 517 B

View File

@@ -18,6 +18,7 @@ services:
restart: unless-stopped
volumes:
- $PWD:/app
- $PWD/var/download:/var/download
- mercure_data:/data
- mercure_config:/config
tty: true
@@ -34,6 +35,7 @@ services:
restart: unless-stopped
volumes:
- $PWD:/app
- $PWD/var/download:/var/download
tty: true
command: php /app/bin/console messenger:consume async -vv --time-limit=3600 --limit=10

View File

@@ -9,6 +9,7 @@
"ext-iconv": "*",
"1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12",
"chrisullyott/php-filesize": "^4.2",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-fixtures-bundle": "^4.1",

51
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": "63610a631352051ae8327669536efcef",
"content-hash": "c519733202d45f8fb3a4f5b8e7dfb95b",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -186,6 +186,55 @@
],
"time": "2023-12-11T17:09:12+00:00"
},
{
"name": "chrisullyott/php-filesize",
"version": "v4.2.1",
"source": {
"type": "git",
"url": "https://github.com/chrisullyott/php-filesize.git",
"reference": "967ea3365c00974b50b608ffc045a267ab92ef43"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/chrisullyott/php-filesize/zipball/967ea3365c00974b50b608ffc045a267ab92ef43",
"reference": "967ea3365c00974b50b608ffc045a267ab92ef43",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpunit/phpunit": "^7"
},
"type": "library",
"autoload": {
"psr-4": {
"ChrisUllyott\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Chris Ullyott",
"email": "contact@chrisullyott.com",
"homepage": "http://www.chrisullyott.com"
}
],
"description": "Easily calculate file sizes and convert between units.",
"homepage": "https://github.com/chrisullyott/php-filesize",
"keywords": [
"php",
"size-calculation"
],
"support": {
"issues": "https://github.com/chrisullyott/php-filesize/issues",
"source": "https://github.com/chrisullyott/php-filesize"
},
"time": "2021-10-17T22:52:23+00:00"
},
{
"name": "composer/semver",
"version": "3.4.3",

View File

@@ -5,9 +5,10 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
# Media
media.base_path: '/var/download'
media.default_movies_dir: movies
media.default_tvshows_dir: tvshows
media.movies_path: '%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%'
# Mercure

View File

@@ -3,8 +3,6 @@
frankenphp {
{$FRANKENPHP_CONFIG}
}
}

View File

@@ -28,4 +28,7 @@ return [
'@hotwired/turbo' => [
'version' => '7.3.0',
],
'@stimulus-components/popover' => [
'version' => '7.0.0',
],
];

View File

@@ -33,11 +33,12 @@ final class SearchController extends AbstractController
]);
}
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
#[Route('/result/{mediaType}/{imdbId}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $input,
): Response {
$result = $this->getMediaInfoHandler->handle($input->toCommand());
$this->warmDownloadOptionCache($result->media);
return $this->render('search/result.html.twig', [

View File

@@ -3,8 +3,11 @@
namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository;
use App\Torrentio\Result\ResultFactory;
use App\Torrentio\Result\TorrentioResult;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Nihilarr\PTN;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
@@ -162,4 +165,16 @@ class Download
return $this;
}
public function getPtn(): object
{
$ptn = (object) (new PTN())->parse($this->filename);
if ($this->mediaType === "tvshows") {
$ptn->season = str_pad($ptn->season, 2, "0", STR_PAD_LEFT);
$ptn->episode = str_pad($ptn->episode, 2, "0", STR_PAD_LEFT);
}
return $ptn;
}
}

View File

@@ -25,27 +25,31 @@ class DownloadRepository extends ServiceEntityRepository
$this->paginator = $paginator;
}
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10): Paginator
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10, string $term = ""): Paginator
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->andWhere('(d.title LIKE :term OR d.imdbId LIKE :term)')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete'])
->setParameter('user', $user)
->setParameter('term', '%' . $term . '%')
->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5): Paginator
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5, string $term = ""): Paginator
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->andWhere('(d.title LIKE :term OR d.imdbId LIKE :term)')
->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('user', $user)
->setParameter('term', '%' . $term . '%')
->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage);

View File

@@ -3,14 +3,18 @@
namespace App\Monitor\Service;
use Aimeos\Map;
use Nihilarr\PTN;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class MediaFiles
{
private Finder $finder;
private string $basePath;
private string $moviesPath;
private string $tvShowsPath;
@@ -18,6 +22,9 @@ class MediaFiles
private Filesystem $filesystem;
public function __construct(
#[Autowire(param: 'media.base_path')]
string $basePath,
#[Autowire(param: 'media.movies_path')]
string $moviesPath,
@@ -27,6 +34,7 @@ class MediaFiles
Filesystem $filesystem,
) {
$this->finder = new Finder();
$this->basePath = $basePath;
$this->moviesPath = $moviesPath;
$this->tvShowsPath = $tvShowsPath;
$this->filesystem = $filesystem;
@@ -43,6 +51,11 @@ class MediaFiles
throw new \Exception(sprintf('A path for media type %s does not exist.', $mediaType));
}
public function getBasePath(): string
{
return $this->basePath;
}
public function getMoviesPath(): string
{
return $this->moviesPath;
@@ -125,4 +138,49 @@ class MediaFiles
return $path;
}
public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber)
{
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);
if ($existingEpisodes->isEmpty()) {
return false;
}
/** @var SplFileInfo $episode */
foreach ($existingEpisodes as $episode) {
$ptn = (object) (new PTN())->parse($episode->getFilename());
if ($ptn->season === $seasonNumber && $ptn->episode === $episodeNumber) {
return $episode;
}
}
return false;
}
public function movieExists(string $title)
{
$filepath = $this->moviesPath . DIRECTORY_SEPARATOR . $title;
$directoryExists = $this->filesystem->exists($filepath);
if (false === $directoryExists) {
return false;
}
if (false === $this->finder->in($filepath)->files()->hasResults()) {
return false;
}
$files = Map::from($this->finder->in($filepath)->files())->filter(function (SplFileInfo $file) {
$validExtensions = ['mkv', 'mp4', 'mpeg'];
return in_array($file->getExtension(), $validExtensions);
})->values();
if (false === $files->isEmpty()) {
return $files[0];
}
return false;
}
}

View File

@@ -8,7 +8,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class GetMediaInfoCommand implements CommandInterface
{
public function __construct(
public string $tmdbId,
public string $imdbId,
public string $mediaType,
) {}
}

View File

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

View File

@@ -12,8 +12,8 @@ use OneToMany\RichBundle\Contract\InputInterface;
class GetMediaInfoInput implements InputInterface
{
public function __construct(
#[SourceRoute('tmdbId')]
public string $tmdbId,
#[SourceRoute('imdbId')]
public string $imdbId,
#[SourceRoute('mediaType')]
public string $mediaType,
@@ -21,6 +21,6 @@ class GetMediaInfoInput implements InputInterface
public function toCommand(): CommandInterface
{
return new GetMediaInfoCommand($this->tmdbId, $this->mediaType);
return new GetMediaInfoCommand($this->imdbId, $this->mediaType);
}
}

View File

@@ -133,9 +133,12 @@ class Tmdb
if (!$result instanceof Movie && !$result instanceof Tv) {
continue;
}
$results[] = $this->parseResult($result);
}
$results = array_filter($results, fn ($result) => null !== $result->imdbId);
return $results;
}
@@ -143,7 +146,16 @@ class Tmdb
{
$finder = new Find($this->client);
$result = $finder->findBy($id, ['external_source' => 'imdb_id']);
return $this->parseResult($result);
if (count($result['movie_results']) > 0) {
return $result['movie_results'][0]['id'];
} elseif (count($result['tv_results']) > 0) {
return $result['tv_results'][0]['id'];
} elseif (count($result['tv_episode_results']) > 0) {
return $result['tv_episode_results'][0]['show_id'];
}
throw new \Exception("No results found for $id");
}
public function movieDetails(string $id)
@@ -183,6 +195,8 @@ class Tmdb
public function mediaDetails(string $id, string $type)
{
$id = $this->find($id);
if ($type === "movies") {
return $this->movieDetails($id);
} else {

View File

@@ -2,6 +2,7 @@
namespace App\Torrentio\Action\Handler;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Result\GetMovieOptionsResult;
use App\Torrentio\Client\Torrentio;
@@ -14,12 +15,15 @@ class GetMovieOptionsHandler implements HandlerInterface
public function __construct(
private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->mediaDetails($command->imdbId, 'movies');
return new GetMovieOptionsResult(
media: $this->tmdb->mediaDetails($command->tmdbId, 'movies'),
media: $media,
file: $this->mediaFiles->movieExists($media->title),
results: $this->torrentio->search($command->imdbId, 'movies'),
);
}

View File

@@ -2,6 +2,7 @@
namespace App\Torrentio\Action\Handler;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
@@ -16,12 +17,18 @@ class GetTvShowOptionsHandler implements HandlerInterface
public function __construct(
private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->episodeDetails($command->tmdbId, $command->season, $command->episode);
$parentShow = $this->tmdb->mediaDetails($command->imdbId, 'tvshows');
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
return new GetTvShowOptionsResult(
media: $this->tmdb->episodeDetails($command->tmdbId, $command->season, $command->episode),
media: $media,
file: $file,
season: $command->season,
episode: $command->episode,
results: $this->torrentio->fetchEpisodeResults(

View File

@@ -9,6 +9,7 @@ class GetMovieOptionsResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public bool|\SplFileInfo $file,
public array $results
) {}
}

View File

@@ -4,11 +4,13 @@ namespace App\Torrentio\Action\Result;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Finder\SplFileInfo;
class GetTvShowOptionsResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public bool|SplFileInfo $file,
public string $season,
public string $episode,
public array $results

View File

@@ -18,6 +18,9 @@ final class DownloadList extends AbstractController
use PaginateTrait;
#[LiveProp(writable: true)]
public string $term = "";
#[LiveProp(writable: true)]
public string $type;
@@ -31,9 +34,9 @@ final class DownloadList extends AbstractController
public function getDownloads()
{
if ($this->type === "active") {
return $this->downloadRepository->getActivePaginated($this->getUser(), $this->pageNumber, $this->perPage);
return $this->downloadRepository->getActivePaginated($this->getUser(), $this->pageNumber, $this->perPage, $this->term);
} elseif ($this->type === "complete") {
return $this->downloadRepository->getCompletePaginated($this->getUser(), $this->pageNumber, $this->perPage);
return $this->downloadRepository->getCompletePaginated($this->getUser(), $this->pageNumber, $this->perPage, $this->term);
}
return [];

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Twig\Extensions;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Service\MediaFiles;
use ChrisUllyott\FileSize;
use Twig\Attribute\AsTwigFilter;
class UtilExtension
{
public function __construct(
private readonly MediaFiles $mediaFiles,
) {}
#[AsTwigFilter('filesize')]
public function type(string|int $size)
{
return (new FileSize($size))->asAuto();
}
#[AsTwigFilter('strip_media_path')]
public function stripMediaPath(string $path)
{
return str_replace(
$this->mediaFiles->getBasePath() . DIRECTORY_SEPARATOR,
'',
$path
);
}
}

View File

@@ -11,9 +11,26 @@ module.exports = {
"bg-green-400",
"bg-purple-400",
"bg-orange-400",
"bg-blue-600",
"bg-rose-600",
"transition-opacity",
"ease-in",
"duration-700",
"opacity-100"
],
theme: {
extend: {},
extend: {
animation: {
fade: 'fadeIn .3s ease-in-out',
},
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 },
},
},
},
},
plugins: [],
}

View File

@@ -1,5 +1,12 @@
<div{{ attributes.defaults(stimulus_controller('download_list')) }} class="min-w-48" >
{% set table_body_id = (type == "complete") ? "complete_downloads" : "active_downloads" %}
{% if this.isWidget == false %}
<div class="flex flex-row mb-2 justify-end">
<twig:DownloadSearch search_path="app_search" placeholder="Find one of your downloads..." />
</div>
{% endif %}
<table id="downloads" class="divide-y divide-gray-200 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 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">

View File

@@ -1,6 +1,14 @@
<tr{{ attributes }} id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.title }}
<a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}"
class="hover:underline mr-1"
>
{{ download.title }}
</a>
{% if download.mediaType == "tvshows" %}
&mdash; <span class="ml-1">(S{{ download.ptn.season }}E{{ download.ptn.episode }})</span>
{% endif %}
</td>
{% if isWidget == false %}

View File

@@ -0,0 +1,29 @@
<div {{ attributes }} class="w-full max-w-sm min-w-[200px]">
<div class="relative">
<form>
<input
data-model="term"
class="w-full bg-transparent
placeholder:text-slate-200 text-gray-50
text-sm border-b border-orange-500 pl-3 pr-28 py-2 transition
duration-300 ease focus:outline-none focus:border-orange-400 hover:border-orange-300
shadow-sm focus:shadow"
placeholder="{{ placeholder ?? 'TV Show, Movie...' }}"
/>
<button
class="absolute top-1 right-1 flex items-center
bg-green-600 py-1 px-2 text-center
text-md text-white transition-all
focus:bg-green-700 active:bg-green-700 hover:bg-green-700
text-white bg-green-600 text-xs
rounded-sm
bg-opacity-80
"
onclick="return false;"
>
Search
</button>
</form>
</div>
</div>

View File

@@ -1,13 +1,13 @@
<div{{ attributes }}>
<a href="{{ path('app_search_result', {
mediaType: mediaType,
tmdbId: tmdbId
imdbId: imdbId
}) }}">
<img src="{{ image }}" class="w-40 rounded-md" />
</a>
<a href="{{ path('app_search_result', {
mediaType: mediaType,
tmdbId: tmdbId
imdbId: imdbId
}) }}">
<h3 class="text-center text-gray-50 max-w-[16ch] text-extrabold">{{ title }}</h3>
</a>

View File

@@ -17,7 +17,7 @@
</p>
</div>
<a class="h-9 rounded-md py-1 px-2 bg-green-600 text-gray-50"
href="{{ path('app_search_result', {mediaType: mediaType, tmdbId: tmdbId}) }}"
href="{{ path('app_search_result', {mediaType: mediaType, imdbId: imdbId}) }}"
>choose</a>
</div>
</div>

View File

@@ -22,7 +22,7 @@
<div class="flex flex-col gap-4">
<twig:Card title="Popular Movies" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_movies %}
<twig:Poster imdbId=""
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"
title="{{ movie.title }}"
description="{{ movie.description }}"
@@ -34,7 +34,7 @@
</twig:Card>
<twig:Card title="Popular TV Shows" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_tvshows %}
<twig:Poster imdbId=""
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"
title="{{ movie.title }}"
description="{{ movie.description }}"

View File

@@ -1,4 +1,13 @@
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
{% if results.file != false %}
<div class="p-3 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
<p class="font-bold text-sm text-left">Existing file(s) for this movie:</p>
<ul class="list-disc ml-3">
<li class="font-normal">{{ results.file.realPath|strip_media_path }} &mdash; <strong>{{ results.file.size|filesize }}</strong></li>
</ul>
</div>
{% endif %}
<div class="overflow-hidden rounded-md">
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
</div>

View File

@@ -8,23 +8,59 @@
</div>
{% endif %}
<div class="flex flex-col gap-4 grow">
<h4 class="text-md font-bold">{{ results.episode }}. {{ results.media.title }}</h4>
<h4 class="text-md font-bold">
{{ results.episode }}. {{ results.media.title }}
</h4>
<p>{{ results.media.description }}</p>
<div>
<small class="py-1 px-1.5 grow-0 font-bold bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white"
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white"
{{ stimulus_action('tv-results', 'toggleList', 'click') }}
><span {{ stimulus_target('tv-results', 'count') }}>{{ results.results|length }}</span> results</small>
<small class="py-1 px-1.5 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Air date {{ results.media.episodeAirDate }}"
>{{ results.media.episodeAirDate }}</small>
>
<span {{ stimulus_target('tv-results', 'count') }}>{{ results.results|length }}</span> results
</small>
{% if results.file != false %}
<span data-controller="popover">
<template data-popover-target="content">
<div data-popover-target="card" class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
<p class="font-bold text-sm text-left">Existing file(s) for this episode:</p>
<ul class="list-disc ml-3">
<li class="font-normal">{{ results.file.realPath|strip_media_path }} &mdash; <strong>{{ results.file.size|filesize }}</strong></li>
</ul>
</div>
</template>
<small
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
data-action="mouseenter->popover#show mouseleave->popover#hide"
>
exists
</small>
</span>
{% endif %}
{% if results.file == false %}
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
missing
</small>
{% endif %}
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Air date {{ results.media.episodeAirDate }}">
{{ results.media.episodeAirDate }}
</small>
{# <small class="py-1 px-1.5 grow-0 font-bold bg-red-600 hover:bg-red-700 rounded-lg font-normal text-white cursor-pointer" title="Clear cache for {{ results.media.title }}"#}
{# {{ stimulus_action('tv-results', 'clearCache', 'click') }}#}
{# >Clear Cache</small>#}
</div>
</div>
<div class="flex flex-col gap-4 justify-between">
<input type="checkbox"
{{ stimulus_target('tv-results', 'episodeSelector') }}
/>
<div class="flex flex-col items-center">
<input type="checkbox"
{{ stimulus_target('tv-results', 'episodeSelector') }}
/>
<span title="You have this downloaded!">
<twig:ux:icon width="20" class="mt-2 text-green-600" name="line-md:circle-twotone" />
</span>
</div>
<div class="flex flex-col items-end hover:cursor-pointer"
{{ stimulus_action('tv-results', 'toggleList', 'click') }}>
<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 32 32">