Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| beed7d6940 | |||
| 924472ed56 | |||
| 7dd61355b7 | |||
| 2a1f69edd4 | |||
| 9db0bfd4c6 | |||
| 18a165fc40 | |||
| 0e13b74b3b | |||
| f9ec089f8b | |||
| 87e72ec55e | |||
| 23a88ec6bb | |||
| d33a961f2d | |||
| 566886ef0e | |||
| 65acd5d21b | |||
| a27fcf334a | |||
| 56c5156380 | |||
| 18b00fc5ae | |||
| e39faa3398 | |||
| 2a9bacea8c | |||
| 0988517bd0 | |||
| d1ae26db45 | |||
| 75e9c1e8c3 | |||
| 93f5b716b2 | |||
| 8ff9cddbb0 | |||
| b0d7bfefd7 | |||
| df4bb3b736 | |||
| 265d782f99 | |||
| dc9242d96e | |||
| 24355a4b30 | |||
| 3b0ba81ce3 | |||
| dc2845d74d | |||
| 5e722dcbc7 | |||
| a126871af8 | |||
| 70f551cea9 | |||
| 4824c2d344 | |||
| c09c7ad030 | |||
| f610297294 | |||
| f2971eee9c | |||
| 47108af1f8 | |||
| f7163b5e00 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ bolt.db
|
||||
###> phpstan/phpstan ###
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
.php-cs-fixer.cache
|
||||
|
||||
@@ -6,6 +6,7 @@ import './bootstrap.js';
|
||||
* which should already be in your base.html.twig.
|
||||
*/
|
||||
import './styles/app.css';
|
||||
import PullToRefresh from 'pulltorefreshjs';
|
||||
|
||||
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
|
||||
|
||||
@@ -18,3 +19,10 @@ var observer = new MutationObserver(function(mutations) {
|
||||
|
||||
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});
|
||||
|
||||
const ptr = PullToRefresh.init({
|
||||
mainElement: 'body',
|
||||
onRefresh() {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
15
assets/bootstrap.js
vendored
15
assets/bootstrap.js
vendored
@@ -1,10 +1,19 @@
|
||||
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 Popover from '@stimulus-components/popover';
|
||||
import Dialog from '@stimulus-components/dialog';
|
||||
import Dropdown from '@stimulus-components/dropdown';
|
||||
import 'animate.css';
|
||||
|
||||
const app = startStimulusApp();
|
||||
// register any custom, 3rd party controllers here
|
||||
app.register('popover', Popover);
|
||||
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'});
|
||||
|
||||
187
assets/components/download-option-tr.js
Normal file
187
assets/components/download-option-tr.js
Normal file
@@ -0,0 +1,187 @@
|
||||
export default class DownloadOptionTr extends HTMLTableRowElement {
|
||||
H264_CODECS = {
|
||||
'h264': 'h264',
|
||||
'h.264': 'h264',
|
||||
'x264': 'h264',
|
||||
}
|
||||
H265_CODECS = {
|
||||
'h265': 'h265',
|
||||
'h.265': 'h265',
|
||||
'x265': 'h265',
|
||||
'hevc': 'h265',
|
||||
}
|
||||
|
||||
#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']}"]`)
|
||||
|
||||
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;
|
||||
|
||||
if (!this.#validateResolutions(activeFilter.resolution)) {
|
||||
include = false;
|
||||
}
|
||||
|
||||
if (!this.#validateCodecs(activeFilter.codec)) {
|
||||
include = false;
|
||||
}
|
||||
|
||||
if (!this.#validateLanguages(activeFilter.language)) {
|
||||
include = false;
|
||||
}
|
||||
|
||||
if (!this.#validateQualities(activeFilter.quality)) {
|
||||
include = false;
|
||||
}
|
||||
|
||||
if (!this.#validateProviders(activeFilter.provider)) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
#validateResolutions(selectedOptions) {
|
||||
return this.#validateIntersection(selectedOptions, this.resolution.trim().split(','));
|
||||
}
|
||||
|
||||
#validateCodecs(selectedOptions) {
|
||||
if (this.#validateIntersection(selectedOptions, Object.keys(this.H264_CODECS))) {
|
||||
return this.#validateIntersection(
|
||||
selectedOptions,
|
||||
[...this.codec.trim().split(','), '', 'n/a']
|
||||
);
|
||||
}
|
||||
if (this.#validateIntersection(selectedOptions, Object.keys(this.H265_CODECS))) {
|
||||
return this.#validateIntersection(
|
||||
selectedOptions,
|
||||
[...this.codec.trim().split(','), '', 'n/a']
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#validateQualities(selectedOptions) {
|
||||
return this.#validateIntersection(selectedOptions, this.quality.trim().split(','));
|
||||
}
|
||||
|
||||
#validateProviders(selectedOptions) {
|
||||
return this.#validateIntersection(selectedOptions, this.provider.trim().split(','));
|
||||
}
|
||||
|
||||
#validateLanguages(selectedOptions) {
|
||||
return this.#validateIntersection(selectedOptions, this.languages);
|
||||
}
|
||||
|
||||
#validateIntersection(selectedOptions, localOptions) {
|
||||
if (selectedOptions === null || selectedOptions === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof selectedOptions === 'string' || selectedOptions instanceof String) {
|
||||
selectedOptions = [selectedOptions];
|
||||
}
|
||||
|
||||
if (selectedOptions.length === 0 ||
|
||||
(selectedOptions.length === 1 && selectedOptions[0] === "") ||
|
||||
(selectedOptions.length === 1 && selectedOptions[0] === "n/a")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.#doesIntersect(localOptions, selectedOptions);
|
||||
}
|
||||
|
||||
#doesIntersect(a, b) {
|
||||
if (a.length === 0 || b.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return this.#intersect(a, b).length > 0;
|
||||
}
|
||||
|
||||
#intersect(a, b) {
|
||||
return a.filter(Set.prototype.has, new Set(b));
|
||||
}
|
||||
}
|
||||
76
assets/components/episode-container.js
Normal file
76
assets/components/episode-container.js
Normal 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;
|
||||
}
|
||||
}
|
||||
36
assets/components/movie-container.js
Normal file
36
assets/components/movie-container.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { getComponent } from '@symfony/ux-live-component';
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['download']
|
||||
static targets = ['download', 'deleteFileInput']
|
||||
|
||||
async initialize() {
|
||||
this.component = await getComponent(this.element);
|
||||
@@ -42,7 +42,8 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
deleteDownload(data) {
|
||||
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
|
||||
const deleteFileInput = document.querySelector(`#delete_file_${data.params.id}`)
|
||||
fetch(`/api/download/${data.params.id}?deleteFile=${deleteFileInput.checked}`, {method: 'DELETE'})
|
||||
.then(res => res.json())
|
||||
.then(json => console.debug(json));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,22 @@ export default class extends Controller {
|
||||
static targets = ['icon']
|
||||
|
||||
connect() {
|
||||
this.element.hideIcon = this.hideIcon.bind(this);
|
||||
this.element.showIcon = this.showIcon.bind(this);
|
||||
this.element.toggleIcon = this.toggleIcon.bind(this);
|
||||
this.element.isVisibile = this.isVisible.bind(this);
|
||||
}
|
||||
|
||||
isVisible() {
|
||||
return !this.iconTarget.classList.contains('hidden');
|
||||
}
|
||||
|
||||
showIcon() {
|
||||
this.iconTarget.classList.remove('hidden');
|
||||
}
|
||||
|
||||
hideIcon() {
|
||||
this.iconTarget.classList.add('hidden');
|
||||
}
|
||||
|
||||
toggleIcon() {
|
||||
@@ -26,6 +41,8 @@ export default class extends Controller {
|
||||
if (this.countValue === this.totalValue) {
|
||||
this.toggleIcon();
|
||||
this.countValue = 0;
|
||||
console.log('filtering')
|
||||
document.getElementById('filter').filterResults();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']
|
||||
|
||||
static values = {
|
||||
title: String,
|
||||
tmdbId: String,
|
||||
@@ -24,87 +21,14 @@ export default class extends Controller {
|
||||
|
||||
async connect() {
|
||||
this.resultCountEl = document.querySelector('#movie_results_count');
|
||||
await this.setOptions();
|
||||
}
|
||||
|
||||
async setOptions() {
|
||||
if (false === this.optionsLoaded) {
|
||||
this.optionsLoaded = true;
|
||||
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
|
||||
.then(res => res.text())
|
||||
.then(response => {
|
||||
this.element.innerHTML = response;
|
||||
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;
|
||||
async listTargetConnected() {
|
||||
this.optionsLoaded = true;
|
||||
this.options = this.element.querySelectorAll('tbody tr');
|
||||
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
|
||||
this.resultCountEl.innerText = this.options.length;
|
||||
this.loadingIconOutlet.toggleIcon();
|
||||
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export default class extends Controller {
|
||||
|
||||
toggle() {
|
||||
this.element.parentElement.classList.toggle('hidden');
|
||||
this.element.classList.toggle('animate__slideInLeft');
|
||||
this.element.classList.toggle('fixed');
|
||||
this.element.classList.toggle('z-20');
|
||||
}
|
||||
|
||||
@@ -6,24 +6,23 @@ 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 = []
|
||||
seasons = []
|
||||
|
||||
activeFilter = {
|
||||
"resolution": "",
|
||||
"codec": "",
|
||||
"language": "",
|
||||
"provider": "",
|
||||
"quality": "",
|
||||
"resolution": [],
|
||||
"codec": [],
|
||||
"language": [],
|
||||
"provider": [],
|
||||
"quality": [],
|
||||
}
|
||||
|
||||
static outlets = ['movie-results', 'tv-results', 'tv-episode-list']
|
||||
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
|
||||
defaultOptions = '<option value="-">-</option>';
|
||||
|
||||
static outlets = ['tv-episode-list']
|
||||
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'loadingIcon', 'selectAll', 'downloadSelected']
|
||||
static values = {
|
||||
'imdbId': String,
|
||||
'media-type': String,
|
||||
@@ -32,133 +31,77 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
async connect() {
|
||||
await this.setInitialFilter();
|
||||
this.setTimerToStopLoadingIcon();
|
||||
this.element.filterResults = this.filter.bind(this);
|
||||
document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
|
||||
}
|
||||
|
||||
async setInitialFilter() {
|
||||
const response = await fetch('/api/user/filters');
|
||||
const filters = await response.json();
|
||||
if (filters.length > 0) {
|
||||
this.activeFilter = filters[0];
|
||||
}
|
||||
if (this.mediaTypeValue === "tvshows") {
|
||||
this.activeFilter['season'] = 1;
|
||||
}
|
||||
await this.filter();
|
||||
}
|
||||
|
||||
setTimerToStopLoadingIcon() {
|
||||
setTimeout(() => this.loadingIconTarget.hideIcon(), 10000);
|
||||
}
|
||||
|
||||
// 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);
|
||||
option.filter({detail: {activeFilter: this.activeFilter }});
|
||||
})
|
||||
await this.filter();
|
||||
}
|
||||
|
||||
addLanguages(option, props) {
|
||||
const languages = Object.assign([], JSON.parse(props['languages']));
|
||||
languages.forEach((language) => {
|
||||
if (!this.languages.includes(language)) {
|
||||
this.languages.push(language);
|
||||
selectAllEpisodes() {
|
||||
document.dispatchEvent(new CustomEvent('selectEpisodeForDownload', {
|
||||
detail: {
|
||||
select: this.selectAllTarget.checked,
|
||||
}
|
||||
});
|
||||
|
||||
const preferred = this.languageTarget.dataset.preferred;
|
||||
if (preferred) {
|
||||
this.languageTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
|
||||
this.languageTarget.innerHTML += '<option value="">n/a</option>';
|
||||
} else {
|
||||
this.languageTarget.innerHTML = '<option value="">n/a</option>';
|
||||
}
|
||||
|
||||
this.languageTarget.innerHTML += this.languages.sort()
|
||||
.map((language) => {
|
||||
const preferred = this.languageTarget.dataset.preferred;
|
||||
if (preferred === language) {
|
||||
return;
|
||||
}
|
||||
return '<option value="'+language+'">'+language+'</option>';
|
||||
})
|
||||
.join();
|
||||
}));
|
||||
}
|
||||
|
||||
addProviders(option, props) {
|
||||
if (!this.providers.includes(props['provider'])) {
|
||||
this.providers.push(props['provider']);
|
||||
}
|
||||
|
||||
const preferred = this.providerTarget.dataset.preferred;
|
||||
if (preferred) {
|
||||
this.providerTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
|
||||
this.providerTarget.innerHTML += '<option value="">n/a</option>';
|
||||
} else {
|
||||
this.providerTarget.innerHTML = '<option value="">n/a</option>';
|
||||
}
|
||||
|
||||
this.providerTarget.innerHTML += this.providers.sort()
|
||||
.map((provider) => {
|
||||
const preferred = this.languageTarget.dataset.preferred;
|
||||
if (preferred === provider) {
|
||||
return;
|
||||
}
|
||||
return '<option value="' + provider + '">' + provider + '</option>'
|
||||
})
|
||||
.join();
|
||||
|
||||
downloadSelectedEpisodes() {
|
||||
document.dispatchEvent(new CustomEvent('downloadSelectedEpisodes', {}));
|
||||
}
|
||||
|
||||
addQualities(option, props) {
|
||||
if (!this.qualities.includes(props['quality'])) {
|
||||
if (props['quality'].toLowerCase() in this.reverseMappedQualitiesValue) {
|
||||
this.qualities.push(props['quality']);
|
||||
}
|
||||
}
|
||||
|
||||
const preferred = this.qualityTarget.dataset.preferred;
|
||||
if (preferred) {
|
||||
this.qualityTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
|
||||
this.qualityTarget.innerHTML += '<option value="">n/a</option>';
|
||||
} else {
|
||||
this.qualityTarget.innerHTML = '<option value="">n/a</option>';
|
||||
}
|
||||
|
||||
this.qualityTarget.innerHTML += this.qualities.sort()
|
||||
.map((quality) => {
|
||||
const preferred = this.qualityTarget.dataset.preferred;
|
||||
if (preferred === quality) {
|
||||
return;
|
||||
}
|
||||
return '<option value="' + quality + '">' + quality + '</option>'
|
||||
})
|
||||
.join();
|
||||
}
|
||||
|
||||
async filter() {
|
||||
filter() {
|
||||
const downloadSeasonSpan = document.querySelector("#downloadSeasonModal");
|
||||
const currentSeason = this.activeFilter['season'];
|
||||
|
||||
let results = [];
|
||||
this.activeFilter = {
|
||||
"resolution": this.resolutionTarget.value,
|
||||
"codec": this.codecTarget.value,
|
||||
"language": this.languageTarget.value,
|
||||
"provider": this.providerTarget.value,
|
||||
"quality": this.qualityTarget.value,
|
||||
"resolution": this.#fetchValuesFromNodeList(this.resolutionTarget.selectedOptions),
|
||||
"codec": this.#fetchValuesFromNodeList(this.codecTarget.selectedOptions),
|
||||
"language": this.#fetchValuesFromNodeList(this.languageTarget.selectedOptions),
|
||||
"provider": this.#fetchValuesFromNodeList(this.providerTarget.selectedOptions),
|
||||
"quality": this.#fetchValuesFromNodeList(this.qualityTarget.selectedOptions),
|
||||
}
|
||||
|
||||
if ("movies" === this.mediaTypeValue) {
|
||||
results = this.movieResultsOutlets;
|
||||
await results.forEach((list) => list.filter(this.activeFilter));
|
||||
|
||||
} else if ("tvshows" === this.mediaTypeValue) {
|
||||
results = this.tvResultsOutlets;
|
||||
if ("tvshows" === this.mediaTypeValue) {
|
||||
downloadSeasonSpan.innerText = 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) {
|
||||
this.tvEpisodeListOutlet.setSeason(event.target.value);
|
||||
}
|
||||
|
||||
uncheckSelectAllBtn() {
|
||||
this.selectAllTarget.checked = false;
|
||||
}
|
||||
|
||||
downloadSeason() {
|
||||
fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, {
|
||||
headers: {
|
||||
@@ -167,20 +110,15 @@ export default class extends Controller {
|
||||
})
|
||||
}
|
||||
|
||||
selectAllEpisodes() {
|
||||
this.tvResultsOutlets.forEach((episode) => {
|
||||
if (episode.isActive()) {
|
||||
episode.selectEpisodeForDownload()
|
||||
}
|
||||
});
|
||||
#fetchValuesFromNodeList(nodeList) {
|
||||
return [...nodeList].map(option => option.value)
|
||||
}
|
||||
|
||||
downloadSelectedEpisodes() {
|
||||
this.tvResultsOutlets.forEach(episode => {
|
||||
if (episode.isActive() && episode.isSelected()) {
|
||||
episode.download();
|
||||
}
|
||||
});
|
||||
this.selectAllTarget.checked = false;
|
||||
#serializeSelectOptions(options) {
|
||||
return this.defaultOptions + options.sort()
|
||||
.map((option) => {
|
||||
return '<option value="' + option + '">' + option + '</option>'
|
||||
})
|
||||
.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ export default class extends Controller {
|
||||
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
|
||||
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:connect', this._onConnect);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ export default class extends Controller {
|
||||
this.component.on('render:finished', (component) => {
|
||||
console.log(component);
|
||||
});
|
||||
if (window.location.hash) {
|
||||
let targetElement = document.querySelector(window.location.hash);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSeason(season) {
|
||||
@@ -25,6 +32,7 @@ export default class extends Controller {
|
||||
|
||||
paginate(event) {
|
||||
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
|
||||
this.component.set('episodeNumber', null);
|
||||
this.component.action('paginate', {page: event.params.page});
|
||||
this.component.render();
|
||||
}
|
||||
|
||||
@@ -18,167 +18,23 @@ export default class extends Controller {
|
||||
active: Boolean,
|
||||
};
|
||||
|
||||
static targets = ['list', 'count', 'episodeSelector', 'toggleButton', 'listContainer']
|
||||
static targets = ['list', 'count', 'episodeSelector',]
|
||||
static outlets = ['loading-icon']
|
||||
|
||||
options = []
|
||||
optionsLoaded = false
|
||||
isOpen = false
|
||||
|
||||
async connect() {
|
||||
await this.setOptions();
|
||||
}
|
||||
|
||||
async setOptions() {
|
||||
if (this.optionsLoaded === false) {
|
||||
this.optionsLoaded = true;
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
|
||||
} catch (error) {
|
||||
console.log('There was an error', error);
|
||||
}
|
||||
|
||||
if (response?.ok) {
|
||||
response = await response.text()
|
||||
this.listContainerTarget.innerHTML = response;
|
||||
this.options = this.element.querySelectorAll('tbody tr');
|
||||
if (this.options.length > 0) {
|
||||
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
|
||||
this.options[0].querySelector('input[type="checkbox"]').checked = true;
|
||||
} else {
|
||||
this.countTarget.innerText = 0;
|
||||
this.episodeSelectorTarget.disabled = true;
|
||||
}
|
||||
this.dispatch('optionsLoaded', {detail: {options: this.options}})
|
||||
this.loadingIconOutlet.increaseCount();
|
||||
} else {
|
||||
console.log(`HTTP Response Code: ${response?.status}`)
|
||||
}
|
||||
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.element.options[0].querySelector('input[type="checkbox"]').checked = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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']),
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
this.loadingIconOutlet.increaseCount();
|
||||
}
|
||||
}
|
||||
|
||||
1
assets/icons/zondicons/checkmark.svg
Normal file
1
assets/icons/zondicons/checkmark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="currentColor" d="m0 11l2-2l5 5L18 3l2 2L7 18z"/></svg>
|
||||
|
After Width: | Height: | Size: 127 B |
@@ -65,7 +65,17 @@ dialog[data-dialog-target="dialog"][closing] {
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@apply bg-gray-50 text-gray-50 p-1 bg-transparent border-b-2 border-orange-400
|
||||
@apply bg-gray-50 text-gray-50 px-2 py-1 bg-transparent border-b-2 border-orange-400
|
||||
}
|
||||
|
||||
.text-input[multiple="multiple"] {
|
||||
@apply bg-transparent backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
|
||||
}
|
||||
|
||||
.text-input option[checked="checked"],
|
||||
.text-input option[checked],
|
||||
.text-input option[selected] {
|
||||
@apply bg-orange-500/60
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
@@ -130,3 +140,52 @@ dialog[data-dialog-target="dialog"][closing] {
|
||||
background: unset;
|
||||
@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;
|
||||
}
|
||||
|
||||
#filter {
|
||||
.ts-wrapper {
|
||||
box-shadow: none !important;
|
||||
padding: 0;
|
||||
|
||||
.ts-control {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
@apply bg-orange-500/60 backdrop-filter backdrop-blur-md;
|
||||
}
|
||||
|
||||
.item[data-ts-item] {
|
||||
background-image: none !important;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
@apply bg-orange-500 rounded-ms font-bold text-black;
|
||||
}
|
||||
|
||||
@apply border border-orange-500 bg-transparent rounded-ms;
|
||||
}
|
||||
|
||||
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
|
||||
@apply border-l border-l-orange-600 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@apply flex flex-col gap-1 justify-between;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"tail": "docker compose exec app ./bin/console tailwind:build --watch",
|
||||
"sym": "docker compose exec app ./bin/console"
|
||||
},
|
||||
"conflict": {
|
||||
|
||||
@@ -10,9 +10,9 @@ pwa:
|
||||
theme_color: "#083344"
|
||||
description: Torsearch provides a simple and intuitive way to manage your personal media library.
|
||||
icons:
|
||||
- src: "icon.png"
|
||||
- src: "/icon.png"
|
||||
sizes: [ 192 ]
|
||||
- src: "icon.png"
|
||||
- src: "/icon.png"
|
||||
sizes: [ 192 ]
|
||||
purpose: maskable
|
||||
categories:
|
||||
|
||||
@@ -64,4 +64,7 @@ return [
|
||||
'version' => '2.4.3',
|
||||
'type' => 'css',
|
||||
],
|
||||
'pulltorefreshjs' => [
|
||||
'version' => '0.1.22',
|
||||
],
|
||||
];
|
||||
|
||||
41
migrations/Version20250724042107.php
Normal file
41
migrations/Version20250724042107.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20250724042107 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE preference_option DROP FOREIGN KEY FK_607C52FD81022C0
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE preference_option
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE preference_option (id INT AUTO_INCREMENT NOT NULL, preference_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, value VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, enabled TINYINT(1) NOT NULL, INDEX IDX_607C52FD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE preference_option ADD CONSTRAINT FK_607C52FD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id)
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,15 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
class SeedDatabaseCommand extends Command
|
||||
{
|
||||
private PreferencesRepository $preferenceRepository;
|
||||
private PreferenceOptionRepository $preferenceOptionRepository;
|
||||
|
||||
private UserRepository $userRepository;
|
||||
|
||||
public function __construct(
|
||||
PreferencesRepository $preferenceRepository,
|
||||
PreferenceOptionRepository $preferenceOptionRepository,
|
||||
UserRepository $userRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->preferenceRepository = $preferenceRepository;
|
||||
$this->preferenceOptionRepository = $preferenceOptionRepository;
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
|
||||
@@ -39,7 +37,6 @@ class SeedDatabaseCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$this->seedPreferences($io);
|
||||
$this->seedPreferenceOptions($io);
|
||||
$this->updateUserPreferences($io);
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -140,72 +137,4 @@ class SeedDatabaseCommand extends Command
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function seedPreferenceOptions(SymfonyStyle $io)
|
||||
{
|
||||
$io->info('[SeedDatabaseCommand] > Seeding preference options...');
|
||||
$options = $this->getPreferenceOptions();
|
||||
|
||||
foreach ($options as $option) {
|
||||
if ($this->preferenceOptionRepository->findBy([
|
||||
'preference' => $option['preference_id'],
|
||||
'name' => $option['name'],
|
||||
'value' => $option['value'],
|
||||
'enabled' => $option['enabled'],
|
||||
])) {
|
||||
continue;
|
||||
}
|
||||
$this->preferenceOptionRepository->getEntityManager()->persist(
|
||||
(new \App\User\Framework\Entity\PreferenceOption())
|
||||
->setPreference($this->preferenceRepository->find($option['preference_id']))
|
||||
->setName($option['name'])
|
||||
->setValue($option['value'])
|
||||
->setEnabled($option['enabled'])
|
||||
);
|
||||
}
|
||||
|
||||
$this->preferenceOptionRepository->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
private function getPreferenceOptions(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'preference_id' => 'resolution',
|
||||
'name' => '720p',
|
||||
'value' => '720p',
|
||||
'enabled' => true
|
||||
],
|
||||
[
|
||||
'preference_id' => 'resolution',
|
||||
'name' => '1080p',
|
||||
'value' => '1080p',
|
||||
'enabled' => true
|
||||
],
|
||||
[
|
||||
'preference_id' => 'resolution',
|
||||
'name' => '2160p',
|
||||
'value' => '2160p',
|
||||
'enabled' => true
|
||||
],
|
||||
[
|
||||
'preference_id' => 'codec',
|
||||
'name' => '-',
|
||||
'value' => '-',
|
||||
'enabled' => true
|
||||
],
|
||||
[
|
||||
'preference_id' => 'codec',
|
||||
'name' => 'h264',
|
||||
'value' => 'h264',
|
||||
'enabled' => true
|
||||
],
|
||||
[
|
||||
'preference_id' => 'codec',
|
||||
'name' => 'h265/HEVC',
|
||||
'value' => 'h265',
|
||||
'enabled' => true
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Base\Service;
|
||||
use Aimeos\Map;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use Nihilarr\PTN;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
@@ -21,6 +22,7 @@ class MediaFiles
|
||||
private string $tvShowsPath;
|
||||
|
||||
private Filesystem $filesystem;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(param: 'media.base_path')]
|
||||
@@ -33,12 +35,14 @@ class MediaFiles
|
||||
string $tvShowsPath,
|
||||
|
||||
Filesystem $filesystem,
|
||||
LoggerInterface $logger,
|
||||
) {
|
||||
$this->finder = new Finder();
|
||||
$this->basePath = $basePath;
|
||||
$this->moviesPath = $moviesPath;
|
||||
$this->tvShowsPath = $tvShowsPath;
|
||||
$this->filesystem = $filesystem;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function getPathByType(string $mediaType): string
|
||||
@@ -220,4 +224,26 @@ class MediaFiles
|
||||
{
|
||||
$this->filesystem->chmod($filepath, $permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $filepath
|
||||
* @return bool
|
||||
* Returns true if file was deleted
|
||||
* Returns false is file not found or was not deleted
|
||||
*/
|
||||
public function removeFile(string $filepath): bool
|
||||
{
|
||||
if (true === $this->filesystem->exists($filepath)) {
|
||||
try {
|
||||
$this->filesystem->remove($filepath);
|
||||
return true;
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger->error($exception->getMessage(), ['file' => $filepath]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->warning('> [MediaFiles] Attempted to remove file, but it did not exist.', ['file' => $filepath]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
11
src/Base/Util/ImdbMatcher.php
Normal file
11
src/Base/Util/ImdbMatcher.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,6 @@ class DeleteDownloadCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
public int $downloadId,
|
||||
public bool $deleteFile = false,
|
||||
) {}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace App\Download\Action\Handler;
|
||||
use App\Download\Action\Command\DeleteDownloadCommand;
|
||||
use App\Download\Action\Result\DeleteDownloadResult;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\Library\Action\Command\DeleteMediaFileCommand;
|
||||
use App\Library\Action\Handler\DeleteMediaFileHandler;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
@@ -14,13 +16,26 @@ readonly class DeleteDownloadHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DownloadRepository $downloadRepository,
|
||||
private DeleteMediaFileHandler $deleteMediaFileHandler,
|
||||
) {}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
$download = $this->downloadRepository->find($command->downloadId);
|
||||
|
||||
if (true === $command->deleteFile) {
|
||||
$deletedFileResult = $this->deleteMediaFileHandler->handle(new DeleteMediaFileCommand(
|
||||
filename: $download->getFilename(),
|
||||
downloadId: $command->downloadId
|
||||
));
|
||||
}
|
||||
$this->downloadRepository->delete($command->downloadId);
|
||||
|
||||
return new DeleteDownloadResult(200, 'Success', $download);
|
||||
return new DeleteDownloadResult(
|
||||
status: 200,
|
||||
message: 'Success',
|
||||
download: $download,
|
||||
deleteMediaFileResult: $deletedFileResult ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Download\Action\Input;
|
||||
|
||||
use App\Download\Action\Command\DeleteDownloadCommand;
|
||||
use OneToMany\RichBundle\Attribute\SourceQuery;
|
||||
use OneToMany\RichBundle\Attribute\SourceRoute;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\InputInterface;
|
||||
@@ -13,12 +14,15 @@ class DeleteDownloadInput implements InputInterface
|
||||
public function __construct(
|
||||
#[SourceRoute('downloadId')]
|
||||
public int $downloadId,
|
||||
#[SourceQuery('deleteFile')]
|
||||
public bool $deleteFile = false,
|
||||
) {}
|
||||
|
||||
public function toCommand(): CommandInterface
|
||||
{
|
||||
return new DeleteDownloadCommand(
|
||||
$this->downloadId,
|
||||
$this->deleteFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,6 @@ class DeleteDownloadResult implements ResultInterface
|
||||
public int $status,
|
||||
public string $message,
|
||||
public Download $download,
|
||||
public ?DeleteMediaFileResult $deleteMediaFileResult = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
14
src/Download/Action/Result/DeleteMediaFileResult.php
Normal file
14
src/Download/Action/Result/DeleteMediaFileResult.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Result;
|
||||
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
class DeleteMediaFileResult implements ResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $message,
|
||||
public string $filepath,
|
||||
public bool $isDeleted,
|
||||
) {}
|
||||
}
|
||||
@@ -3,92 +3,80 @@
|
||||
namespace App\Download;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Torrentio\Result\TorrentioResult;
|
||||
use App\User\Dto\UserPreferences;
|
||||
|
||||
class DownloadOptionEvaluator
|
||||
{
|
||||
/**
|
||||
* @param Monitor $monitor
|
||||
* @param TorrentioResult[] $results
|
||||
* @param UserPreferences $filter
|
||||
* @return TorrentioResult|null
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function evaluateOptions(array $results, UserPreferences $userPreferences): ?TorrentioResult
|
||||
public function evaluateOptions(array $results, UserPreferences $filter): ?TorrentioResult
|
||||
{
|
||||
$sizeLow = 000;
|
||||
$sizeHigh = 4096;
|
||||
|
||||
$bestMatches = [];
|
||||
$matches = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
if (!in_array($userPreferences->language, $result->languages)) {
|
||||
continue;
|
||||
$matches = Map::from($results)->filter(function ($result) use ($filter) {
|
||||
if (false === $this->validateFilterItems($result, $filter)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($result->resolution === $userPreferences->resolution
|
||||
&& $result->codec === $userPreferences->codec
|
||||
) {
|
||||
$bestMatches[] = $result;
|
||||
if (false === $this->validateSize($result, $filter)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($userPreferences->resolution === '2160p'
|
||||
&& $userPreferences->codec === $result->codec
|
||||
&& $result->resolution === '1080p'
|
||||
) {
|
||||
$matches[] = $result;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if ($userPreferences->codec === 'h264'
|
||||
&& $userPreferences->resolution === $result->resolution
|
||||
&& $result->codec === 'h265'
|
||||
) {
|
||||
$matches[] = $result;
|
||||
}
|
||||
|
||||
if (($userPreferences->codec === null )
|
||||
&& ($userPreferences->resolution === null )) {
|
||||
$matches[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
$sizeMatches = [];
|
||||
|
||||
foreach ($bestMatches as $result) {
|
||||
if (str_contains($result->size, 'GB')) {
|
||||
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
|
||||
} else {
|
||||
$size = (int) trim(str_replace('MB', '', $result->size));
|
||||
}
|
||||
|
||||
if ($size > $sizeLow && $size < $sizeHigh) {
|
||||
$sizeMatches[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($sizeMatches)) {
|
||||
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
|
||||
}
|
||||
|
||||
foreach ($matches as $result) {
|
||||
$size = 0;
|
||||
if (str_contains($result->size, 'GB')) {
|
||||
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
|
||||
} else {
|
||||
$size = (int) trim(str_replace('MB', '', $result->size));
|
||||
}
|
||||
|
||||
if ($size > $sizeLow && $size < $sizeHigh) {
|
||||
$sizeMatches[] = $result;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($sizeMatches)) {
|
||||
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
|
||||
if ($matches->count() > 0) {
|
||||
return Map::from($matches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function validateFilterItems(TorrentioResult $result, UserPreferences $filter): bool
|
||||
{
|
||||
if (array_intersect($filter->language, $result->languages) === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$valid = true;
|
||||
|
||||
if (null !== $filter->resolution && !in_array($result->resolution, $filter->resolution)) {
|
||||
$valid = false;
|
||||
}
|
||||
|
||||
if (null !== $filter->codec && in_array($result->codec, $filter->codec)) {
|
||||
$valid = false;
|
||||
}
|
||||
|
||||
if (null !== $filter->quality && in_array($result->quality, $filter->quality)) {
|
||||
$valid = false;
|
||||
}
|
||||
|
||||
if (null !== $filter->provider && in_array($result->provider, $filter->provider)) {
|
||||
$valid = false;
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
|
||||
private function validateSize(TorrentioResult $result, UserPreferences $filter): bool
|
||||
{
|
||||
$sizeLow = 000;
|
||||
$sizeHigh = 4096;
|
||||
|
||||
if (str_contains($result->size, 'GB')) {
|
||||
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
|
||||
} else {
|
||||
$size = (int) trim(str_replace('MB', '', $result->size));
|
||||
}
|
||||
|
||||
if ($size > $sizeLow && $size < $sizeHigh) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ class ApiController extends AbstractController
|
||||
message: "{$result->download->getTitle()} has been deleted.",
|
||||
);
|
||||
|
||||
return $this->json(['status' => 200, 'message' => 'Download Deleted']);
|
||||
return $this->json($result);
|
||||
}
|
||||
|
||||
#[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])]
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\User\Framework\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Timestampable\Traits\TimestampableEntity;
|
||||
use Nihilarr\PTN;
|
||||
use Symfony\Component\Serializer\Attribute\Ignore;
|
||||
use Symfony\UX\Turbo\Attribute\Broadcast;
|
||||
|
||||
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
|
||||
@@ -44,6 +45,7 @@ class Download
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $episodeId = null;
|
||||
|
||||
#[Ignore]
|
||||
#[ORM\ManyToOne(inversedBy: 'downloads')]
|
||||
private ?User $user = null;
|
||||
|
||||
|
||||
16
src/Library/Action/Command/DeleteMediaFileCommand.php
Normal file
16
src/Library/Action/Command/DeleteMediaFileCommand.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Library\Action\Command;
|
||||
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<DeleteMediaFileCommand>
|
||||
*/
|
||||
class DeleteMediaFileCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $filename,
|
||||
public ?int $downloadId = null,
|
||||
) {}
|
||||
}
|
||||
42
src/Library/Action/Handler/DeleteMediaFileHandler.php
Normal file
42
src/Library/Action/Handler/DeleteMediaFileHandler.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Library\Action\Handler;
|
||||
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Download\Action\Result\DeleteMediaFileResult;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\Library\Action\Command\DeleteMediaFileCommand;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
/**
|
||||
* @implements HandlerInterface<DeleteMediaFileCommand,DeleteMediaFileResult>
|
||||
*/
|
||||
class DeleteMediaFileHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DownloadRepository $downloadRepository,
|
||||
private readonly MediaFiles $mediaFiles,
|
||||
) {}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
/** @var Download $downloadRecord */
|
||||
$downloadRecord = $this->downloadRepository->find($command->downloadId);
|
||||
$filepath = $this->getFullFilepath($downloadRecord);
|
||||
$result = $this->mediaFiles->removeFile($filepath);
|
||||
|
||||
return new DeleteMediaFileResult(
|
||||
message: true === $result ? 'File removed' : 'File not removed',
|
||||
filepath: $filepath,
|
||||
isDeleted: $result
|
||||
);
|
||||
}
|
||||
|
||||
private function getFullFilepath(Download $download): string
|
||||
{
|
||||
return $this->mediaFiles->getPathByType($download->getMediaType()) . DIRECTORY_SEPARATOR . $download->getFilename();
|
||||
}
|
||||
}
|
||||
29
src/Library/Action/Input/DeleteMediaFileInput.php
Normal file
29
src/Library/Action/Input/DeleteMediaFileInput.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Library\Action\Input;
|
||||
|
||||
use App\Library\Action\Command\DeleteMediaFileCommand;
|
||||
use OneToMany\RichBundle\Attribute\SourceRequest;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\InputInterface;
|
||||
|
||||
/**
|
||||
* @implements InputInterface<DeleteMediaFileInput,DeleteMediaFileCommand>
|
||||
*/
|
||||
class DeleteMediaFileInput implements InputInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[SourceRequest('filename')]
|
||||
public string $filename,
|
||||
#[SourceRequest('downloadId', nullify: true)]
|
||||
public ?int $downloadId = null,
|
||||
) {}
|
||||
|
||||
public function toCommand(): CommandInterface
|
||||
{
|
||||
return new DeleteMediaFileCommand(
|
||||
$this->filename,
|
||||
$this->downloadId,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/Library/Action/Result/DeleteMediaFileResult.php
Normal file
15
src/Library/Action/Result/DeleteMediaFileResult.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Library\Action\Result;
|
||||
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
/**
|
||||
* @implements ResultInterface
|
||||
*/
|
||||
class DeleteMediaFileResult implements ResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $status,
|
||||
) {}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ readonly class MediaFileDto
|
||||
public string $size,
|
||||
) {}
|
||||
|
||||
public static function fromSplFileInfo(\SplFileInfo $fileInfo): self
|
||||
public static function fromSplFileInfo(\SplFileInfo|false $fileInfo): self|false
|
||||
{
|
||||
return new static(
|
||||
return false === $fileInfo ? false : new static(
|
||||
path: $fileInfo->getRealPath(),
|
||||
filename: $fileInfo->getFilename(),
|
||||
extension: $fileInfo->getExtension(),
|
||||
|
||||
@@ -11,5 +11,6 @@ class GetMediaInfoCommand implements CommandInterface
|
||||
public string $imdbId,
|
||||
public string $mediaType,
|
||||
public ?int $season = null,
|
||||
public ?int $episode = null,
|
||||
) {}
|
||||
}
|
||||
@@ -20,6 +20,6 @@ class GetMediaInfoHandler implements HandlerInterface
|
||||
{
|
||||
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
|
||||
|
||||
return new GetMediaInfoResult($media, $command->season);
|
||||
return new GetMediaInfoResult($media, $command->season, $command->episode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Search\Action\Handler;
|
||||
|
||||
use App\Base\Util\ImdbMatcher;
|
||||
use App\Search\Action\Result\RedirectToMediaResult;
|
||||
use App\Search\Action\Result\SearchResult;
|
||||
use App\Tmdb\Tmdb;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
@@ -17,6 +19,13 @@ class SearchHandler implements HandlerInterface
|
||||
|
||||
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(
|
||||
term: $command->term,
|
||||
results: $this->tmdb->search($command->term)
|
||||
|
||||
@@ -19,6 +19,9 @@ class GetMediaInfoInput implements InputInterface
|
||||
|
||||
#[SourceRoute('season', nullify: true)]
|
||||
public ?int $season,
|
||||
|
||||
#[SourceRoute('episode', nullify: true)]
|
||||
public ?int $episode,
|
||||
) {}
|
||||
|
||||
public function toCommand(): CommandInterface
|
||||
@@ -26,6 +29,10 @@ class GetMediaInfoInput implements InputInterface
|
||||
if ("tvshows" === $this->mediaType && null === $this->season) {
|
||||
$this->season = 1;
|
||||
}
|
||||
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season);
|
||||
|
||||
if ("tvshows" === $this->mediaType && null === $this->episode) {
|
||||
$this->episode = 1;
|
||||
}
|
||||
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season, $this->episode);
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,6 @@ class GetMediaInfoResult implements ResultInterface
|
||||
public function __construct(
|
||||
public TmdbResult $media,
|
||||
public ?int $season,
|
||||
public ?int $episode,
|
||||
) {}
|
||||
}
|
||||
|
||||
13
src/Search/Action/Result/RedirectToMediaResult.php
Normal file
13
src/Search/Action/Result/RedirectToMediaResult.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
213
src/Search/Framework/Command/SearchCommand.php
Normal file
213
src/Search/Framework/Command/SearchCommand.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Search\Framework\Command;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Download\Action\Command\DownloadMediaCommand;
|
||||
use App\Download\Action\Handler\DownloadMediaHandler;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\Search\Action\Handler\SearchHandler;
|
||||
use App\Search\Action\Command\SearchCommand as CommandInput;
|
||||
use App\Search\Action\Result\SearchResult;
|
||||
use App\Tmdb\TmdbResult;
|
||||
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
|
||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
|
||||
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||
use App\Torrentio\Result\TorrentioResult;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\ChoiceQuestion;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
#[AsCommand('search')]
|
||||
class SearchCommand extends Command
|
||||
{
|
||||
private SearchHandler $searchHandler;
|
||||
private GetTvShowOptionsHandler $getTvShowOptionsHandler;
|
||||
private GetMovieOptionsHandler $getMovieOptionsHandler;
|
||||
private UserRepository $userRepository;
|
||||
private DownloadRepository $downloadRepository;
|
||||
private DownloadMediaHandler $downloadMediaHandler;
|
||||
private MessageBusInterface $bus;
|
||||
|
||||
public function __construct(SearchHandler $searchHandler, GetTvShowOptionsHandler $getTvShowOptionsHandler,
|
||||
GetMovieOptionsHandler $getMovieOptionsHandler,
|
||||
UserRepository $userRepository,
|
||||
DownloadRepository $downloadRepository,
|
||||
DownloadMediaHandler $downloadMediaHandler,
|
||||
MessageBusInterface $bus,
|
||||
?string $name = null
|
||||
) {
|
||||
parent::__construct($name);
|
||||
$this->searchHandler = $searchHandler;
|
||||
$this->getTvShowOptionsHandler = $getTvShowOptionsHandler;
|
||||
$this->getMovieOptionsHandler = $getMovieOptionsHandler;
|
||||
$this->userRepository = $userRepository;
|
||||
$this->downloadRepository = $downloadRepository;
|
||||
$this->downloadMediaHandler = $downloadMediaHandler;
|
||||
$this->bus = $bus;
|
||||
}
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addArgument('term', InputArgument::REQUIRED);
|
||||
$this->addOption('local', 'l', InputOption::VALUE_NONE, 'Perform the download locally instead of submitting it to the queue.');
|
||||
}
|
||||
|
||||
public function run(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$command = new CommandInput($input->getArgument('term'));
|
||||
|
||||
// Perform search
|
||||
$mediaOptions = $this->searchHandler->handle($command);
|
||||
|
||||
// Render results and ask the User to pick one
|
||||
$mediaChoice = $this->askToChooseMediaOption($io, $output, $mediaOptions);
|
||||
|
||||
// Find download options based on the User's choice
|
||||
$downloadOptions = $this->fetchDownloadOptions($mediaChoice);
|
||||
|
||||
// Render results and ask the User to pick one
|
||||
$downloadChoice = $this->askToChooseDownloadOption($io, $output, $downloadOptions);
|
||||
|
||||
// Have user confirm download action
|
||||
$confirmation = $this->askToConfirmDownload($io, $output, $downloadChoice);
|
||||
|
||||
// Begin download or submit to the queue
|
||||
if (true === $confirmation) {
|
||||
$downloadLocally = $input->getOption('local');
|
||||
$this->submitDownload($io, $mediaChoice, $downloadChoice, $downloadLocally);
|
||||
} else {
|
||||
$io->success('No results found.');
|
||||
}
|
||||
|
||||
$io->success('Success!');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function askToChooseMediaOption(SymfonyStyle $io, OutputInterface $output, SearchResult $result): TmdbResult
|
||||
{
|
||||
$table = new Table($output)
|
||||
->setHeaders(['ID', 'Title', 'Year', 'IMDb ID', 'Description'])
|
||||
->setRows(
|
||||
Map::from($result->results)
|
||||
->map(fn ($result, $index) => [$index, $result->title, $result->year, $result->imdbId, substr($result->description, 0, 80) . '...'])
|
||||
->toArray()
|
||||
);
|
||||
|
||||
$table->render();
|
||||
|
||||
$question = new Question('Enter the ID of the correct result: ');
|
||||
$choiceId = $io->askQuestion($question);
|
||||
$choice = $result->results[$choiceId];
|
||||
|
||||
$io->info('You chose: ' . $choice->title);
|
||||
$io->table(
|
||||
['ID', 'Title', 'Year', 'IMDb ID', 'Description'],
|
||||
[
|
||||
[$choiceId, $choice->title, $choice->year, $choice->imdbId, substr($choice->description, 0, 80) . '...'],
|
||||
]
|
||||
);
|
||||
|
||||
return $choice;
|
||||
}
|
||||
|
||||
private function fetchDownloadOptions(TmdbResult $result): array
|
||||
{
|
||||
$handlers = [
|
||||
'movies' => $this->getMovieOptionsHandler,
|
||||
'tvshows' => $this->getTvShowOptionsHandler,
|
||||
];
|
||||
|
||||
$handler = $handlers[$result->mediaType];
|
||||
|
||||
if ("movies" === $result->mediaType) {
|
||||
$command = new GetMovieOptionsCommand(
|
||||
$result->tmdbId,
|
||||
$result->imdbId
|
||||
);
|
||||
} else {
|
||||
$command = new GetTvShowOptionsCommand(
|
||||
$result->tmdbId,
|
||||
$result->imdbId,
|
||||
1,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
$result = $handler->handle($command);
|
||||
return $result->results;
|
||||
}
|
||||
|
||||
private function askToChooseDownloadOption(SymfonyStyle $io, OutputInterface $output, array $options): TorrentioResult
|
||||
{
|
||||
$table = new Table($output)
|
||||
->setHeaders(['ID', 'Size', 'Resolution', 'Codec', 'Seeders', 'Provider', 'Language'])
|
||||
->setRows(
|
||||
Map::from($options)
|
||||
->map(fn (TorrentioResult $result, $index) => [$index, $result->size, $result->resolution, $result->codec, $result->seeders, $result->provider, implode(', ', $result->languages)])
|
||||
->toArray()
|
||||
);
|
||||
$table->render();
|
||||
|
||||
$question = new Question('Enter the ID of the item to download: ');
|
||||
$choiceId = $io->askQuestion($question);
|
||||
$choice = $options[$choiceId];
|
||||
$io->info('You chose: ' . $choice->title);
|
||||
|
||||
return $choice;
|
||||
}
|
||||
|
||||
private function askToConfirmDownload(SymfonyStyle $io, OutputInterface $output, TorrentioResult $downloadOption): bool
|
||||
{
|
||||
$question = new ChoiceQuestion('Are you sure you want to download the above file?', ['yes', 'no']);
|
||||
$choice = $io->askQuestion($question);
|
||||
return $choice === 'yes';
|
||||
}
|
||||
|
||||
private function submitDownload(SymfonyStyle $io, TmdbResult $mediaChoice, TorrentioResult $downloadOption, bool $downloadLocally = false): void
|
||||
{
|
||||
$io->writeln('> Adding download record');
|
||||
$user = $this->userRepository->find(1);
|
||||
$download = $this->downloadRepository->insert(
|
||||
$user,
|
||||
$downloadOption->url,
|
||||
$downloadOption->title,
|
||||
$downloadOption->filename,
|
||||
$mediaChoice->imdbId,
|
||||
$mediaChoice->mediaType,
|
||||
);
|
||||
|
||||
$io->writeln('> Download record added: ' . $download->getId());
|
||||
$downloadCommand = new DownloadMediaCommand(
|
||||
$download->getUrl(),
|
||||
$download->getTitle(),
|
||||
$download->getFilename(),
|
||||
$download->getMEdiaType(),
|
||||
$download->getImdbId(),
|
||||
$download->getUser()->getId(),
|
||||
$download->getId()
|
||||
);
|
||||
|
||||
if (true === $downloadLocally) {
|
||||
$io->writeln('> Beginning local download');
|
||||
$this->downloadMediaHandler->handle($downloadCommand);
|
||||
} else {
|
||||
$io->writeln('> Submitting download to queue');
|
||||
$this->bus->dispatch($downloadCommand);
|
||||
}
|
||||
|
||||
$io->writeln('> Download added to queue');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Search\Action\Handler\GetMediaInfoHandler;
|
||||
use App\Search\Action\Handler\SearchHandler;
|
||||
use App\Search\Action\Input\GetMediaInfoInput;
|
||||
use App\Search\Action\Input\SearchInput;
|
||||
use App\Search\Action\Result\RedirectToMediaResult;
|
||||
use App\Tmdb\TmdbResult;
|
||||
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
|
||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
@@ -28,12 +29,19 @@ final class WebController extends AbstractController
|
||||
): Response {
|
||||
$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', [
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')]
|
||||
#[Route('/result/{mediaType}/{imdbId}/{season}/{episode?}', name: 'app_search_result')]
|
||||
public function result(
|
||||
GetMediaInfoInput $input,
|
||||
?int $season = null,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Tmdb\Framework\Controller;
|
||||
|
||||
use App\Base\Util\ImdbMatcher;
|
||||
use App\Tmdb\Tmdb;
|
||||
use App\Tmdb\TmdbResult;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -17,17 +18,28 @@ class ApiController extends AbstractController
|
||||
$results = [];
|
||||
|
||||
$term = $request->query->get('query') ?? null;
|
||||
$term = trim($term);
|
||||
|
||||
if (null !== $term) {
|
||||
$tmdbResults = $tmdb->search($term);
|
||||
|
||||
foreach ($tmdbResults as $tmdbResult) {
|
||||
/** @var TmdbResult $tmdbResult */
|
||||
$results[] = [
|
||||
'data' => $tmdbResult,
|
||||
'text' => $tmdbResult->title,
|
||||
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
|
||||
if (ImdbMatcher::isMatch($term)) {
|
||||
$tmdbResult = $tmdb->findByImdbId($term);
|
||||
$results = [
|
||||
[
|
||||
'data' => $tmdbResult,
|
||||
'text' => $tmdbResult->title,
|
||||
'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",
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,28 @@ class Tmdb
|
||||
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)
|
||||
{
|
||||
$client = new MovieRepository($this->client);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Torrentio\Action\Handler;
|
||||
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Library\Dto\MediaFileDto;
|
||||
use App\Tmdb\Tmdb;
|
||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
|
||||
@@ -27,8 +28,9 @@ class GetTvShowOptionsHandler implements HandlerInterface
|
||||
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
|
||||
|
||||
return new GetTvShowOptionsResult(
|
||||
parentShow: $parentShow,
|
||||
media: $media,
|
||||
file: $file,
|
||||
file: MediaFileDto::fromSplFileInfo($file),
|
||||
season: $command->season,
|
||||
episode: $command->episode,
|
||||
results: $this->torrentio->fetchEpisodeResults(
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
namespace App\Torrentio\Action\Result;
|
||||
|
||||
use App\Library\Dto\MediaFileDto;
|
||||
use App\Tmdb\TmdbResult;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
use Symfony\Component\Finder\SplFileInfo;
|
||||
|
||||
class GetTvShowOptionsResult implements ResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public TmdbResult $parentShow,
|
||||
public TmdbResult $media,
|
||||
public bool|SplFileInfo $file,
|
||||
public MediaFileDto|false $file,
|
||||
public string $season,
|
||||
public string $episode,
|
||||
public array $results
|
||||
|
||||
@@ -9,12 +9,15 @@ use App\Torrentio\Action\Input\GetMovieOptionsInput;
|
||||
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
|
||||
use App\Torrentio\Exception\TorrentioRateLimitException;
|
||||
use Carbon\Carbon;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\Cache;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\UX\Turbo\TurboBundle;
|
||||
|
||||
final class WebController extends AbstractController
|
||||
{
|
||||
@@ -24,8 +27,9 @@ final class WebController extends AbstractController
|
||||
private readonly Broadcaster $broadcaster,
|
||||
) {}
|
||||
|
||||
#[Cache(expires: 3600, public: false, mustRevalidate: true)]
|
||||
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
|
||||
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
|
||||
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache, Request $request): Response
|
||||
{
|
||||
$cacheId = sprintf(
|
||||
"page.torrentio.movies.%s.%s",
|
||||
@@ -33,17 +37,22 @@ final class WebController extends AbstractController
|
||||
$input->imdbId
|
||||
);
|
||||
|
||||
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
|
||||
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
|
||||
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
|
||||
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
|
||||
return $this->render('torrentio/movies.html.twig', [
|
||||
'results' => $results,
|
||||
]);
|
||||
return $this->getMovieOptionsHandler->handle($input->toCommand());
|
||||
});
|
||||
|
||||
if ($request->headers->get('Turbo-Frame')) {
|
||||
return $this->sendFragmentResponse($results, $request);
|
||||
}
|
||||
|
||||
return $this->render('torrentio/movies.html.twig', [
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
|
||||
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
|
||||
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
|
||||
{
|
||||
$cacheId = sprintf(
|
||||
"page.torrentio.tvshows.%s.%s.%s.%s",
|
||||
@@ -54,13 +63,18 @@ final class WebController extends AbstractController
|
||||
);
|
||||
|
||||
try {
|
||||
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
|
||||
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
|
||||
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
|
||||
return $this->render('torrentio/tvshows.html.twig', [
|
||||
'results' => $results,
|
||||
]);
|
||||
// });
|
||||
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
|
||||
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
|
||||
return $this->getTvShowOptionsHandler->handle($input->toCommand());
|
||||
});
|
||||
|
||||
if ($request->headers->get('Turbo-Frame')) {
|
||||
return $this->sendFragmentResponse($results, $request);
|
||||
}
|
||||
|
||||
return $this->render('torrentio/tvshows.html.twig', [
|
||||
'results' => $results,
|
||||
]);
|
||||
} catch (TorrentioRateLimitException $exception) {
|
||||
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
|
||||
return $this->render('bare.html.twig',
|
||||
@@ -73,29 +87,16 @@ final class WebController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
|
||||
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
|
||||
private function sendFragmentResponse(ResultInterface $result, Request $request): Response
|
||||
{
|
||||
$cacheId = sprintf(
|
||||
"page.torrentio.tvshows.%s.%s.%s.%s",
|
||||
$input->tmdbId,
|
||||
$input->imdbId,
|
||||
$input->season,
|
||||
$input->episode,
|
||||
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
|
||||
return $this->renderBlock(
|
||||
'torrentio/fragments.html.twig',
|
||||
$request->query->get('block'),
|
||||
[
|
||||
'results' => $result,
|
||||
'target' => $request->query->get('target')
|
||||
]
|
||||
);
|
||||
$cache->delete($cacheId);
|
||||
|
||||
$this->broadcaster->alert(
|
||||
title: 'Success',
|
||||
message: 'Torrentio cache Cleared.'
|
||||
);
|
||||
|
||||
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
|
||||
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
|
||||
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
|
||||
return $this->render('torrentio/tvshows.html.twig', [
|
||||
'results' => $results,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,27 @@
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\User\Database\CodecList;
|
||||
use App\User\Database\QualityList;
|
||||
use App\User\Database\ResolutionList;
|
||||
use App\User\Dto\PreferenceOptions;
|
||||
use App\User\Dto\PreferenceOptionsFactory;
|
||||
use App\User\Dto\UserPreferencesFactory;
|
||||
use App\User\Framework\Form\UserMediaPreferencesForm;
|
||||
use App\User\Framework\Repository\PreferencesRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||
|
||||
#[AsLiveComponent]
|
||||
final class Filter
|
||||
final class Filter extends AbstractController
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
use ComponentWithFormTrait;
|
||||
|
||||
public array $preferences = [];
|
||||
|
||||
@@ -21,15 +32,25 @@ final class Filter
|
||||
public array $reverseMappedQualities = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly PreferencesRepository $preferencesRepository,
|
||||
private readonly Security $security,
|
||||
) {
|
||||
$this->preferences = Map::from($this->preferencesRepository->findEnabled())
|
||||
->rekey(fn($element) => $element->getId())
|
||||
->map(fn($element) => $element->getPreferenceOptions()->toArray())
|
||||
->toArray();
|
||||
$this->userPreferences = Map::from($this->security->getUser()->getUserPreferenceValues())
|
||||
->toArray();
|
||||
$this->preferences = (array) PreferenceOptionsFactory::createSelectOptions();
|
||||
$this->userPreferences = (array) UserPreferencesFactory::createFromUser($security->getUser());
|
||||
$this->reverseMappedQualities = QualityList::getAsReverseMap();
|
||||
}
|
||||
|
||||
public function getResolutionOptions()
|
||||
{
|
||||
return ResolutionList::asSelectOptions();
|
||||
}
|
||||
|
||||
public function getCodecOptions()
|
||||
{
|
||||
return CodecList::asSelectOptions();
|
||||
}
|
||||
|
||||
protected function instantiateForm(): FormInterface
|
||||
{
|
||||
return $this->createForm(UserMediaPreferencesForm::class, UserPreferencesFactory::createFromUser($this->getUser()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ use App\Search\Action\Command\GetMediaInfoCommand;
|
||||
use App\Search\Action\Handler\GetMediaInfoHandler;
|
||||
use App\Search\TvEpisodePaginator;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveAction;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveArg;
|
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp;
|
||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||
|
||||
#[AsLiveComponent]
|
||||
final class TvEpisodeList
|
||||
final class TvEpisodeList
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
use PaginateTrait;
|
||||
@@ -27,6 +29,12 @@ final class TvEpisodeList
|
||||
#[LiveProp(writable: true)]
|
||||
public int $season = 1;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public int $reloadCount = 0;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public ?int $episodeNumber = null;
|
||||
|
||||
public function __construct(
|
||||
private GetMediaInfoHandler $getMediaInfoHandler,
|
||||
) {}
|
||||
@@ -34,6 +42,14 @@ final class TvEpisodeList
|
||||
public function getEpisodes()
|
||||
{
|
||||
$results = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand($this->imdbId, "tvshows", $this->season));
|
||||
|
||||
if (null !== $this->episodeNumber) {
|
||||
$this->pageNumber = ceil($this->episodeNumber / $this->perPage);
|
||||
$this->episodeNumber = null;
|
||||
}
|
||||
|
||||
$this->reloadCount++;
|
||||
|
||||
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
|
||||
}
|
||||
|
||||
|
||||
25
src/Twig/Dto/EpisodeIdDto.php
Normal file
25
src/Twig/Dto/EpisodeIdDto.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Dto;
|
||||
|
||||
class EpisodeIdDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $season,
|
||||
public string $episode,
|
||||
) {}
|
||||
|
||||
public function asEpisodeId(): string
|
||||
{
|
||||
return "S". str_pad($this->season, 2, "0", STR_PAD_LEFT) .
|
||||
"E". str_pad($this->episode, 2, "0", STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
if ("" !== $this->season && "" !== $this->episode) {
|
||||
return $this->asEpisodeId();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Twig\Extensions;
|
||||
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
|
||||
use App\Twig\Dto\EpisodeIdDto;
|
||||
use ChrisUllyott\FileSize;
|
||||
use Twig\Attribute\AsTwigFilter;
|
||||
use Twig\Attribute\AsTwigFunction;
|
||||
@@ -63,4 +64,42 @@ class UtilExtension
|
||||
return "S". str_pad($season, 2, "0", STR_PAD_LEFT) .
|
||||
"E". str_pad($episode, 2, "0", STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
#[AsTwigFunction('episode_anchor')]
|
||||
public function episodeAnchor($season, $episode): ?string
|
||||
{
|
||||
return "episode_" . (int) $season . "_" . (int) $episode;
|
||||
}
|
||||
|
||||
#[AsTwigFunction('extract_from_episode_id')]
|
||||
public function extractFromEpisodeId(?string $episodeId): ?EpisodeIdDto
|
||||
{
|
||||
if (null === $episodeId) {
|
||||
return new EpisodeIdDto("", "");
|
||||
}
|
||||
|
||||
// Capture season
|
||||
$seasonMatch = [];
|
||||
preg_match('/[sS]\d\d(\d)?(\d)?/', $episodeId, $seasonMatch);
|
||||
if (empty($seasonMatch)) {
|
||||
$season = "";
|
||||
} else {
|
||||
$season = str_replace(['S', 's'], '', $seasonMatch[0]);
|
||||
}
|
||||
|
||||
// Capture episode
|
||||
$episodeMatch = [];
|
||||
preg_match('/[eE]\d\d(\d)?(\d)?/', $episodeId, $episodeMatch);
|
||||
if (empty($episodeMatch)) {
|
||||
$episode = "";
|
||||
} else {
|
||||
$episode = str_replace(['E', 'e'], '', $episodeMatch[0]);
|
||||
}
|
||||
|
||||
if (null === $season && null === $episode) {
|
||||
return new EpisodeIdDto("", "");
|
||||
}
|
||||
|
||||
return new EpisodeIdDto($season, $episode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\User\Action\Command;
|
||||
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
|
||||
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
|
||||
class SaveUserMediaPreferencesCommand implements CommandInterface
|
||||
@@ -14,4 +15,15 @@ class SaveUserMediaPreferencesCommand implements CommandInterface
|
||||
public string $language,
|
||||
public string $provider,
|
||||
) {}
|
||||
|
||||
public static function fromUserMediaPreferencesForm(FormInterface $form): self
|
||||
{
|
||||
return new static(
|
||||
resolution: \implode(',', $form->get('resolution')->getData()),
|
||||
codec: \implode(',', $form->get('codec')->getData()),
|
||||
quality: \implode(',', $form->get('quality')->getData()),
|
||||
language: \implode(',', $form->get('language')->getData()),
|
||||
provider: \implode(',', $form->get('provider')->getData()),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/User/Database/CodecList.php
Normal file
24
src/User/Database/CodecList.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Database;
|
||||
|
||||
class CodecList
|
||||
{
|
||||
public static $codecs = [
|
||||
'h264',
|
||||
'h265/HEVC',
|
||||
];
|
||||
|
||||
public static function getCodecs()
|
||||
{
|
||||
return self::$codecs;
|
||||
}
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
return [
|
||||
'h264' => 'h264',
|
||||
'h265/HEVC' => 'h265',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,7 @@ class QualityList
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
$result = [];
|
||||
$result = ['n/a' => null];
|
||||
foreach (array_keys(static::$qualities) as $quality) {
|
||||
$result[$quality] = $quality;
|
||||
}
|
||||
|
||||
27
src/User/Database/ResolutionList.php
Normal file
27
src/User/Database/ResolutionList.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Database;
|
||||
|
||||
class ResolutionList
|
||||
{
|
||||
public static $resolutions = [
|
||||
'480p',
|
||||
'720p',
|
||||
'1080p',
|
||||
'2160p',
|
||||
];
|
||||
|
||||
public static function getResolutions()
|
||||
{
|
||||
return self::$resolutions;
|
||||
}
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach (static::$resolutions as $resolution) {
|
||||
$result[$resolution] = $resolution;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
14
src/User/Dto/PreferenceOptions.php
Normal file
14
src/User/Dto/PreferenceOptions.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Dto;
|
||||
|
||||
class PreferenceOptions
|
||||
{
|
||||
public function __construct(
|
||||
public readonly array $resolutions,
|
||||
public readonly array $codecs,
|
||||
public readonly array $languages,
|
||||
public readonly array $providers,
|
||||
public readonly array $qualities,
|
||||
) {}
|
||||
}
|
||||
23
src/User/Dto/PreferenceOptionsFactory.php
Normal file
23
src/User/Dto/PreferenceOptionsFactory.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Dto;
|
||||
|
||||
use App\User\Database\CodecList;
|
||||
use App\User\Database\CountryLanguages;
|
||||
use App\User\Database\ProviderList;
|
||||
use App\User\Database\QualityList;
|
||||
use App\User\Database\ResolutionList;
|
||||
|
||||
class PreferenceOptionsFactory
|
||||
{
|
||||
public static function createSelectOptions(): PreferenceOptions
|
||||
{
|
||||
return new PreferenceOptions(
|
||||
resolutions: ResolutionList::asSelectOptions(),
|
||||
codecs: CodecList::asSelectOptions(),
|
||||
languages: CountryLanguages::asSelectOptions(),
|
||||
providers: ProviderList::asSelectOptions(),
|
||||
qualities: QualityList::asSelectOptions(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ class UserPreferences
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
public readonly ?string $resolution,
|
||||
public readonly ?string $codec,
|
||||
public readonly ?string $language,
|
||||
public readonly ?string $provider,
|
||||
public readonly ?string $quality,
|
||||
public readonly ?array $resolution,
|
||||
public readonly ?array $codec,
|
||||
public readonly ?array $language,
|
||||
public readonly ?array $provider,
|
||||
public readonly ?array $quality,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ class UserPreferencesFactory
|
||||
public static function createFromUser(UserInterface $user): UserPreferences
|
||||
{
|
||||
return new UserPreferences(
|
||||
resolution: self::getNestedValue($user, 'resolution'),
|
||||
codec: self::getNestedValue($user, 'codec'),
|
||||
resolution: self::getValue($user, 'resolution'),
|
||||
codec: self::getValue($user, 'codec'),
|
||||
language: self::getValue($user, 'language'),
|
||||
provider: self::getValue($user, 'provider'),
|
||||
quality: self::getValue($user, 'quality'),
|
||||
@@ -27,21 +27,7 @@ class UserPreferencesFactory
|
||||
if ($value === "") {
|
||||
return null;
|
||||
}
|
||||
$value = explode(',', $value);
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @param User $user */
|
||||
private static function getNestedValue(UserInterface $user, string $preferenceId): ?string
|
||||
{
|
||||
$preference = $user->getUserPreference($preferenceId);
|
||||
if (null === $preference) {
|
||||
return null;
|
||||
}
|
||||
return $preference->getPreference()
|
||||
->getPreferenceOptions()
|
||||
->filter(fn (PreferenceOption $option) => (string) $option->getId() === $preference->getPreferenceValue())
|
||||
->first()
|
||||
->getValue()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Controller\Api;
|
||||
|
||||
use App\User\Dto\UserPreferences;
|
||||
use App\User\Dto\UserPreferencesFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/user/filters')]
|
||||
class UserFilterApiController extends AbstractController
|
||||
{
|
||||
#[Route('', 'api.user.filters', methods: ['GET'])]
|
||||
public function getFilters(): Response
|
||||
{
|
||||
return $this->json([
|
||||
UserPreferencesFactory::createFromUser($this->getUser())
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\User\Framework\Controller\Web;
|
||||
|
||||
use App\Base\Service\Broadcaster;
|
||||
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
|
||||
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
|
||||
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
|
||||
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
|
||||
@@ -14,8 +15,10 @@ use App\User\Database\ProviderList;
|
||||
use App\User\Database\QualityList;
|
||||
use App\User\Dto\UserPreferencesFactory;
|
||||
use App\User\Framework\Form\GettingStartedFilterForm;
|
||||
use App\User\Framework\Form\UserMediaPreferencesForm;
|
||||
use App\User\Framework\Repository\PreferencesRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -29,53 +32,42 @@ class PreferencesController extends AbstractController
|
||||
#[Route('/user/preferences', 'app_user_preferences', methods: ['GET'])]
|
||||
public function mediaPreferences(): Response
|
||||
{
|
||||
$mediaPreferences = $this->getUser()->getMediaPreferences();
|
||||
$downloadPreferences = $this->getUser()->getDownloadPreferences();
|
||||
$languages = CountryLanguages::$languages;
|
||||
sort($languages);
|
||||
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
|
||||
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
|
||||
|
||||
return $this->render(
|
||||
'user/preferences.html.twig',
|
||||
[
|
||||
'preferences' => $this->preferencesRepository->findEnabled(),
|
||||
'languages' => $languages,
|
||||
'providers' => ProviderList::getProviders(),
|
||||
'qualities' => QualityList::getBaseQualities(),
|
||||
'mediaPreferences' => $mediaPreferences,
|
||||
'downloadPreferences' => $downloadPreferences,
|
||||
'filterForm' => $this->createForm(GettingStartedFilterForm::class, (array) UserPreferencesFactory::createFromUser($this->getUser())),
|
||||
'preferences_form' => $form,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/user/preferences/media', 'app_save_media_preferences', methods: ['POST'])]
|
||||
public function saveMediaPreferences(
|
||||
SaveUserMediaPreferencesInput $input,
|
||||
SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
|
||||
#[Route('/user/preferences/media', 'app_user_media_preferences_submit', methods: ['POST'])]
|
||||
public function mediaPreferencesSubmit(
|
||||
Request $request,
|
||||
SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler
|
||||
): Response
|
||||
{
|
||||
$saveUserMediaPreferencesHandler->handle($input->toCommand());
|
||||
$mediaPreferences = $this->getUser()->getMediaPreferences();
|
||||
$downloadPreferences = $this->getUser()->getDownloadPreferences();
|
||||
$form = $this->createForm(UserMediaPreferencesForm::class);
|
||||
|
||||
$languages = CountryLanguages::$languages;
|
||||
sort($languages);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$this->broadcaster->alert(
|
||||
title: 'Success',
|
||||
message: 'Your media preferences have been saved.'
|
||||
);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$saveUserMediaPreferencesHandler->handle(
|
||||
SaveUserMediaPreferencesCommand::fromUserMediaPreferencesForm($form)
|
||||
);
|
||||
$this->broadcaster->alert('Success', 'Your media preferences have been saved.');
|
||||
}
|
||||
|
||||
return $this->render(
|
||||
'user/preferences.html.twig',
|
||||
[
|
||||
'preferences' => $this->preferencesRepository->findEnabled(),
|
||||
'languages' => $languages,
|
||||
'providers' => ProviderList::$providers,
|
||||
'qualities' => QualityList::getBaseQualities(),
|
||||
'mediaPreferences' => $mediaPreferences,
|
||||
'downloadPreferences' => $downloadPreferences,
|
||||
'filterForm' => $this->createForm(GettingStartedFilterForm::class ?? null),
|
||||
'preferences_form' => $form,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -86,11 +78,11 @@ class PreferencesController extends AbstractController
|
||||
SaveUserDownloadPreferencesHandler $saveUserDownloadPreferencesHandler,
|
||||
): Response
|
||||
{
|
||||
$downloadPreferences = $saveUserDownloadPreferencesHandler->handle($input->toCommand())->downloadPreferences;
|
||||
$mediaPreferences = $this->getUser()->getMediaPreferences();
|
||||
$downloadPreferences = $this->getUser()->getDownloadPreferences();
|
||||
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
|
||||
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
|
||||
|
||||
$languages = CountryLanguages::$languages;
|
||||
sort($languages);
|
||||
$saveUserDownloadPreferencesHandler->handle($input->toCommand());
|
||||
|
||||
$this->broadcaster->alert(
|
||||
title: 'Success',
|
||||
@@ -100,12 +92,8 @@ class PreferencesController extends AbstractController
|
||||
return $this->render(
|
||||
'user/preferences.html.twig',
|
||||
[
|
||||
'preferences' => $this->preferencesRepository->findEnabled(),
|
||||
'languages' => $languages,
|
||||
'providers' => ProviderList::getProviders(),
|
||||
'qualities' => QualityList::getBaseQualities(),
|
||||
'mediaPreferences' => $mediaPreferences,
|
||||
'downloadPreferences' => $downloadPreferences,
|
||||
'preferences_form' => $form,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,17 +26,6 @@ class Preference
|
||||
#[ORM\Column]
|
||||
private ?bool $enabled = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, PreferenceOption>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference', fetch: 'EAGER')]
|
||||
private Collection $preferenceOptions;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->preferenceOptions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
@@ -94,34 +83,4 @@ class Preference
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PreferenceOption>
|
||||
*/
|
||||
public function getPreferenceOptions(): Collection
|
||||
{
|
||||
return $this->preferenceOptions;
|
||||
}
|
||||
|
||||
public function addPreferenceOption(PreferenceOption $preferenceOption): static
|
||||
{
|
||||
if (!$this->preferenceOptions->contains($preferenceOption)) {
|
||||
$this->preferenceOptions->add($preferenceOption);
|
||||
$preferenceOption->setPreference($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removePreferenceOption(PreferenceOption $preferenceOption): static
|
||||
{
|
||||
if ($this->preferenceOptions->removeElement($preferenceOption)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($preferenceOption->getPreference() === $this) {
|
||||
$preferenceOption->setPreference(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Entity;
|
||||
|
||||
use App\User\Framework\Repository\PreferenceOptionRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Ignore;
|
||||
|
||||
#[ORM\Entity(repositoryClass: PreferenceOptionRepository::class)]
|
||||
class PreferenceOption
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $value = null;
|
||||
|
||||
#[Ignore]
|
||||
#[ORM\ManyToOne(inversedBy: 'preferenceOptions')]
|
||||
private ?Preference $preference = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?bool $enabled = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(?string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getValue(): ?string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function setValue(?string $value): static
|
||||
{
|
||||
$this->value = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPreference(): ?Preference
|
||||
{
|
||||
return $this->preference;
|
||||
}
|
||||
|
||||
public function setPreference(?Preference $preference): static
|
||||
{
|
||||
$this->preference = $preference;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): ?bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): static
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -215,11 +215,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
if (in_array($userPreference->getPreference()->getId(), ['language', 'provider', 'quality'])) {
|
||||
return $userPreference->getPreferenceValue();
|
||||
}
|
||||
foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) {
|
||||
if ($preferenceOption->getId() === (int) $userPreference->getPreferenceValue()) {
|
||||
return $preferenceOption->getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace App\User\Framework\Form;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\User\Database\CodecList;
|
||||
use App\User\Database\CountryLanguages;
|
||||
use App\User\Database\ProviderList;
|
||||
use App\User\Database\QualityList;
|
||||
use App\User\Framework\Repository\PreferenceOptionRepository;
|
||||
use App\User\Database\ResolutionList;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -14,17 +14,14 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class GettingStartedFilterForm extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PreferenceOptionRepository $preferenceOptionRepository,
|
||||
) {}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$this->addChoiceField($builder, 'language', CountryLanguages::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'quality', QualityList::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'provider', ProviderList::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'resolution', $this->getPreferenceChoices('resolution'));
|
||||
$this->addChoiceField($builder, 'codec', $this->getPreferenceChoices('codec'));
|
||||
$this->addChoiceField($builder, 'resolution', ResolutionList::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'codec', CodecList::asSelectOptions());;
|
||||
}
|
||||
|
||||
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void
|
||||
@@ -42,16 +39,6 @@ class GettingStartedFilterForm extends AbstractType
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
|
||||
private function getPreferenceChoices(string $preference): array
|
||||
{
|
||||
$options = $this->preferenceOptionRepository->findBy(['preference' => $preference]);
|
||||
$result = [];
|
||||
foreach ($options as $item) {
|
||||
$result[$item->getName()] = $item->getId();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function addDefaultChoice(array $choices): iterable
|
||||
{
|
||||
return ['n/a' => null] + $choices;
|
||||
|
||||
80
src/User/Framework/Form/UserMediaPreferencesForm.php
Normal file
80
src/User/Framework/Form/UserMediaPreferencesForm.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Form;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\User\Database\CodecList;
|
||||
use App\User\Database\CountryLanguages;
|
||||
use App\User\Database\ProviderList;
|
||||
use App\User\Database\QualityList;
|
||||
use App\User\Database\ResolutionList;
|
||||
use App\User\Dto\UserPreferences;
|
||||
use App\User\Dto\UserPreferencesFactory;
|
||||
use App\User\Framework\Repository\PreferenceOptionRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Event\PreSetDataEvent;
|
||||
use Symfony\Component\Form\Event\PreSubmitEvent;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Routing\Generator\UrlGenerator;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
class UserMediaPreferencesForm extends AbstractType
|
||||
{
|
||||
private UserPreferences $userPreferences;
|
||||
|
||||
public function __construct(
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly Security $security,
|
||||
) {
|
||||
$this->userPreferences = UserPreferencesFactory::createFromUser($security->getUser());
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$this->addChoiceField($builder, 'language', CountryLanguages::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'quality', QualityList::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'provider', ProviderList::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'resolution', ResolutionList::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'codec', CodecList::asSelectOptions());
|
||||
}
|
||||
|
||||
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void
|
||||
{
|
||||
$question = [
|
||||
'attr' => [
|
||||
'class' => 'min-w-24 text-input mb-4',
|
||||
'data-result-filter-target' => $fieldName,
|
||||
'data-controller' => 'symfony--ux-autocomplete--autocomplete',
|
||||
'data-symfony--ux-autocomplete--autocomplete-tom-select-options-value' => '{"highlight":false}',
|
||||
'data-preferred' => \json_encode($this->userPreferences->$fieldName),
|
||||
],
|
||||
'row_attr' => [
|
||||
'class' => 'filter-label text-white'
|
||||
],
|
||||
'label_attr' => ['class' => 'block font-semibold mb-2'],
|
||||
'choices' => $this->addDefaultChoice($choices),
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
];
|
||||
$builder->add($fieldName, ChoiceType::class, $question);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'action' => $this->urlGenerator->generate('app_user_media_preferences_submit'),
|
||||
'attr' => [
|
||||
'class' => 'filter-items w-full p-4 bg-black/20 border-2 border-orange-500 text-md dark:text-gray-50 rounded-lg',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
private function addDefaultChoice(array $choices): iterable
|
||||
{
|
||||
return ['n/a' => 'n/a'] + $choices;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Repository;
|
||||
|
||||
use App\User\Framework\Entity\PreferenceOption;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<PreferenceOption>
|
||||
*/
|
||||
class PreferenceOptionRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PreferenceOption::class);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return PreferenceOption[] Returns an array of PreferenceOption objects
|
||||
// */
|
||||
// public function findByExampleField($value): array
|
||||
// {
|
||||
// return $this->createQueryBuilder('p')
|
||||
// ->andWhere('p.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->orderBy('p.id', 'ASC')
|
||||
// ->setMaxResults(10)
|
||||
// ->getQuery()
|
||||
// ->getResult()
|
||||
// ;
|
||||
// }
|
||||
|
||||
// public function findOneBySomeField($value): ?PreferenceOption
|
||||
// {
|
||||
// return $this->createQueryBuilder('p')
|
||||
// ->andWhere('p.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
@@ -10,6 +10,8 @@ module.exports = {
|
||||
"flex-row",
|
||||
"p-2",
|
||||
"p-4",
|
||||
"w-32",
|
||||
"w-64",
|
||||
"bg-blue-300",
|
||||
"bg-orange-300",
|
||||
"bg-fuchsia-300",
|
||||
@@ -18,8 +20,10 @@ module.exports = {
|
||||
"bg-orange-400",
|
||||
"bg-blue-600",
|
||||
"bg-rose-600",
|
||||
"bg-black/20",
|
||||
"alert-success",
|
||||
"alert-warning",
|
||||
"font-bold",
|
||||
"min-w-64",
|
||||
"rotate-180",
|
||||
"-rotate-180",
|
||||
@@ -34,6 +38,9 @@ module.exports = {
|
||||
"rounded-sm",
|
||||
"rounded-md",
|
||||
"r-tablecell",
|
||||
"animate__animated",
|
||||
"animate__slideInLeft",
|
||||
"animate__animateFaster"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -14,14 +14,11 @@
|
||||
{% if entity.status != "Complete" %}
|
||||
<turbo-stream action="update" target="download_progress_{{ id }}">
|
||||
<template>
|
||||
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||
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 class="background text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||
style="width: {{ entity.progress }}%">
|
||||
</div>
|
||||
<div class="number text-black font-bold text-center z-40"
|
||||
>{{ entity.progress }}%</div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
<turbo-stream action="update" target="action_buttons_{{ id }}">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<li {{ attributes }} id="alert_{{ alert_id }}"
|
||||
class="alert alert-{{ type|default('success') }}"
|
||||
role="alert"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -9,7 +9,7 @@
|
||||
<span class="sr-only">Info</span>
|
||||
<h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3>
|
||||
</div>
|
||||
<div class="mt-2 text-sm w-[350px] font-bold">
|
||||
<div class="mt-2 text-sm w-[300px] font-bold overflow-hidden text-wrap">
|
||||
{{ message }}
|
||||
</div>
|
||||
</li>
|
||||
@@ -1,13 +1,20 @@
|
||||
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
|
||||
<a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}"
|
||||
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate"
|
||||
>
|
||||
{% if download.mediaType == "movies" %}
|
||||
{% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType} %}
|
||||
{% set route = path('app_search_result', routeParams) %}
|
||||
{% else %}
|
||||
{% set episodeIdDto = extract_from_episode_id(download.episodeId) %}
|
||||
{% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType, season: episodeIdDto.season, episode: episodeIdDto.episode} %}
|
||||
{% set route = path('app_search_result', routeParams) ~ "#" ~ episode_anchor(episodeIdDto.season, episodeIdDto.episode) %}
|
||||
{% endif %}
|
||||
<a href="{{ route }}"
|
||||
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate">
|
||||
{{ download.title }}
|
||||
</a>
|
||||
|
||||
{% if download.mediaType == "tvshows" and download.episodeId != null %}
|
||||
— <span class="ml-1">(S{{ download.episodeId }})</span>
|
||||
— <span class="ml-1">({{ download.episodeId }})</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -19,19 +26,23 @@
|
||||
{{ download.mediaType }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap 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 %}
|
||||
<div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-full h-6 align-middle overflow-hidden">
|
||||
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||
style="width:{{ download.progress }}%">
|
||||
<div class="flex flex-row items-center justify-center">
|
||||
<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="background text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||
style="width: {{ download.progress }}%">
|
||||
</div>
|
||||
<div class="number text-black font-bold text-center z-40"
|
||||
>{{ download.progress }}%</div>
|
||||
</div>
|
||||
<div class="text-black text-center" style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">{{ download.progress }}%</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<twig:StatusBadge color="green" status="Complete" />
|
||||
{% endif %}
|
||||
</td>
|
||||
<td id="hidden md:table-cell action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center">
|
||||
<td id="hidden md:table-cell action_buttons_{{ download.id }}" class="pl-2 pr-4 py-4 flex flex-row items-center justify-end">
|
||||
{% if download.status == 'In Progress' and download.progress < 100 %}
|
||||
<button id="pause_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}>
|
||||
<twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" />
|
||||
@@ -44,7 +55,12 @@
|
||||
|
||||
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', height: '17.75px', width: '17.75px', class: 'rounded-full align-middle text-red-600 hover:text-red-700' }) %}
|
||||
<twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}" show_cancel show_submit>
|
||||
Are you sure you want to delete <span class="font-bold">{{ download.filename }}</span>?
|
||||
<p class="mb-1">Are you sure you want to delete the following record?</p>
|
||||
<p class="mb-1 ml-4 italic">{{ download.filename }}</p>
|
||||
<div class="">
|
||||
<input id="delete_file_{{ download.id }}" class="accent-orange-500" type="checkbox" value="false" name="delete_file" />
|
||||
<label for="delete_file_{{ download.id }}">Delete the file as well?</label>
|
||||
</div>
|
||||
</twig:Modal>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -4,113 +4,69 @@
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
Resolution
|
||||
<select id="resolution"
|
||||
data-result-filter-target="resolution"
|
||||
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
|
||||
value="{{ app.user.userPreferenceValues["resolution"] }}"
|
||||
>
|
||||
<option value="">n/a</option>
|
||||
{% for option in this.preferences['resolution'] %}
|
||||
<option value="{{ option.value }}"
|
||||
{{ option.value == this.userPreferences['resolution'] ? 'selected' }}
|
||||
>{{ option.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label for="codec">
|
||||
Codec
|
||||
<select id="codec" data-result-filter-target="codec" class="px-1 py-0.5 bg-stone-100 text-sm text-gray-800 rounded-md">
|
||||
<option value="">n/a</option>
|
||||
{% for option in this.preferences['codec'] %}
|
||||
<option value="{{ option.value }}"
|
||||
{{ option.value == this.userPreferences['codec'] ? 'selected' }}
|
||||
>{{ option.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label for="language">
|
||||
Language
|
||||
<select id="language"
|
||||
data-result-filter-target="language"
|
||||
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
|
||||
{% if this.userPreferences['language'] != null %}
|
||||
data-preferred="{{ this.userPreferences['language'] }}"
|
||||
{% endif %}
|
||||
>
|
||||
</select>
|
||||
</label>
|
||||
<label for="provider">
|
||||
Provider
|
||||
<select id="provider"
|
||||
data-result-filter-target="provider"
|
||||
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
|
||||
{% if this.userPreferences['provider'] != null %}
|
||||
data-preferred="{{ this.userPreferences['provider'] }}"
|
||||
{% endif %}
|
||||
>
|
||||
</select>
|
||||
</label>
|
||||
<label for="quality">
|
||||
Quality
|
||||
<select id="quality"
|
||||
data-result-filter-target="quality"
|
||||
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
|
||||
{% if this.userPreferences['quality'] != null %}
|
||||
data-preferred="{{ this.userPreferences['quality'] }}"
|
||||
{% endif %}
|
||||
>
|
||||
</select>
|
||||
</label>
|
||||
{% if results.media.mediaType == "tvshows" %}
|
||||
<label for="season">
|
||||
Season
|
||||
<select id="season" name="season" value="1" data-result-filter-target="season" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
|
||||
{{ stimulus_action('result_filter', 'setSeason', 'change') }}
|
||||
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}
|
||||
>
|
||||
<option selected value="1">1</option>
|
||||
{% for season in range(2, results.media.episodes|length) %}
|
||||
<option value="{{ season }}">{{ season }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{# <label for="episodeNumber">#}
|
||||
{# Episode#}
|
||||
{# <select id="episodeNumber" name="episodeNumber" data-result-filter-target="episode" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-sm">#}
|
||||
{# <option selected value="">n/a</option>#}
|
||||
{# </select>#}
|
||||
{# </label>#}
|
||||
{% endif %}
|
||||
<span {{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
|
||||
|
||||
{% set preferences_form = form %}
|
||||
{{ form_start(preferences_form) }}
|
||||
<h3 class="font-bold text-lg mb-2 md:mb-4">Apply a filter to your results</h3>
|
||||
<div class="flex flex-col md:flex-row gap-2 justify-between">
|
||||
{{ form_row(preferences_form.resolution) }}
|
||||
{{ form_row(preferences_form.codec) }}
|
||||
{{ form_row(preferences_form.language) }}
|
||||
{{ form_row(preferences_form.provider) }}
|
||||
{{ form_row(preferences_form.quality) }}
|
||||
|
||||
{% if results.media.mediaType == "tvshows" %}
|
||||
<div class="flex flex-col gap-1 md:gap-3">
|
||||
<label for="season">
|
||||
Season
|
||||
</label>
|
||||
<select id="season" name="season" value="1" data-result-filter-target="season" class="px-1 mb-4 py-1 md:py-2 text-center bg-orange-500 rounded-ms text-black"
|
||||
{{ stimulus_action('result_filter', 'setSeason', 'change') }}
|
||||
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}
|
||||
>
|
||||
{% for season in range(1, results.media.episodes|length) %}
|
||||
<option value="{{ season }}"
|
||||
{% if results.season == season %}
|
||||
selected="selected"
|
||||
{% endif %}
|
||||
>{{ season }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ form_end(preferences_form) }}
|
||||
|
||||
<div class="flex flex-col md:flex-row justify-between">
|
||||
<span
|
||||
{{ stimulus_target('result-filter', 'loadingIcon') }}
|
||||
{{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
|
||||
class="loading-icon">
|
||||
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" />
|
||||
</span>
|
||||
|
||||
{% if results.media.mediaType == "tvshows" %}
|
||||
<div class="flex flex-row gap-2 justify-end px-8">
|
||||
<twig:Modal heading="Back up a sec!" button_text="Download Season" submit_action="{{ stimulus_action('result_filter', 'downloadSeason', 'click')|stimulus_action('dialog', 'close') }}" button_class="px-1.5 py-1 border border-green-500 bg-green-800/60 rounded-ms text-sm font-semibold" show_cancel show_submit>
|
||||
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
|
||||
the appropriate file(s).
|
||||
<br /><br />
|
||||
Do you wish to download <strong>season <span id="downloadSeasonModal">{{ results.season }}</span></strong> of "<strong>{{ results.media.title }}</strong>"?
|
||||
</twig:Modal>
|
||||
|
||||
<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_action('result_filter', 'downloadSelectedEpisodes', 'click') }}
|
||||
>Download Selected</button>
|
||||
|
||||
<input type="checkbox" name="selectAll" id="selectAll"
|
||||
{{ stimulus_target('result_filter', 'selectAll') }}
|
||||
{{ stimulus_action('result_filter', 'selectAllEpisodes', 'change') }}
|
||||
/>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if results.media.mediaType == "tvshows" %}
|
||||
<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>
|
||||
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
|
||||
the appropriate file(s).
|
||||
<br /><br />
|
||||
Do you wish to download <strong>season <span id="downloadSeasonModal">{{ results.season }}</span></strong> of "<strong>{{ results.media.title }}</strong>"?
|
||||
</twig:Modal>
|
||||
|
||||
<button class="px-1.5 py-1 bg-green-600 hover:bg-green-700 rounded-ms text-sm font-semibold"
|
||||
{{ stimulus_target('result_filter', 'downloadSelected') }}
|
||||
{{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }}
|
||||
>Download Selected</button>
|
||||
|
||||
<input type="checkbox" name="selectAll" id="selectAll"
|
||||
{{ stimulus_target('result_filter', 'selectAll') }}
|
||||
{{ stimulus_action('result_filter', 'selectAllEpisodes', 'change') }}
|
||||
/>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div {{ turbo_stream_listen(app.session.get('mercure_alert_topic')) }} class="fixed z-40 top-10 right-10">
|
||||
<div {{ turbo_stream_listen(app.session.get('mercure_alert_topic')) }} class="fixed z-40 top-4 right-3 md:top-10 md:right-10">
|
||||
<div class="z-40">
|
||||
<ul id="alert_list" class="flex flex-col gap-2">
|
||||
{% for message in app.flashes('warning') %}
|
||||
|
||||
@@ -3,7 +3,19 @@
|
||||
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
|
||||
class="mr-1 hover:underline rounded-md"
|
||||
>
|
||||
{{ monitor.title }}
|
||||
|
||||
{% if monitor.monitorType == "movies" %}
|
||||
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType} %}
|
||||
{% set route = path('app_search_result', routeParams) %}
|
||||
{% else %}
|
||||
{% set episodeIdDto = extract_from_episode_id(monitor|monitor_media_id) %}
|
||||
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType, season: episodeIdDto.season, episode: episodeIdDto.episode} %}
|
||||
{% set route = path('app_search_result', routeParams) ~ "#" ~ episode_anchor(episodeIdDto.season, episodeIdDto.episode) %}
|
||||
{% endif %}
|
||||
<a href="{{ route }}"
|
||||
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate">
|
||||
{{ monitor.title }}
|
||||
</a>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<nav id="navbar" {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950 animate__animated animate__slideInLeft animate__slow">
|
||||
<nav id="navbar" {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950 animate__animated animate__animateFaster">
|
||||
<div class="px-4 py-4 flex flex-col gap-12">
|
||||
<h1 class="text-3xl mt-12 md:mt-0 font-extrabold text-orange-500 mb-3"><a href="{{ path('app_index') }}">Torsearch</a></h1>
|
||||
<ul class="nav-list space-y-1">
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
mediaType: mediaType,
|
||||
imdbId: imdbId
|
||||
}) }}">
|
||||
<img src="{{ image }}" class="w-full md:w-40 rounded-md" />
|
||||
<img src="{{ preload(image) }}" class="w-full md:w-40 rounded-md" />
|
||||
</a>
|
||||
<a href="{{ path('app_search_result', {
|
||||
mediaType: mediaType,
|
||||
imdbId: imdbId
|
||||
}) }}">
|
||||
<h3 class="text-center text-white md:text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3>
|
||||
<h3 class="text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
>
|
||||
</select>
|
||||
<button
|
||||
id="search-button"
|
||||
class="absolute top-1 right-1 flex items-center rounded
|
||||
bg-green-600 py-1 px-2.5 border border-transparent text-center
|
||||
text-sm text-white transition-all
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
<div class="w-full flex flex-col">
|
||||
<h3 class="mb-4 text-xl font-medium leading-tight font-bold text-gray-50">
|
||||
{{ title }} - {{ year }}
|
||||
{{ title }} ({{ year }})
|
||||
</h3>
|
||||
<p class="hidden md:block md:text-gray-50">
|
||||
{{ description }}
|
||||
|
||||
8
templates/components/SubmitButton.html.twig
Normal file
8
templates/components/SubmitButton.html.twig
Normal file
@@ -0,0 +1,8 @@
|
||||
<button class="submit-button flex flex-row gap-2 items-center">
|
||||
{% if show_icon|default %}
|
||||
<twig:ux:icon name="zondicons:checkmark" width=".8rem" class="text-green-500" />
|
||||
{% endif %}
|
||||
{% if false == icon_only|default(false) %}
|
||||
{{ text|default('submit') }}
|
||||
{% endif %}
|
||||
</button>
|
||||
@@ -2,8 +2,9 @@
|
||||
class="episode-list flex flex-col gap-4"
|
||||
>
|
||||
<div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4">
|
||||
{% for episode in this.episodes.items %}
|
||||
<div id="episode_{{ episode['season_number'] }}_{{ episode['episode_number'] }}" class="results"
|
||||
{% for episode in this.getEpisodes().items %}
|
||||
<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-download-button-outlet=".download-btn"
|
||||
{{ stimulus_controller('tv_results', {
|
||||
@@ -15,70 +16,73 @@
|
||||
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="flex flex-col md:flex-row gap-4">
|
||||
{% if episode['poster'] != null %}
|
||||
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" />
|
||||
{% else %}
|
||||
<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" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-col gap-4 grow">
|
||||
<h4 class="text-md font-bold">
|
||||
{{ episode['episode_number'] }}. {{ episode['name'] }}
|
||||
</h4>
|
||||
<p>{{ episode['overview']|truncate }}</p>
|
||||
<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"
|
||||
{{ stimulus_action('tv-results', 'toggleList', 'click') }}
|
||||
>
|
||||
<span {{ stimulus_target('tv-results', 'count') }}>-</span> results
|
||||
</button>
|
||||
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500/60 bg-clip-padding backdrop-filter backdrop-blur-md rounded-md">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
{% if episode['poster'] != null %}
|
||||
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" />
|
||||
{% else %}
|
||||
<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" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-col gap-4 grow">
|
||||
<h4 class="text-md font-bold">
|
||||
{{ episode['episode_number'] }}. {{ episode['name'] }}
|
||||
</h4>
|
||||
<p>{{ episode['overview']|truncate }}</p>
|
||||
<div>
|
||||
<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'] }}.">
|
||||
<span class="results-count-number" {{ 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'] }}">
|
||||
{{ episode['air_date']|date(null, 'UTC') }}
|
||||
</small>
|
||||
<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') }}
|
||||
</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,
|
||||
season: episode['season_number'],
|
||||
episode: episode['episode_number'],
|
||||
block: 'media_exists_badge',
|
||||
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.">
|
||||
missing
|
||||
</small>
|
||||
</twig:Turbo:Frame>
|
||||
}) }}">
|
||||
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
|
||||
missing
|
||||
</small>
|
||||
</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 class="flex flex-col gap-4 justify-between">
|
||||
<div class="flex flex-col items-center">
|
||||
<input type="checkbox"
|
||||
{{ stimulus_target('tv-results', 'episodeSelector') }}
|
||||
/>
|
||||
</div>
|
||||
<button class="flex flex-col items-end transition-transform duration-300 ease-in-out rotate-90"
|
||||
{{ stimulus_target('tv-results', 'toggleButton') }}
|
||||
{{ 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 class="results-container inline-block overflow-hidden rounded-lg hidden">
|
||||
<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 {{ stimulus_target('tv-results', 'listContainer') }} class="inline-block overflow-hidden rounded-lg">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</episode-container>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% set paginator = this.episodes %}
|
||||
|
||||
17
templates/components/field/select.html.twig
Normal file
17
templates/components/field/select.html.twig
Normal file
@@ -0,0 +1,17 @@
|
||||
<div{{ attributes }}>
|
||||
<label class="text-gray-50" for="quality">{{ label }}</label>
|
||||
<select class="p-1.5 rounded-md mb-2" name="quality" id="quality" value="{{ value }}">
|
||||
{% if true == show_na %}
|
||||
<option class="text-gray-800"
|
||||
value=""
|
||||
{{ value is null ? "selected" }}
|
||||
>n/a</option>
|
||||
{% endif %}
|
||||
{% for option in options %}
|
||||
<option class="text-gray-800"
|
||||
value="{{ option.value }}"
|
||||
{{ quality == option.value ? "selected" }}
|
||||
>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full flex flex-col">
|
||||
<div class="mb-4 flex flex-row gap-2 justify-between">
|
||||
<h3 class="text-xl font-medium leading-tight font-bold text-gray-50">
|
||||
{{ results.media.title }} - {{ results.media.year }}
|
||||
{{ results.media.title }} ({{ results.media.year }})
|
||||
</h3>
|
||||
|
||||
{% if results.media.mediaType == "tvshows" %}
|
||||
@@ -56,8 +56,8 @@
|
||||
|
||||
{% if "movies" == results.media.mediaType %}
|
||||
<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 id="movie_results_count">-</span> results
|
||||
<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 class="results-count-number" id="movie_results_count">-</span> results
|
||||
</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 }}">
|
||||
@@ -81,16 +81,22 @@
|
||||
<twig:Filter results="{{ results }}" filter="{{ filter }}" />
|
||||
|
||||
{% 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}) }}
|
||||
data-movie-results-loading-icon-outlet=".loading-icon"
|
||||
>
|
||||
</div>
|
||||
<twig:Turbo:Frame id="movie_results_frame" src="{{ path('app_torrentio_movies', {
|
||||
tmdbId: results.media.tmdbId,
|
||||
imdbId: results.media.imdbId,
|
||||
target: 'movie_results_frame',
|
||||
block: 'movie_results'
|
||||
}) }}" />
|
||||
</movie-container>
|
||||
{% elseif "tvshows" == results.media.mediaType %}
|
||||
<twig:TvEpisodeList
|
||||
results="results"
|
||||
:imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1"
|
||||
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer"
|
||||
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer" :episodeNumber="results.episode"
|
||||
/>
|
||||
{% endif %}
|
||||
</twig:Card>
|
||||
|
||||
21
templates/torrentio/fragments.html.twig
Normal file
21
templates/torrentio/fragments.html.twig
Normal file
@@ -0,0 +1,21 @@
|
||||
{% block movie_results %}
|
||||
<turbo-stream action="replace" targets="#{{ target }}">
|
||||
<template>
|
||||
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
|
||||
<div class="overflow-hidden rounded-md">
|
||||
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% endblock %}
|
||||
|
||||
{% block tvshow_results %}
|
||||
<turbo-stream action="replace" targets="#{{ target }}">
|
||||
<template>
|
||||
<div id="{{ target }}">
|
||||
{{ include('torrentio/partial/option-table.html.twig', {controller: 'tv-results'}) }}
|
||||
</div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% endblock %}
|
||||
@@ -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") }}
|
||||
>
|
||||
<thead class="text-xs text-gray-700 uppercase dark:text-gray-400">
|
||||
@@ -41,7 +41,29 @@
|
||||
</thead>
|
||||
<tbody class="flex-1 sm:flex-none">
|
||||
{% 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">
|
||||
{{ result.size }}
|
||||
</td>
|
||||
@@ -64,17 +86,7 @@
|
||||
{{ result.languageFlags|raw }}
|
||||
</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">
|
||||
<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') }}
|
||||
>
|
||||
<button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50">
|
||||
Download
|
||||
</button>
|
||||
<label for="select">
|
||||
|
||||
@@ -5,84 +5,27 @@
|
||||
{% block body %}
|
||||
<div class="p-4 flex flex-col md:flex-row gap-2">
|
||||
<twig:Card title="Media Preferences" class="w-full">
|
||||
<p class="text-gray-50 mb-2">Define a filter to be pre-applied to your download options.</p>
|
||||
<form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_save_media_preferences') }}">
|
||||
<label class="text-gray-50" for="quality">Quality</label>
|
||||
<select class="p-1.5 rounded-md mb-2" name="quality" id="quality" value="{{ mediaPreferences['quality'].getPreferenceValue() }}">
|
||||
<option class="text-gray-800"
|
||||
value=""
|
||||
{{ mediaPreferences['quality'].getPreferenceValue() is null ? "selected" }}
|
||||
>n/a</option>
|
||||
{% for quality in qualities %}
|
||||
<option class="text-gray-800"
|
||||
value="{{ quality }}"
|
||||
{{ quality == mediaPreferences['quality'].getPreferenceValue() ? "selected" }}
|
||||
>{{ quality }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label class="text-gray-50" for="resolution">Resolution</label>
|
||||
<select class="p-1.5 rounded-md mb-2" name="resolution" id="resolution" value="{{ mediaPreferences['resolution'].getPreferenceValue() }}">
|
||||
<option class="text-gray-800"
|
||||
value=""
|
||||
{{ mediaPreferences['resolution'] is null ? "selected" }}
|
||||
>n/a</option>
|
||||
|
||||
{% for pref in mediaPreferences['resolution'].getPreference().getPreferenceOptions() %}
|
||||
<option class="text-gray-800"
|
||||
value="{{ pref.id }}"
|
||||
{{ pref.id == mediaPreferences['resolution'].getPreferenceValue() ? "selected" }}
|
||||
>{{ pref.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label class="text-gray-50" for="codec">Codec</label>
|
||||
<select class="p-1.5 rounded-md mb-2" name="codec" id="codec" value="{{ mediaPreferences['codec'].getPreferenceValue() }}">
|
||||
<option class="text-gray-800"
|
||||
value=""
|
||||
{{ mediaPreferences['codec'].getPreferenceValue() is null ? "selected" }}
|
||||
>n/a</option>
|
||||
{% for pref in mediaPreferences['codec'].getPreference().getPreferenceOptions() %}
|
||||
<option class="text-gray-800"
|
||||
value="{{ pref.id }}"
|
||||
{{ pref.id == mediaPreferences['codec'].getPreferenceValue() ? "selected" }}
|
||||
>{{ pref.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label class="text-gray-50" for="provider">Provider</label>
|
||||
<select class="p-1.5 rounded-md mb-2" name="provider" id="provider" value="{{ mediaPreferences['provider'].getPreferenceValue() }}">
|
||||
<option class="text-gray-800"
|
||||
value=""
|
||||
{{ "" == mediaPreferences['provider'].getPreferenceValue() ? "selected" }}
|
||||
>n/a</option>
|
||||
{% for provider in providers %}
|
||||
<option class="text-gray-800"
|
||||
value="{{ provider }}"
|
||||
{{ provider == mediaPreferences['provider'].getPreferenceValue() ? "selected" }}
|
||||
>{{ provider }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label class="text-gray-50" for="language">Language</label>
|
||||
<select class="p-1.5 rounded-md mb-2" name="language" id="language" value="{{ mediaPreferences['language'].getPreferenceValue() }}">
|
||||
<option class="text-gray-800"
|
||||
value=""
|
||||
{{ mediaPreferences['language'].getPreferenceValue() is null ? "selected" }}
|
||||
>n/a</option>
|
||||
{% for language in languages %}
|
||||
<option class="text-gray-800"
|
||||
value="{{ language }}"
|
||||
{{ language == mediaPreferences['language'].getPreferenceValue() ? "selected" }}
|
||||
>{{ language }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
|
||||
</form>
|
||||
<p class="text-gray-50 mb-4">Define a filter to be pre-applied to your download options.</p>
|
||||
<div id="filter">
|
||||
{{ form_start(preferences_form) }}
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
{{ form_row(preferences_form.resolution) }}
|
||||
{{ form_row(preferences_form.codec) }}
|
||||
{{ form_row(preferences_form.language) }}
|
||||
{{ form_row(preferences_form.provider) }}
|
||||
{{ form_row(preferences_form.quality) }}
|
||||
<div class="self-end mb-4">
|
||||
<twig:SubmitButton show_icon text="Save"/>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_end(preferences_form) }}
|
||||
</div>
|
||||
</twig:Card>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex flex-col md:flex-row gap-2">
|
||||
<twig:Card title="Download Preferences" class="w-full">
|
||||
<p class="text-gray-50 mb-2">Change how your downloads are stored.</p>
|
||||
<p class="text-gray-50 mb-4">Change how your downloads are stored.</p>
|
||||
<form id="download_preferences" class="flex flex-col" name="download_preferences" method="post" action="{{ path('app_save_download_preferences') }}">
|
||||
<div class="flex flex-row gap-2 mb-2">
|
||||
<input type="hidden" name="movie_folder" id="movie_folder_hidden" value="0" />
|
||||
|
||||
Reference in New Issue
Block a user