From 56c5156380d0a7248e8801bc18066f645c960ee3 Mon Sep 17 00:00:00 2001 From: Brock H Caldwell Date: Thu, 24 Jul 2025 15:52:42 -0500 Subject: [PATCH] wip: working movies & tvshows w/ filtering --- assets/bootstrap.js | 7 +- assets/components/download-option-tr.js | 93 +++++++++++++++++++ assets/components/episode-container.js | 57 +++--------- assets/components/movie-container.js | 45 +++++++++ .../controllers/movie_results_controller.js | 69 +------------- .../controllers/result_filter_controller.js | 56 +++++------ assets/controllers/tv_results_controller.js | 43 ++------- src/Twig/Extensions/UtilExtension.php | 2 +- templates/components/Filter.html.twig | 2 +- templates/components/TvEpisodeList.html.twig | 2 +- templates/search/result.html.twig | 8 +- .../torrentio/partial/option-table.html.twig | 17 +++- 12 files changed, 215 insertions(+), 186 deletions(-) create mode 100644 assets/components/download-option-tr.js create mode 100644 assets/components/movie-container.js diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 03a8795..3439c95 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -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 Popover from '@stimulus-components/popover'; import Dialog from '@stimulus-components/dialog'; import Dropdown from '@stimulus-components/dropdown'; import 'animate.css'; -import EpisodeContainer from './components/episode-container.js'; const app = startStimulusApp(); // register any custom, 3rd party controllers here @@ -12,3 +15,5 @@ app.register('dialog', Dialog); app.register('dropdown', Dropdown); customElements.define('episode-container', EpisodeContainer); +customElements.define('movie-container', MovieContainer); +customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'}); diff --git a/assets/components/download-option-tr.js b/assets/components/download-option-tr.js new file mode 100644 index 0000000..ab744d7 --- /dev/null +++ b/assets/components/download-option-tr.js @@ -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; + } +} diff --git a/assets/components/episode-container.js b/assets/components/episode-container.js index b6dc4ac..7c5fff2 100644 --- a/assets/components/episode-container.js +++ b/assets/components/episode-container.js @@ -2,6 +2,9 @@ export default class EpisodeContainer extends HTMLElement { H264_CODECS = ['h264', 'h.264', 'x264'] H265_CODECS = ['h265', 'h.265', 'x265', 'hevc'] + options = []; + + #episodeSelectorEl; #resultsToggleBtnEl; #resultsTableEl; #resultsCountBadgeEl; @@ -13,11 +16,13 @@ export default class EpisodeContainer extends HTMLElement { 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('selectEpisodeForDownload', (e) => this.selectEpisodeForDownload(e.detail.select)); } connectedCallback() { @@ -39,56 +44,23 @@ export default class EpisodeContainer extends HTMLElement { this.#resultsTableEl.classList.toggle('hidden'); } - filter({ detail: { activeFilter } }) { - const options = this.querySelectorAll('tr.download-option'); + selectEpisodeForDownload(select) { + if (this.#episodeSelectorEl.disabled === false) { + this.#episodeSelectorEl.checked = select; + } + } + filter({ detail: { activeFilter } }) { let firstIncluded = true; let count = 0; let selectedCount = 0; - 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; - } - } + this.options.forEach((option) => { + const include = option.filter({ detail: { activeFilter: activeFilter } }); 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; @@ -97,8 +69,7 @@ export default class EpisodeContainer extends HTMLElement { } else { count = count + 1; } - - this.#resultsCountNumberEl.innerText = count; }); + this.#resultsCountNumberEl.innerText = count; } } diff --git a/assets/components/movie-container.js b/assets/components/movie-container.js new file mode 100644 index 0000000..0b423be --- /dev/null +++ b/assets/components/movie-container.js @@ -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; + } +} diff --git a/assets/controllers/movie_results_controller.js b/assets/controllers/movie_results_controller.js index 1e6fed9..dbb2272 100644 --- a/assets/controllers/movie_results_controller.js +++ b/assets/controllers/movie_results_controller.js @@ -30,73 +30,8 @@ export default class extends Controller { this.optionsLoaded = true; this.options = this.element.querySelectorAll('tbody tr'); 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; - } - - // 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; + this.loadingIconOutlet.toggleIcon(); + document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}})); } } diff --git a/assets/controllers/result_filter_controller.js b/assets/controllers/result_filter_controller.js index fa13901..f3141c5 100644 --- a/assets/controllers/result_filter_controller.js +++ b/assets/controllers/result_filter_controller.js @@ -6,9 +6,6 @@ import { Controller } from '@hotwired/stimulus'; */ /* stimulusFetch: 'lazy' */ export default class extends Controller { - H264_CODECS = ['h264', 'h.264', 'x264'] - H265_CODECS = ['h265', 'h.265', 'x265', 'hevc'] - languages = [] providers = [] qualities = [] @@ -36,20 +33,30 @@ export default class extends Controller { this.activeFilter['season'] = 1; } await this.filter(); + + document.addEventListener('optionsLoaded', this.loadOptions.bind(this)); } // Event is fired from movies/tvshows controllers to populate this data async loadOptions({detail: { options }}) { await options.forEach((option) => { - this.addLanguages(option, option.dataset); - this.addProviders(option, option.dataset); - this.addQualities(option, option.dataset); + this.addLanguages(option); + this.addProviders(option); + this.addQualities(option); }) await this.filter(); } - addLanguages(option, props) { - const languages = Object.assign([], JSON.parse(props['languages'])); + selectAllEpisodes() { + document.dispatchEvent(new CustomEvent('selectEpisodeForDownload', { + detail: { + select: this.selectAllTarget.checked, + } + })); + } + + addLanguages(option) { + const languages = Object.assign([], option.languages); languages.forEach((language) => { if (!this.languages.includes(language)) { this.languages.push(language); @@ -75,9 +82,9 @@ export default class extends Controller { .join(); } - addProviders(option, props) { - if (!this.providers.includes(props['provider'])) { - this.providers.push(props['provider']); + addProviders(option) { + if (!this.providers.includes(option.provider)) { + this.providers.push(option.provider); } const preferred = this.providerTarget.dataset.preferred; @@ -100,10 +107,10 @@ export default class extends Controller { } - addQualities(option, props) { - if (!this.qualities.includes(props['quality'])) { - if (props['quality'].toLowerCase() in this.reverseMappedQualitiesValue) { - this.qualities.push(props['quality']); + addQualities(option) { + if (!this.qualities.includes(option.quality)) { + if (option.quality.toLowerCase() in this.reverseMappedQualitiesValue) { + this.qualities.push(option.quality); } } @@ -129,7 +136,6 @@ export default class extends Controller { async filter() { const downloadSeasonSpan = document.querySelector("#downloadSeasonModal"); - let results = []; this.activeFilter = { "resolution": this.resolutionTarget.value, "codec": this.codecTarget.value, @@ -138,11 +144,7 @@ export default class extends Controller { "quality": this.qualityTarget.value, } - if ("movies" === this.mediaTypeValue) { - results = this.movieResultsOutlets; - - } else if ("tvshows" === this.mediaTypeValue) { - results = this.tvResultsOutlets; + if ("tvshows" === this.mediaTypeValue) { downloadSeasonSpan.innerText = 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); - - // await results.forEach((list) => list.filter(this.activeFilter)); } setSeason(event) { @@ -174,14 +176,6 @@ 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()) { diff --git a/assets/controllers/tv_results_controller.js b/assets/controllers/tv_results_controller.js index cef2ee7..a8fe7cc 100644 --- a/assets/controllers/tv_results_controller.js +++ b/assets/controllers/tv_results_controller.js @@ -22,57 +22,28 @@ export default class extends Controller { static outlets = ['loading-icon'] options = [] - optionsLoaded = false - isOpen = false - async listTargetConnected() { - this.options = this.element.querySelectorAll('tbody tr'); - if (this.options.length > 0) { - this.options.forEach((option) => + listTargetConnected() { + this.element.options = this.element.querySelectorAll('tbody tr'); + if (this.element.options.length > 0) { + this.element.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue ); - this.options[0].querySelector('input[type="checkbox"]').checked = true; - this.dispatch('optionsLoaded', {detail: {options: this.options}}) + this.element.options[0].querySelector('input[type="checkbox"]').checked = true; this.loadingIconOutlet.increaseCount(); + document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.element.options}})); } else { this.countTarget.innerText = 0; 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; - } - } - download() { - this.options.forEach(option => { + this.element.options.forEach(option => { const optionSelector = option.querySelector('input[type="checkbox"]'); if (true === optionSelector.checked) { const downloadBtn = option.querySelector('button.download-btn'); diff --git a/src/Twig/Extensions/UtilExtension.php b/src/Twig/Extensions/UtilExtension.php index f005089..76f9108 100644 --- a/src/Twig/Extensions/UtilExtension.php +++ b/src/Twig/Extensions/UtilExtension.php @@ -68,7 +68,7 @@ class UtilExtension #[AsTwigFunction('episode_anchor')] public function episodeAnchor($season, $episode): ?string { - return "episode_" . $season . "_" . $episode; + return "episode_" . (int) $season . "_" . (int) $episode; } #[AsTwigFunction('extract_from_episode_id')] diff --git a/templates/components/Filter.html.twig b/templates/components/Filter.html.twig index 719b1dd..378e25f 100644 --- a/templates/components/Filter.html.twig +++ b/templates/components/Filter.html.twig @@ -4,7 +4,7 @@ data-result-filter-movie-results-outlet=".results" data-result-filter-tv-results-outlet=".results" 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" >
-
diff --git a/templates/search/result.html.twig b/templates/search/result.html.twig index 3a29632..09c3343 100644 --- a/templates/search/result.html.twig +++ b/templates/search/result.html.twig @@ -56,8 +56,8 @@ {% if "movies" == results.media.mediaType %}
- - - results + + - results @@ -81,7 +81,7 @@ {% if "movies" == results.media.mediaType %} -
@@ -91,7 +91,7 @@ target: 'movie_results_frame', block: 'movie_results' }) }}" /> -
+ {% elseif "tvshows" == results.media.mediaType %} {% for result in results.results %} - + {{ result.size }}