Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| beed7d6940 | |||
| 924472ed56 | |||
| 7dd61355b7 | |||
| 2a1f69edd4 | |||
| 9db0bfd4c6 | |||
| 18a165fc40 | |||
| 0e13b74b3b | |||
| f9ec089f8b |
@@ -1,6 +1,15 @@
|
||||
export default class DownloadOptionTr extends HTMLTableRowElement {
|
||||
H264_CODECS = ['h264', 'h.264', 'x264']
|
||||
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
|
||||
H264_CODECS = {
|
||||
'h264': 'h264',
|
||||
'h.264': 'h264',
|
||||
'x264': 'h264',
|
||||
}
|
||||
H265_CODECS = {
|
||||
'h265': 'h265',
|
||||
'h.265': 'h265',
|
||||
'x265': 'h265',
|
||||
'hevc': 'h265',
|
||||
}
|
||||
|
||||
#downloadBtnEl;
|
||||
#selectEpisodeInputEl;
|
||||
@@ -53,13 +62,6 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
|
||||
|
||||
filter({ detail: { activeFilter } }) {
|
||||
const optionHeader = document.querySelector(`[data-option-id="${this.dataset['localId']}"]`)
|
||||
const props = {
|
||||
"resolution": this.resolution.trim(),
|
||||
"codec": this.codec.trim(),
|
||||
"provider": this.provider.trim(),
|
||||
"languages": this.languages,
|
||||
"quality": this.quality,
|
||||
}
|
||||
|
||||
let include = true;
|
||||
this.classList.add('r-tablerow');
|
||||
@@ -69,25 +71,24 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
|
||||
|
||||
this.querySelector('input[type="checkbox"]').checked = false;
|
||||
|
||||
for (let [key, value] of Object.entries(activeFilter)) {
|
||||
if (value === "" || key === "season") {
|
||||
continue;
|
||||
}
|
||||
if (key === "codec" && value === "h264") {
|
||||
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
|
||||
include = false;
|
||||
}
|
||||
} else if (key === "codec" && value === "h265") {
|
||||
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
|
||||
include = false;
|
||||
}
|
||||
} else if (key === "language") {
|
||||
if (!props["languages"].includes(value)) {
|
||||
include = false;
|
||||
}
|
||||
} else if (props[key] !== value) {
|
||||
include = false;
|
||||
}
|
||||
if (!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) {
|
||||
@@ -121,4 +122,66 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,17 @@ export default class extends Controller {
|
||||
seasons = []
|
||||
|
||||
activeFilter = {
|
||||
"resolution": "",
|
||||
"codec": "",
|
||||
"language": "",
|
||||
"provider": "",
|
||||
"quality": "",
|
||||
"resolution": [],
|
||||
"codec": [],
|
||||
"language": [],
|
||||
"provider": [],
|
||||
"quality": [],
|
||||
}
|
||||
|
||||
defaultOptions = '<option value="-">-</option>';
|
||||
|
||||
static outlets = ['tv-episode-list']
|
||||
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
|
||||
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'loadingIcon', 'selectAll', 'downloadSelected']
|
||||
static values = {
|
||||
'imdbId': String,
|
||||
'media-type': String,
|
||||
@@ -29,22 +31,32 @@ 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();
|
||||
}
|
||||
|
||||
document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
|
||||
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);
|
||||
this.addProviders(option);
|
||||
this.addQualities(option);
|
||||
option.filter({detail: {activeFilter: this.activeFilter }});
|
||||
})
|
||||
await this.filter();
|
||||
}
|
||||
|
||||
selectAllEpisodes() {
|
||||
@@ -59,93 +71,15 @@ export default class extends Controller {
|
||||
document.dispatchEvent(new CustomEvent('downloadSelectedEpisodes', {}));
|
||||
}
|
||||
|
||||
addLanguages(option) {
|
||||
const languages = Object.assign([], option.languages);
|
||||
languages.forEach((language) => {
|
||||
if (!this.languages.includes(language)) {
|
||||
this.languages.push(language);
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
if (!this.providers.includes(option.provider)) {
|
||||
this.providers.push(option.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();
|
||||
|
||||
}
|
||||
|
||||
addQualities(option) {
|
||||
if (!this.qualities.includes(option.quality)) {
|
||||
if (option.quality.toLowerCase() in this.reverseMappedQualitiesValue) {
|
||||
this.qualities.push(option.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");
|
||||
|
||||
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 ("tvshows" === this.mediaTypeValue) {
|
||||
@@ -175,4 +109,16 @@ export default class extends Controller {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#fetchValuesFromNodeList(nodeList) {
|
||||
return [...nodeList].map(option => option.value)
|
||||
}
|
||||
|
||||
#serializeSelectOptions(options) {
|
||||
return this.defaultOptions + options.sort()
|
||||
.map((option) => {
|
||||
return '<option value="' + option + '">' + option + '</option>'
|
||||
})
|
||||
.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ export default class extends Controller {
|
||||
option.querySelector('.download-btn').dataset['title'] = this.titleValue
|
||||
);
|
||||
this.element.options[0].querySelector('input[type="checkbox"]').checked = true;
|
||||
this.loadingIconOutlet.increaseCount();
|
||||
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.element.options}}));
|
||||
} else {
|
||||
this.countTarget.innerText = 0;
|
||||
this.episodeSelectorTarget.disabled = true;
|
||||
}
|
||||
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 |
@@ -68,6 +68,16 @@ dialog[data-dialog-target="dialog"][closing] {
|
||||
@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 {
|
||||
@apply bg-green-600/40 px-1.5 py-1 w-full rounded-md text-gray-50 backdrop-filter backdrop-blur-sm border-2 border-green-500 hover:bg-green-700/40
|
||||
}
|
||||
@@ -148,3 +158,34 @@ dialog[data-dialog-target="dialog"][closing] {
|
||||
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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,21 @@ 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 extends AbstractController
|
||||
{
|
||||
use DefaultActionTrait;
|
||||
use ComponentWithFormTrait;
|
||||
|
||||
public array $preferences = [];
|
||||
|
||||
@@ -43,4 +48,9 @@ final class Filter extends AbstractController
|
||||
{
|
||||
return CodecList::asSelectOptions();
|
||||
}
|
||||
|
||||
protected function instantiateForm(): FormInterface
|
||||
{
|
||||
return $this->createForm(UserMediaPreferencesForm::class, UserPreferencesFactory::createFromUser($this->getUser()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class UtilExtension
|
||||
|
||||
// Capture season
|
||||
$seasonMatch = [];
|
||||
preg_match('/[sS]\d\d/', $episodeId, $seasonMatch);
|
||||
preg_match('/[sS]\d\d(\d)?(\d)?/', $episodeId, $seasonMatch);
|
||||
if (empty($seasonMatch)) {
|
||||
$season = "";
|
||||
} else {
|
||||
@@ -89,7 +89,7 @@ class UtilExtension
|
||||
|
||||
// Capture episode
|
||||
$episodeMatch = [];
|
||||
preg_match('/[eE]\d\d/', $episodeId, $episodeMatch);
|
||||
preg_match('/[eE]\d\d(\d)?(\d)?/', $episodeId, $episodeMatch);
|
||||
if (empty($episodeMatch)) {
|
||||
$episode = "";
|
||||
} else {
|
||||
|
||||
@@ -19,11 +19,11 @@ class SaveUserMediaPreferencesCommand implements CommandInterface
|
||||
public static function fromUserMediaPreferencesForm(FormInterface $form): self
|
||||
{
|
||||
return new static(
|
||||
resolution: $form->get('resolution')->getData(),
|
||||
codec: $form->get('codec')->getData(),
|
||||
quality: $form->get('quality')->getData(),
|
||||
language: $form->get('language')->getData(),
|
||||
provider: $form->get('provider')->getData(),
|
||||
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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class UserPreferencesFactory
|
||||
if ($value === "") {
|
||||
return null;
|
||||
}
|
||||
$value = explode(',', $value);
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,7 @@ class PreferencesController extends AbstractController
|
||||
): Response
|
||||
{
|
||||
$downloadPreferences = $this->getUser()->getDownloadPreferences();
|
||||
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
|
||||
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
|
||||
$form = $this->createForm(UserMediaPreferencesForm::class);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
|
||||
@@ -8,19 +8,30 @@ 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
|
||||
{
|
||||
@@ -34,10 +45,20 @@ class UserMediaPreferencesForm extends AbstractType
|
||||
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void
|
||||
{
|
||||
$question = [
|
||||
'attr' => ['class' => 'w-64 text-input mb-4'],
|
||||
'label_attr' => ['class' => 'w-64 text-white block font-semibold mb-2'],
|
||||
'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);
|
||||
}
|
||||
@@ -46,11 +67,14 @@ class UserMediaPreferencesForm extends AbstractType
|
||||
{
|
||||
$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' => ''] + $choices;
|
||||
return ['n/a' => 'n/a'] + $choices;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ module.exports = {
|
||||
"bg-orange-400",
|
||||
"bg-blue-600",
|
||||
"bg-rose-600",
|
||||
"bg-black/20",
|
||||
"alert-success",
|
||||
"alert-warning",
|
||||
"font-bold",
|
||||
|
||||
@@ -6,108 +6,67 @@
|
||||
data-result-filter-tv-episode-list-outlet=".episode-list"
|
||||
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 name, value in this.resolutionOptions %}
|
||||
<option value="{{ value }}"
|
||||
{{ value == this.userPreferences['resolution'] ? 'selected' }}
|
||||
>{{ 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 name, value in this.codecOptions %}
|
||||
<option value="{{ value }}"
|
||||
{{ value == this.userPreferences['codec'] ? 'selected' }}
|
||||
>{{ 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') }}
|
||||
>
|
||||
{% for season in range(1, results.media.episodes|length) %}
|
||||
<option value="{{ season }}"
|
||||
{% if results.season == season %}
|
||||
selected="selected"
|
||||
{% endif %}
|
||||
>{{ season }}</option>
|
||||
{% endfor %}
|
||||
</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 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>
|
||||
@@ -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>
|
||||
@@ -16,7 +16,7 @@
|
||||
active: 'true',
|
||||
}) }}
|
||||
>
|
||||
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
|
||||
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500/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'] }}" />
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -6,16 +6,24 @@
|
||||
<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-4">Define a filter to be pre-applied to your download options.</p>
|
||||
{{ form_start(preferences_form) }}
|
||||
{{ form_row(preferences_form.language) }}
|
||||
{{ form_row(preferences_form.quality) }}
|
||||
{{ form_row(preferences_form.provider) }}
|
||||
{{ form_row(preferences_form.resolution) }}
|
||||
{{ form_row(preferences_form.codec) }}
|
||||
<button class="submit-button">Save</button>
|
||||
{{ form_end(preferences_form) }}
|
||||
<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-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') }}">
|
||||
|
||||
Reference in New Issue
Block a user