Compare commits

..

9 Commits

29 changed files with 509 additions and 385 deletions

9
assets/bootstrap.js vendored
View File

@@ -1,9 +1,12 @@
import EpisodeContainer from './components/episode-container.js';
import DownloadOptionTr from './components/download-option-tr.js';
import MovieContainer from "./components/movie-container.js";
import { startStimulusApp } from '@symfony/stimulus-bundle'; import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover'; import Popover from '@stimulus-components/popover';
import Dialog from '@stimulus-components/dialog'; import Dialog from '@stimulus-components/dialog';
import Dropdown from '@stimulus-components/dropdown'; import Dropdown from '@stimulus-components/dropdown';
import 'animate.css'; import 'animate.css';
import Brock from './components/brock.js';
const app = startStimulusApp(); const app = startStimulusApp();
// register any custom, 3rd party controllers here // register any custom, 3rd party controllers here
@@ -11,4 +14,6 @@ app.register('popover', Popover);
app.register('dialog', Dialog); app.register('dialog', Dialog);
app.register('dropdown', Dropdown); app.register('dropdown', Dropdown);
customElements.define('brock-app', Brock); customElements.define('episode-container', EpisodeContainer);
customElements.define('movie-container', MovieContainer);
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});

View File

@@ -1,25 +0,0 @@
export default class Brock extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.render();
}
render() {
this.innerHTML = `
Yo, yo, yo! Waddup ${this.name}, doe, it's Brocky fresh!
`;
}
// attribute change
attributeChangedCallback(property, oldValue, newValue) {
if (oldValue === newValue) return;
this[ property ] = newValue;
this.render();
}
static get observedAttributes() {
return ['name'];
}
}

View File

@@ -0,0 +1,124 @@
export default class DownloadOptionTr extends HTMLTableRowElement {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
#downloadBtnEl;
#selectEpisodeInputEl;
url;
size;
quality;
resolution;
codec;
seeders;
provider;
languages;
mediaType;
season;
episode;
filename;
imdbId;
episodeId;
mediaTitle;
constructor() {
super();
this.url = this.getAttribute('url');
this.size = this.getAttribute('size');
this.quality = this.getAttribute('quality');
this.resolution = this.getAttribute('resolution');
this.codec = this.getAttribute('codec');
this.seeders = this.getAttribute('seeders');
this.provider = this.getAttribute('provider');
this.filename = this.getAttribute('filename');
this.imdbId = this.getAttribute('imdb-id');
this.languages = JSON.parse(this.getAttribute('languages'));
this.mediaType = this.getAttribute('media-type');
this.mediaTitle = this.getAttribute('media-title');
this.season = this.getAttribute('season') ?? null;
this.episode = this.getAttribute('episode') ?? null;
this.episodeId = this.getAttribute('episode-id') ?? null;
this.#downloadBtnEl = this.querySelector('.download-btn');
this.#selectEpisodeInputEl = this.querySelector('input[type="checkbox"]');
this.#downloadBtnEl.addEventListener('click', () => this.download());
}
get isSelected() {
return this.#selectEpisodeInputEl.checked;
}
set isSelected(value) {
this.#selectEpisodeInputEl.checked = value;
}
filter({ detail: { activeFilter } }) {
const optionHeader = document.querySelector(`[data-option-id="${this.dataset['localId']}"]`)
const props = {
"resolution": this.resolution.trim(),
"codec": this.codec.trim(),
"provider": this.provider.trim(),
"languages": this.languages,
"quality": this.quality,
}
let include = true;
this.classList.add('r-tablerow');
this.classList.remove('hidden');
optionHeader.classList.add('r-tablerow');
optionHeader.classList.remove('hidden');
this.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
this.classList.remove('r-tablerow');
this.classList.add('hidden');
optionHeader.classList.remove('r-tablerow');
optionHeader.classList.add('hidden');
}
return include;
}
download() {
fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
url: this.url,
title: this.mediaTitle,
filename: this.filename,
mediaType: this.mediaType,
imdbId: this.imdbId,
episodeId: this.episodeId
})
})
.then(res => res.json())
.then(json => {
console.log(json)
})
}
}

View File

@@ -0,0 +1,76 @@
export default class EpisodeContainer extends HTMLElement {
options = [];
showTitle;
#episodeSelectorEl;
#resultsToggleBtnEl;
#resultsTableEl;
#resultsCountBadgeEl;
#resultsCountNumberEl;
constructor() {
super();
this.showTitle = this.getAttribute('show-title');
this.#resultsTableEl = this.querySelector('.results-container');
this.#resultsToggleBtnEl = this.querySelector('.dropdown-button');
this.#resultsCountBadgeEl = this.querySelector('.results-count-badge');
this.#resultsCountNumberEl = this.querySelector('.results-count-number');
this.#episodeSelectorEl = this.querySelector('.episode-selector');
this.#resultsToggleBtnEl.addEventListener('click', () => this.toggleResults());
this.#resultsCountBadgeEl.addEventListener('click', () => this.toggleResults());
document.addEventListener('filterDownloadOptions', this.filter.bind(this));
document.addEventListener('downloadSelectedEpisodes', this.downloadSelectedResults.bind(this));
document.addEventListener('selectEpisodeForDownload', (e) => this.selectEpisodeForDownload(e.detail.select));
}
toggleResults() {
this.#resultsToggleBtnEl.classList.toggle('rotate-90');
this.#resultsToggleBtnEl.classList.toggle('-rotate-90');
this.#resultsTableEl.classList.toggle('hidden');
}
selectEpisodeForDownload(select) {
if (this.#episodeSelectorEl.disabled === false) {
this.#episodeSelectorEl.checked = select;
}
}
downloadSelectedResults() {
if (this.#episodeSelectorEl.disabled === false &&
this.#episodeSelectorEl.checked === true
) {
console.log('episode is selected')
this.options.forEach(option => {
if (option.isSelected === true) {
option.download();
}
option.isSelected = false;
})
}
}
filter({ detail: { activeFilter } }) {
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const include = option.filter({ detail: { activeFilter: activeFilter } });
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
this.#resultsCountNumberEl.innerText = count;
}
}

View File

@@ -0,0 +1,36 @@
export default class MovieContainer extends HTMLElement {
#resultsTableEl;
#resultsCountNumberEl;
constructor() {
super();
this.#resultsTableEl = this.querySelector('.results-container');
this.#resultsCountNumberEl = document.querySelector('.results-count-number');
document.addEventListener('filterDownloadOptions', this.filter.bind(this));
}
filter({ detail: { activeFilter } }) {
const options = this.querySelectorAll('tr.download-option');
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
options.forEach((option) => {
const include = option.filter({ detail: { activeFilter: activeFilter } });
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
this.#resultsCountNumberEl.innerText = count;
}
}

View File

@@ -6,9 +6,6 @@ import { Controller } from '@hotwired/stimulus';
*/ */
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = { static values = {
title: String, title: String,
tmdbId: String, tmdbId: String,
@@ -30,73 +27,8 @@ export default class extends Controller {
this.optionsLoaded = true; this.optionsLoaded = true;
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.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.toggleIcon();
this.resultCountEl.innerText = this.options.length; this.resultCountEl.innerText = this.options.length;
} this.loadingIconOutlet.toggleIcon();
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}}));
// Keeps compatible with Filter & TV Shows
isActive() {
return true;
}
async filter(activeFilter) {
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const optionHeader = document.querySelector(`[data-option-id="${option.dataset['localId']}"]`)
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"quality": option.dataset['quality'],
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden');
optionHeader.classList.add('r-tablerow');
optionHeader.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
optionHeader.classList.remove('r-tablerow');
optionHeader.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
this.resultCountEl.innerText = count;
} }
} }

View File

@@ -6,9 +6,6 @@ import { Controller } from '@hotwired/stimulus';
*/ */
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
languages = [] languages = []
providers = [] providers = []
qualities = [] qualities = []
@@ -22,7 +19,7 @@ export default class extends Controller {
"quality": "", "quality": "",
} }
static outlets = ['movie-results', 'tv-results', 'tv-episode-list'] static outlets = ['tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
static values = { static values = {
'imdbId': String, 'imdbId': String,
@@ -36,20 +33,34 @@ export default class extends Controller {
this.activeFilter['season'] = 1; this.activeFilter['season'] = 1;
} }
await this.filter(); await this.filter();
document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
} }
// 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) => {
this.addLanguages(option, option.dataset); this.addLanguages(option);
this.addProviders(option, option.dataset); this.addProviders(option);
this.addQualities(option, option.dataset); this.addQualities(option);
}) })
await this.filter(); await this.filter();
} }
addLanguages(option, props) { selectAllEpisodes() {
const languages = Object.assign([], JSON.parse(props['languages'])); document.dispatchEvent(new CustomEvent('selectEpisodeForDownload', {
detail: {
select: this.selectAllTarget.checked,
}
}));
}
downloadSelectedEpisodes() {
document.dispatchEvent(new CustomEvent('downloadSelectedEpisodes', {}));
}
addLanguages(option) {
const languages = Object.assign([], option.languages);
languages.forEach((language) => { languages.forEach((language) => {
if (!this.languages.includes(language)) { if (!this.languages.includes(language)) {
this.languages.push(language); this.languages.push(language);
@@ -75,9 +86,9 @@ export default class extends Controller {
.join(); .join();
} }
addProviders(option, props) { addProviders(option) {
if (!this.providers.includes(props['provider'])) { if (!this.providers.includes(option.provider)) {
this.providers.push(props['provider']); this.providers.push(option.provider);
} }
const preferred = this.providerTarget.dataset.preferred; const preferred = this.providerTarget.dataset.preferred;
@@ -100,10 +111,10 @@ export default class extends Controller {
} }
addQualities(option, props) { addQualities(option) {
if (!this.qualities.includes(props['quality'])) { if (!this.qualities.includes(option.quality)) {
if (props['quality'].toLowerCase() in this.reverseMappedQualitiesValue) { if (option.quality.toLowerCase() in this.reverseMappedQualitiesValue) {
this.qualities.push(props['quality']); this.qualities.push(option.quality);
} }
} }
@@ -128,9 +139,7 @@ export default class extends Controller {
async filter() { async filter() {
const downloadSeasonSpan = document.querySelector("#downloadSeasonModal"); const downloadSeasonSpan = document.querySelector("#downloadSeasonModal");
const currentSeason = this.activeFilter['season'];
let results = [];
this.activeFilter = { this.activeFilter = {
"resolution": this.resolutionTarget.value, "resolution": this.resolutionTarget.value,
"codec": this.codecTarget.value, "codec": this.codecTarget.value,
@@ -139,26 +148,26 @@ export default class extends Controller {
"quality": this.qualityTarget.value, "quality": this.qualityTarget.value,
} }
if ("movies" === this.mediaTypeValue) { if ("tvshows" === this.mediaTypeValue) {
results = this.movieResultsOutlets; downloadSeasonSpan.innerText = this.seasonTarget.value;
await results.forEach((list) => list.filter(this.activeFilter));
} else if ("tvshows" === this.mediaTypeValue) {
results = this.tvResultsOutlets;
this.activeFilter.season = this.seasonTarget.value; this.activeFilter.season = this.seasonTarget.value;
downloadSeasonSpan.innerText = this.activeFilter.season;
await results.forEach((list) => list.filter(this.activeFilter, currentSeason, this.seasonTarget.value));
} }
const event = new CustomEvent('filterDownloadOptions', {
detail: {
activeFilter: this.activeFilter
}
})
// Event is picked up by the episode-container
// or movie-container web components
document.dispatchEvent(event);
} }
setSeason(event) { setSeason(event) {
this.tvEpisodeListOutlet.setSeason(event.target.value); this.tvEpisodeListOutlet.setSeason(event.target.value);
} }
uncheckSelectAllBtn() {
this.selectAllTarget.checked = false;
}
downloadSeason() { downloadSeason() {
fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, { fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, {
headers: { headers: {
@@ -166,21 +175,4 @@ export default class extends Controller {
} }
}) })
} }
selectAllEpisodes() {
this.tvResultsOutlets.forEach((episode) => {
if (episode.isActive()) {
episode.selectEpisodeForDownload()
}
});
}
downloadSelectedEpisodes() {
this.tvResultsOutlets.forEach(episode => {
if (episode.isActive() && episode.isSelected()) {
episode.download();
}
});
this.selectAllTarget.checked = false;
}
} }

View File

@@ -12,6 +12,11 @@ export default class extends Controller {
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete') const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
window.location.href = `/search?term=${autocompleteController.tomSelect.lastValue}` window.location.href = `/search?term=${autocompleteController.tomSelect.lastValue}`
} }
document.querySelector("#search-button").addEventListener('click', (event) => {
event.preventDefault();
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
window.location.href = `/search?term=${autocompleteController.tomSelect.lastQuery}`
});
this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect); this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect);
this.element.addEventListener('autocomplete:connect', this._onConnect); this.element.addEventListener('autocomplete:connect', this._onConnect);
} }

View File

@@ -16,8 +16,10 @@ export default class extends Controller {
}); });
if (window.location.hash) { if (window.location.hash) {
let targetElement = document.querySelector(window.location.hash); let targetElement = document.querySelector(window.location.hash);
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); if (targetElement) {
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster'); targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster');
}
} }
} }

View File

@@ -18,149 +18,23 @@ export default class extends Controller {
active: Boolean, active: Boolean,
}; };
static targets = ['list', 'count', 'episodeSelector', 'toggleButton', 'listContainer'] static targets = ['list', 'count', 'episodeSelector',]
static outlets = ['loading-icon'] static outlets = ['loading-icon']
options = [] options = []
optionsLoaded = false
isOpen = false
async listTargetConnected() { listTargetConnected() {
this.options = this.element.querySelectorAll('tbody tr'); this.element.options = this.element.querySelectorAll('tbody tr');
if (this.options.length > 0) { if (this.element.options.length > 0) {
this.options.forEach((option) => this.element.options.forEach((option) =>
option.querySelector('.download-btn').dataset['title'] = this.titleValue option.querySelector('.download-btn').dataset['title'] = this.titleValue
); );
this.options[0].querySelector('input[type="checkbox"]').checked = true; this.element.options[0].querySelector('input[type="checkbox"]').checked = true;
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.increaseCount(); this.loadingIconOutlet.increaseCount();
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.element.options}}));
} else { } else {
this.countTarget.innerText = 0; this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true; this.episodeSelectorTarget.disabled = true;
} }
} }
//
// async clearCache() {
// await fetch(`/torrentio/tvshows/clear/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
// .then(res => res.text())
// .then(response => {});
// }
async setActive() {
if (false === this.optionsLoaded) {
await this.setOptions();
}
}
async setInActive() {
this.episodeSelectorTarget.checked = false;
}
isActive() {
return this.activeValue;
}
isSelected() {
return this.episodeSelectorTarget.checked;
}
selectEpisodeForDownload() {
if (true === this.isActive() && this.episodeSelectorTarget.disabled === false) {
this.episodeSelectorTarget.checked = !this.episodeSelectorTarget.checked;
}
}
toggleList() {
this.listTarget.classList.toggle('options-table');
this.listTarget.classList.toggle('hidden');
this.toggleButtonTarget.classList.toggle('rotate-90');
this.toggleButtonTarget.classList.toggle('-rotate-90');
}
download() {
this.options.forEach(option => {
const optionSelector = option.querySelector('input[type="checkbox"]');
if (true === optionSelector.checked) {
const downloadBtn = option.querySelector('button.download-btn');
const downloadBtnController = this.application.getControllerForElementAndIdentifier(downloadBtn, 'download-button');
downloadBtnController.download();
optionSelector.checked = false;
this.episodeSelectorTarget.checked = false;
}
})
}
async filter(activeFilter, currentSeason, newSeason) {
if (currentSeason !== activeFilter['season']) {
if (this.seasonValue === newSeason) {
await this.setActive();
} else {
await this.setInActive();
}
}
if (false === this.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const optionHeader = document.querySelector(`[data-option-id="${option.dataset['localId']}"]`)
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
"quality": option.dataset['quality'],
}
let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden');
optionHeader.classList.add('r-tablerow');
optionHeader.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
optionHeader.classList.remove('r-tablerow');
optionHeader.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
this.countTarget.innerText = count;
});
}
} }

View File

@@ -130,3 +130,21 @@ dialog[data-dialog-target="dialog"][closing] {
background: unset; background: unset;
@apply bg-orange-500/80 text-black font-bold rounded-md @apply bg-orange-500/80 text-black font-bold rounded-md
} }
.progress {
display: grid;
grid-template: 1fr / 1fr;
place-items: center;
}
.progress > * {
grid-column: 1 / 1;
grid-row: 1 / 1;
}
.progress .background {
z-index: 1;
place-self: start;
}
.progress .number {
z-index: 2;
background: transparent;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Base\Util;
class ImdbMatcher
{
public static function isMatch(string $imdbId): bool
{
return preg_match('/^tt\d{7}$/', $imdbId);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Search\Action\Handler; namespace App\Search\Action\Handler;
use App\Base\Util\ImdbMatcher;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Search\Action\Result\SearchResult; use App\Search\Action\Result\SearchResult;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
@@ -17,6 +19,13 @@ class SearchHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
if (ImdbMatcher::isMatch($command->term)) {
$result = $this->tmdb->findByImdbId($command->term);
return new RedirectToMediaResult(
imdbId: $result->imdbId,
mediaType: $result->mediaType,
);
}
return new SearchResult( return new SearchResult(
term: $command->term, term: $command->term,
results: $this->tmdb->search($command->term) results: $this->tmdb->search($command->term)

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Search\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class RedirectToMediaResult implements ResultInterface
{
public function __construct(
public string $imdbId,
public string $mediaType,
) {}
}

View File

@@ -6,6 +6,7 @@ use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler; use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput; use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput; use App\Search\Action\Input\SearchInput;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand; use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
@@ -28,6 +29,13 @@ final class WebController extends AbstractController
): Response { ): Response {
$results = $this->searchHandler->handle($searchInput->toCommand()); $results = $this->searchHandler->handle($searchInput->toCommand());
if ($results instanceof RedirectToMediaResult) {
return $this->redirectToRoute('app_search_result', [
'mediaType' => $results->mediaType,
'imdbId' => $results->imdbId,
]);
}
return $this->render('search/results.html.twig', [ return $this->render('search/results.html.twig', [
'results' => $results, 'results' => $results,
]); ]);

View File

@@ -2,6 +2,7 @@
namespace App\Tmdb\Framework\Controller; namespace App\Tmdb\Framework\Controller;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -17,17 +18,28 @@ class ApiController extends AbstractController
$results = []; $results = [];
$term = $request->query->get('query') ?? null; $term = $request->query->get('query') ?? null;
$term = trim($term);
if (null !== $term) { if (null !== $term) {
$tmdbResults = $tmdb->search($term); if (ImdbMatcher::isMatch($term)) {
$tmdbResult = $tmdb->findByImdbId($term);
foreach ($tmdbResults as $tmdbResult) { $results = [
/** @var TmdbResult $tmdbResult */ [
$results[] = [ 'data' => $tmdbResult,
'data' => $tmdbResult, 'text' => $tmdbResult->title,
'text' => $tmdbResult->title, 'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId", ]
]; ];
} else {
$tmdbResults = $tmdb->search($term);
foreach ($tmdbResults as $tmdbResult) {
/** @var TmdbResult $tmdbResult */
$results[] = [
'data' => $tmdbResult,
'text' => $tmdbResult->title,
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
];
}
} }
} }

View File

@@ -185,6 +185,28 @@ class Tmdb
throw new \Exception("No results found for $id"); throw new \Exception("No results found for $id");
} }
public function findByImdbId(string $imdbId)
{
$finder = new Find($this->client);
$result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);
if (count($result['movie_results']) > 0) {
$result = $result['movie_results'][0];
$mediaType = MediaType::Movie->value;
} elseif (count($result['tv_results']) > 0) {
$result = $result['tv_results'][0];
$mediaType = MediaType::TvShow->value;
} elseif (count($result['tv_episode_results']) > 0) {
$result = $result['tv_episode_results'][0];
$mediaType = MediaType::TvShow->value;
}
$result['media_type'] = $mediaType;
$result = $this->mediaDetails($imdbId, $result['media_type']);
return $result;
}
public function movieDetails(string $id) public function movieDetails(string $id)
{ {
$client = new MovieRepository($this->client); $client = new MovieRepository($this->client);

View File

@@ -28,6 +28,7 @@ class GetTvShowOptionsHandler implements HandlerInterface
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode); $file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
return new GetTvShowOptionsResult( return new GetTvShowOptionsResult(
parentShow: $parentShow,
media: $media, media: $media,
file: MediaFileDto::fromSplFileInfo($file), file: MediaFileDto::fromSplFileInfo($file),
season: $command->season, season: $command->season,

View File

@@ -9,6 +9,7 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsResult implements ResultInterface class GetTvShowOptionsResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $parentShow,
public TmdbResult $media, public TmdbResult $media,
public MediaFileDto|false $file, public MediaFileDto|false $file,
public string $season, public string $season,

View File

@@ -68,7 +68,7 @@ class UtilExtension
#[AsTwigFunction('episode_anchor')] #[AsTwigFunction('episode_anchor')]
public function episodeAnchor($season, $episode): ?string public function episodeAnchor($season, $episode): ?string
{ {
return "episode_" . $season . "_" . $episode; return "episode_" . (int) $season . "_" . (int) $episode;
} }
#[AsTwigFunction('extract_from_episode_id')] #[AsTwigFunction('extract_from_episode_id')]

View File

@@ -16,10 +16,9 @@ class CodecList
public static function asSelectOptions(): array public static function asSelectOptions(): array
{ {
$result = []; return [
foreach (static::$codecs as $codec) { 'h264' => 'h264',
$result[$codec] = $codec; 'h265/HEVC' => 'h265',
} ];
return $result;
} }
} }

View File

@@ -22,6 +22,7 @@ module.exports = {
"bg-rose-600", "bg-rose-600",
"alert-success", "alert-success",
"alert-warning", "alert-warning",
"font-bold",
"min-w-64", "min-w-64",
"rotate-180", "rotate-180",
"-rotate-180", "-rotate-180",

View File

@@ -14,14 +14,11 @@
{% if entity.status != "Complete" %} {% if entity.status != "Complete" %}
<turbo-stream action="update" target="download_progress_{{ id }}"> <turbo-stream action="update" target="download_progress_{{ id }}">
<template> <template>
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10" <div class="background text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
style="width:{{ entity.progress }}%"> style="width: {{ entity.progress }}%">
</div>
<div class="absolute text-black text-center"
style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">
{{ entity.progress }}%
</div>
</div> </div>
<div class="number text-black font-bold text-center z-40"
>{{ entity.progress }}%</div>
</template> </template>
</turbo-stream> </turbo-stream>
<turbo-stream action="update" target="action_buttons_{{ id }}"> <turbo-stream action="update" target="action_buttons_{{ id }}">

View File

@@ -29,12 +29,13 @@
<td class="whitespace-nowrap gap-2 text-sm align-middle text-gray-800 dark:text-gray-50"> <td class="whitespace-nowrap gap-2 text-sm align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %} {% if download.progress < 100 %}
<div class="flex flex-row items-center justify-center"> <div class="flex flex-row items-center justify-center">
<div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-16 h-6 align-middle overflow-hidden"> <div id="download_progress_{{ download.id }}" class="progress border-2 border-green-600 rounded-md text-center w-16 h-6 align-middle overflow-hidden">
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10" <div class="background text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
style="width:{{ download.progress }}%"> style="width: {{ download.progress }}%">
</div> </div>
<div class="number text-black font-bold text-center z-40"
>{{ download.progress }}%</div>
</div> </div>
<div class="text-black font-bold text-center z-40 ml-[-42px]">{{ download.progress }}%</div>
</div> </div>
{% else %} {% else %}

View File

@@ -4,7 +4,7 @@
data-result-filter-movie-results-outlet=".results" data-result-filter-movie-results-outlet=".results"
data-result-filter-tv-results-outlet=".results" data-result-filter-tv-results-outlet=".results"
data-result-filter-tv-episode-list-outlet=".episode-list" data-result-filter-tv-episode-list-outlet=".episode-list"
data-action="change->result-filter#filter movie-results:optionsLoaded@window->result-filter#loadOptions tv-results:optionsLoaded@window->result-filter#loadOptions action-button:downloadSeason@window->result-filter#downloadSeason" data-action="change->result-filter#filter action-button:downloadSeason@window->result-filter#downloadSeason"
> >
<div class="w-full p-4 flex flex-col md:flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg"> <div class="w-full p-4 flex flex-col md:flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<label for="resolution"> <label for="resolution">
@@ -91,7 +91,7 @@
{% if results.media.mediaType == "tvshows" %} {% if results.media.mediaType == "tvshows" %}
<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 bg-green-600 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
<a href="{{ path('app_user_preferences') }}" class="text-underline">preferences</a> to choose <a href="{{ path('app_user_preferences') }}" class="text-underline">preferences</a> to choose
the appropriate file(s). the appropriate file(s).
@@ -99,7 +99,7 @@
Do you wish to download <strong>season <span id="downloadSeasonModal">{{ results.season }}</span></strong> of "<strong>{{ results.media.title }}</strong>"? Do you wish to download <strong>season <span id="downloadSeasonModal">{{ results.season }}</span></strong> of "<strong>{{ results.media.title }}</strong>"?
</twig:Modal> </twig:Modal>
<button class="px-1.5 py-1 bg-green-600 hover:bg-green-700 rounded-ms text-sm font-semibold" <button class="px-1.5 py-1 bg-green-800/60 hover:bg-green-700/60 border border-green-500 rounded-ms text-sm font-semibold"
{{ stimulus_target('result_filter', 'downloadSelected') }} {{ stimulus_target('result_filter', 'downloadSelected') }}
{{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }} {{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }}
>Download Selected</button> >Download Selected</button>

View File

@@ -20,6 +20,7 @@
> >
</select> </select>
<button <button
id="search-button"
class="absolute top-1 right-1 flex items-center rounded class="absolute top-1 right-1 flex items-center rounded
bg-green-600 py-1 px-2.5 border border-transparent text-center bg-green-600 py-1 px-2.5 border border-transparent text-center
text-sm text-white transition-all text-sm text-white transition-all

View File

@@ -3,7 +3,8 @@
> >
<div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4"> <div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4">
{% for episode in this.getEpisodes().items %} {% for episode in this.getEpisodes().items %}
<div id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results" <episode-container id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results"
show-title="{{ this.title }}"
data-tv-results-loading-icon-outlet=".loading-icon" 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', {
@@ -15,77 +16,73 @@
active: 'true', active: 'true',
}) }} }) }}
> >
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md"> <div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
{% if episode['poster'] != null %} {% if episode['poster'] != null %}
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" /> <img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" />
{% else %} {% else %}
<div class="w-full md:w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center"> <div class="w-full md:w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="32" name="hugeicons:loading-01" /> <twig:ux:icon width="32" name="hugeicons:loading-01" />
</div> </div>
{% endif %} {% endif %}
<div class="flex flex-col gap-4 grow"> <div class="flex flex-col gap-4 grow">
<h4 class="text-md font-bold"> <h4 class="text-md font-bold">
{{ episode['episode_number'] }}. {{ episode['name'] }} {{ episode['episode_number'] }}. {{ episode['name'] }}
</h4> </h4>
<p>{{ episode['overview']|truncate }}</p> <p>{{ episode['overview']|truncate }}</p>
<div> <div>
<button class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white" <button class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white" title="Click to expand the results table for season {{ episode['season_number'] }} episode {{ episode['episode_number'] }}.">
{{ stimulus_action('tv-results', 'toggleList', 'click') }} <span class="results-count-number" {{ stimulus_target('tv-results', 'count') }}>-</span> results
> </button>
<span {{ stimulus_target('tv-results', 'count') }}>-</span> results
</button>
<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 {{ episode['name'] }}"> <small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title='"{{ episode['name'] }}" aired on {{ episode['air_date']|date(null, 'UTC') }}.'>
{{ episode['air_date']|date(null, 'UTC') }} {{ episode['air_date']|date(null, 'UTC') }}
</small> </small>
<twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('api.library.search', { <twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('api.library.search', {
title: this.title, title: this.title,
season: episode['season_number'], season: episode['season_number'],
episode: episode['episode_number'], episode: episode['episode_number'],
block: 'media_exists_badge', block: 'media_exists_badge',
target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode['season_number'], episode['episode_number']) target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode['season_number'], episode['episode_number'])
}) }}"> }) }}">
<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."> <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 missing
</small> </small>
</twig:Turbo:Frame> </twig:Turbo:Frame>
</div>
</div>
<div class="flex flex-col gap-4 justify-between">
<div class="flex flex-col items-center">
<input class="episode-selector" type="checkbox"
{{ stimulus_target('tv-results', 'episodeSelector') }}
/>
</div>
<button class="dropdown-button flex flex-col items-end transition-transform duration-300 ease-in-out rotate-90" title="Click to expand the results table for season {{ episode['season_number'] }} episode {{ episode['episode_number'] }}.">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 6L10 16l10 10" />
</svg>
</button>
</div> </div>
</div> </div>
<div class="flex flex-col gap-4 justify-between"> <div class="results-container inline-block overflow-hidden rounded-lg hidden">
<div class="flex flex-col items-center"> <twig:Turbo:Frame id="results_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('app_torrentio_tvshows', {
<input type="checkbox" tmdbId: this.tmdbId,
{{ stimulus_target('tv-results', 'episodeSelector') }} imdbId: this.imdbId,
/> season: episode['season_number'],
</div> episode: episode['episode_number'],
<button class="flex flex-col items-end transition-transform duration-300 ease-in-out rotate-90" target: 'results_' ~ episode_id(episode['season_number'], episode['episode_number']),
{{ stimulus_target('tv-results', 'toggleButton') }} block: 'tvshow_results'
{{ stimulus_action('tv-results', 'toggleList', 'click') }}> }) }}" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 6L10 16l10 10" />
</svg>
</button>
</div> </div>
</div> </div>
<div class="inline-block overflow-hidden rounded-lg"> </episode-container>
<twig:Turbo:Frame id="results_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('app_torrentio_tvshows', {
tmdbId: this.tmdbId,
imdbId: this.imdbId,
season: episode['season_number'],
episode: episode['episode_number'],
target: 'results_' ~ episode_id(episode['season_number'], episode['episode_number']),
block: 'tvshow_results'
}) }}" />
</div>
</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% set paginator = this.episodes %} {% set paginator = this.episodes %}

View File

@@ -56,8 +56,8 @@
{% if "movies" == results.media.mediaType %} {% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow"> <div class="flex flex-row justify-start items-end grow">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white"> <span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span id="movie_results_count">-</span> results <span class="results-count-number" id="movie_results_count">-</span> results
</span> </span>
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Release date {{ results.media.episodeAirDate }}"> <small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Release date {{ results.media.episodeAirDate }}">
@@ -81,7 +81,7 @@
<twig:Filter results="{{ results }}" filter="{{ filter }}" /> <twig:Filter results="{{ results }}" filter="{{ filter }}" />
{% if "movies" == results.media.mediaType %} {% if "movies" == results.media.mediaType %}
<div 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" data-movie-results-loading-icon-outlet=".loading-icon"
> >
@@ -91,7 +91,7 @@
target: 'movie_results_frame', target: 'movie_results_frame',
block: 'movie_results' block: 'movie_results'
}) }}" /> }) }}" />
</div> </movie-container>
{% elseif "tvshows" == results.media.mediaType %} {% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList <twig:TvEpisodeList
results="results" results="results"

View File

@@ -1,4 +1,4 @@
<table class="w-full max-w-[75vw] text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 flex-row flex-no-wrap {{ results.media.mediaType == "tvshows" ? "hidden" : "options-table" }}" <table class="w-full max-w-[75vw] text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 flex-row flex-no-wrap options-table"
{{ stimulus_target(controller, "list") }} {{ stimulus_target(controller, "list") }}
> >
<thead class="text-xs text-gray-700 uppercase dark:text-gray-400"> <thead class="text-xs text-gray-700 uppercase dark:text-gray-400">
@@ -41,7 +41,29 @@
</thead> </thead>
<tbody class="flex-1 sm:flex-none"> <tbody class="flex-1 sm:flex-none">
{% for result in results.results %} {% for result in results.results %}
<tr class="bg-white dark:bg-slate-700 flex flex-col flex-no wrap r-tablerow border-b border-gray-500" data-local-id="{{ result.localId }}" data-provider="{{ result.provider }}" data-quality="{{ result.quality }}" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }}"{% endif %}> <tr is="dl-tr"
class="download-option bg-white dark:bg-slate-700 flex flex-col flex-no wrap r-tablerow border-b border-gray-500"
url="{{ result.url }}"
size="{{ result.size }}"
quality="{{ result.quality }}"
resolution="{{ result.resolution }}"
codec="{{ result.codec }}"
seeders="{{ result.seeders }}"
provider="{{ result.provider }}"
languages="{{ result.languages|json_encode }}"
media-type="{{ results.media.mediaType }}"
imdb-id="{{ results.media.imdbId }}"
filename="{{ result.filename }}"
data-local-id="{{ result.localId }}"
{% if "tvshows" == results.media.mediaType %}
season="{{ result.season }}"
episode="{{ result.episodeNumber }}"
episode-id="{{ episode_id(result.season, result.episodeNumber) }}"
media-title="{{ results.parentShow.title }}"
{% else %}
media-title="{{ results.media.title }}"
{% endif %}
>
<td id="size" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50"> <td id="size" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.size }} {{ result.size }}
</td> </td>
@@ -64,17 +86,7 @@
{{ result.languageFlags|raw }} {{ result.languageFlags|raw }}
</td> </td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-start mb:justify-end"> <td class="px-4 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-start mb:justify-end">
<button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50" <button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50">
{{ stimulus_controller('download_button', {
url: result.url,
title: results.media.title,
filename: result.filename,
mediaType: results.media.mediaType,
imdbId: results.media.imdbId ?? app.current_route_parameters.imdbId,
episodeId: results|episode_id_from_results
}) }}
{{ stimulus_action('download_button', 'download', 'click') }}
>
Download Download
</button> </button>
<label for="select"> <label for="select">