wip: working movies & tvshows w/ filtering

This commit is contained in:
2025-07-24 15:52:42 -05:00
parent 18b00fc5ae
commit 56c5156380
12 changed files with 215 additions and 186 deletions

7
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 EpisodeContainer from './components/episode-container.js';
const app = startStimulusApp(); const app = startStimulusApp();
// register any custom, 3rd party controllers here // register any custom, 3rd party controllers here
@@ -12,3 +15,5 @@ app.register('dialog', Dialog);
app.register('dropdown', Dropdown); app.register('dropdown', Dropdown);
customElements.define('episode-container', EpisodeContainer); customElements.define('episode-container', EpisodeContainer);
customElements.define('movie-container', MovieContainer);
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});

View File

@@ -0,0 +1,93 @@
export default class DownloadOptionTr extends HTMLTableRowElement {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
size;
quality;
resolution;
codec;
seeders;
provider;
languages;
mediaType;
season;
episode;
constructor() {
super();
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.languages = JSON.parse(this.getAttribute('languages'));
this.mediaType = this.getAttribute('media-type');
this.season = this.getAttribute('season') ?? null;
this.episode = this.getAttribute('episode') ?? null;
// document.addEventListener('filterDownloadOptions', this.filter.bind(this));
}
connectedCallback() {
}
// attribute change
attributeChangedCallback(property, oldValue, newValue) {
if (oldValue === newValue) return;
this[ property ] = newValue;
}
static get observedAttributes() {
return ['size', 'quality', 'resolution', 'codec', 'seeders', 'provider'];
}
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;
}
}

View File

@@ -2,6 +2,9 @@ export default class EpisodeContainer extends HTMLElement {
H264_CODECS = ['h264', 'h.264', 'x264'] H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc'] H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
options = [];
#episodeSelectorEl;
#resultsToggleBtnEl; #resultsToggleBtnEl;
#resultsTableEl; #resultsTableEl;
#resultsCountBadgeEl; #resultsCountBadgeEl;
@@ -13,11 +16,13 @@ export default class EpisodeContainer extends HTMLElement {
this.#resultsToggleBtnEl = this.querySelector('.dropdown-button'); this.#resultsToggleBtnEl = this.querySelector('.dropdown-button');
this.#resultsCountBadgeEl = this.querySelector('.results-count-badge'); this.#resultsCountBadgeEl = this.querySelector('.results-count-badge');
this.#resultsCountNumberEl = this.querySelector('.results-count-number'); this.#resultsCountNumberEl = this.querySelector('.results-count-number');
this.#episodeSelectorEl = this.querySelector('.episode-selector');
this.#resultsToggleBtnEl.addEventListener('click', () => this.toggleResults()); this.#resultsToggleBtnEl.addEventListener('click', () => this.toggleResults());
this.#resultsCountBadgeEl.addEventListener('click', () => this.toggleResults()); this.#resultsCountBadgeEl.addEventListener('click', () => this.toggleResults());
document.addEventListener('filterDownloadOptions', this.filter.bind(this)); document.addEventListener('filterDownloadOptions', this.filter.bind(this));
document.addEventListener('selectEpisodeForDownload', (e) => this.selectEpisodeForDownload(e.detail.select));
} }
connectedCallback() { connectedCallback() {
@@ -39,56 +44,23 @@ export default class EpisodeContainer extends HTMLElement {
this.#resultsTableEl.classList.toggle('hidden'); this.#resultsTableEl.classList.toggle('hidden');
} }
filter({ detail: { activeFilter } }) { selectEpisodeForDownload(select) {
const options = this.querySelectorAll('tr.download-option'); if (this.#episodeSelectorEl.disabled === false) {
this.#episodeSelectorEl.checked = select;
}
}
filter({ detail: { activeFilter } }) {
let firstIncluded = true; let firstIncluded = true;
let count = 0; let count = 0;
let selectedCount = 0; let selectedCount = 0;
options.forEach((option) => { this.options.forEach((option) => {
const optionHeader = document.querySelector(`[data-option-id="${option.dataset['localId']}"]`) const include = option.filter({ detail: { activeFilter: activeFilter } });
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) { if (false === include) {
option.classList.remove('r-tablerow'); option.classList.remove('r-tablerow');
option.classList.add('hidden'); option.classList.add('hidden');
optionHeader.classList.remove('r-tablerow');
optionHeader.classList.add('hidden');
} else if (true === firstIncluded) { } else if (true === firstIncluded) {
count = 1; count = 1;
selectedCount = selectedCount + 1; selectedCount = selectedCount + 1;
@@ -97,8 +69,7 @@ export default class EpisodeContainer extends HTMLElement {
} else { } else {
count = count + 1; count = count + 1;
} }
this.#resultsCountNumberEl.innerText = count;
}); });
this.#resultsCountNumberEl.innerText = count;
} }
} }

View File

@@ -0,0 +1,45 @@
export default class MovieContainer extends HTMLElement {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
#resultsTableEl;
#resultsCountNumberEl;
constructor() {
super();
this.#resultsTableEl = this.querySelector('.results-container');
this.#resultsCountNumberEl = document.querySelector('.results-count-number');
document.addEventListener('filterDownloadOptions', this.filter.bind(this));
}
// attribute change
attributeChangedCallback(property, oldValue, newValue) {
if (oldValue === newValue) return;
this[ property ] = newValue;
}
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

@@ -30,73 +30,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 = []
@@ -36,20 +33,30 @@ 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,
}
}));
}
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 +82,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 +107,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);
} }
} }
@@ -129,7 +136,6 @@ export default class extends Controller {
async filter() { async filter() {
const downloadSeasonSpan = document.querySelector("#downloadSeasonModal"); const downloadSeasonSpan = document.querySelector("#downloadSeasonModal");
let results = [];
this.activeFilter = { this.activeFilter = {
"resolution": this.resolutionTarget.value, "resolution": this.resolutionTarget.value,
"codec": this.codecTarget.value, "codec": this.codecTarget.value,
@@ -138,11 +144,7 @@ export default class extends Controller {
"quality": this.qualityTarget.value, "quality": this.qualityTarget.value,
} }
if ("movies" === this.mediaTypeValue) { if ("tvshows" === this.mediaTypeValue) {
results = this.movieResultsOutlets;
} else if ("tvshows" === this.mediaTypeValue) {
results = this.tvResultsOutlets;
downloadSeasonSpan.innerText = this.seasonTarget.value; downloadSeasonSpan.innerText = this.seasonTarget.value;
this.activeFilter.season = this.seasonTarget.value; this.activeFilter.season = this.seasonTarget.value;
} }
@@ -153,9 +155,9 @@ export default class extends Controller {
} }
}) })
// Event is picked up by the episode-container
// or movie-container web components
document.dispatchEvent(event); document.dispatchEvent(event);
// await results.forEach((list) => list.filter(this.activeFilter));
} }
setSeason(event) { setSeason(event) {
@@ -174,14 +176,6 @@ export default class extends Controller {
}) })
} }
selectAllEpisodes() {
this.tvResultsOutlets.forEach((episode) => {
if (episode.isActive()) {
episode.selectEpisodeForDownload()
}
});
}
downloadSelectedEpisodes() { downloadSelectedEpisodes() {
this.tvResultsOutlets.forEach(episode => { this.tvResultsOutlets.forEach(episode => {
if (episode.isActive() && episode.isSelected()) { if (episode.isActive() && episode.isSelected()) {

View File

@@ -22,57 +22,28 @@ export default class extends Controller {
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() { isSelected() {
return this.episodeSelectorTarget.checked; return this.episodeSelectorTarget.checked;
} }
selectEpisodeForDownload() {
if (true === this.isActive() && this.episodeSelectorTarget.disabled === false) {
this.episodeSelectorTarget.checked = !this.episodeSelectorTarget.checked;
}
}
download() { download() {
this.options.forEach(option => { this.element.options.forEach(option => {
const optionSelector = option.querySelector('input[type="checkbox"]'); const optionSelector = option.querySelector('input[type="checkbox"]');
if (true === optionSelector.checked) { if (true === optionSelector.checked) {
const downloadBtn = option.querySelector('button.download-btn'); const downloadBtn = option.querySelector('button.download-btn');

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

@@ -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">

View File

@@ -53,7 +53,7 @@
</div> </div>
<div class="flex flex-col gap-4 justify-between"> <div class="flex flex-col gap-4 justify-between">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<input type="checkbox" <input class="episode-selector" type="checkbox"
{{ stimulus_target('tv-results', 'episodeSelector') }} {{ stimulus_target('tv-results', 'episodeSelector') }}
/> />
</div> </div>

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

@@ -41,7 +41,22 @@
</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="download-option 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"
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 }}"
data-local-id="{{ result.localId }}"
{% if "tvshows" == results.media.mediaType %}
season="{{ result.season }}"
episode="{{ result.episodeNumber }}"
{% 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>