Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5bab8e6fd | |||
| 502b85dda4 | |||
| 9c430290e9 | |||
| 583591bf4f | |||
| 182708b8f0 | |||
| d6ba4d7d2a | |||
| e5c5ec93a8 | |||
| 942911d8ef | |||
| 2f7d406d12 | |||
| 4e1adc576c | |||
| 575fc08f24 | |||
| 87bdde801d | |||
| 7d35b6266b | |||
| caeda625fd | |||
| d710e31d2b | |||
| 39a64faa74 | |||
| c6a84df2fd | |||
| a7273cf2e5 | |||
| c9cfa5e427 | |||
| cb50007208 | |||
| 62aa0f4554 | |||
| 08e376babc | |||
| 2becc98d61 | |||
| 0430dba6a9 | |||
| beed7d6940 | |||
| 924472ed56 | |||
| 7dd61355b7 | |||
| 2a1f69edd4 | |||
| 9db0bfd4c6 | |||
| 18a165fc40 | |||
| 0e13b74b3b | |||
| f9ec089f8b | |||
| 87e72ec55e | |||
| 23a88ec6bb | |||
| d33a961f2d | |||
| 566886ef0e | |||
| 65acd5d21b | |||
| a27fcf334a | |||
| 56c5156380 | |||
| 18b00fc5ae | |||
| e39faa3398 |
7
.env
7
.env
@@ -51,3 +51,10 @@ OIDC_CLIENT_ID="Enter your OIDC client id"
|
||||
OIDC_CLIENT_SECRET="Enter your OIDC client secret"
|
||||
OIDC_BYPASS_FORM_LOGIN=false
|
||||
###< drenso/symfony-oidc-bundle ###
|
||||
|
||||
###> symfony/ntfy-notifier ###
|
||||
# NTFY_DSN=ntfy://default/TOPIC
|
||||
###< symfony/ntfy-notifier ###
|
||||
|
||||
NOTIFICATION_TRANSPORT=
|
||||
NTFY_DSN=
|
||||
|
||||
3
.env.test
Normal file
3
.env.test
Normal file
@@ -0,0 +1,3 @@
|
||||
# define your env variables for the test env here
|
||||
KERNEL_CLASS='App\Kernel'
|
||||
APP_SECRET='$ecretf0rt3st'
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -19,3 +19,8 @@ bolt.db
|
||||
phpstan.neon
|
||||
###< phpstan/phpstan ###
|
||||
.php-cs-fixer.cache
|
||||
|
||||
###> phpunit/phpunit ###
|
||||
/phpunit.xml
|
||||
/.phpunit.cache/
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
@@ -18,11 +18,3 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
16
assets/bootstrap.js
vendored
16
assets/bootstrap.js
vendored
@@ -1,9 +1,16 @@
|
||||
import '@ungap/custom-elements'
|
||||
import PreviewContentDialog from "./components/preview-content-dialog.js";
|
||||
import EpisodeContainer from './components/episode-container.js';
|
||||
import DownloadOptionTr from './components/download-option-tr.js';
|
||||
import DownloadListRow from './components/download-list-row.js';
|
||||
import MonitorListRow from './components/monitor-list-row.js';
|
||||
import MovieContainer from "./components/movie-container.js";
|
||||
|
||||
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
||||
import Popover from '@stimulus-components/popover';
|
||||
import Dialog from '@stimulus-components/dialog';
|
||||
import Dropdown from '@stimulus-components/dropdown';
|
||||
import 'animate.css';
|
||||
import Brock from './components/brock.js';
|
||||
|
||||
const app = startStimulusApp();
|
||||
// register any custom, 3rd party controllers here
|
||||
@@ -11,4 +18,9 @@ app.register('popover', Popover);
|
||||
app.register('dialog', Dialog);
|
||||
app.register('dropdown', Dropdown);
|
||||
|
||||
customElements.define('brock-app', Brock);
|
||||
customElements.define('preview-content-dialog', PreviewContentDialog, {extends: 'dialog'});
|
||||
customElements.define('episode-container', EpisodeContainer);
|
||||
customElements.define('movie-container', MovieContainer);
|
||||
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});
|
||||
customElements.define('download-list-row', DownloadListRow, {extends: 'tr'});
|
||||
customElements.define('monitor-list-row', MonitorListRow, {extends: 'tr'});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export default class Brock extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
Yo, yo, yo! Waddup ${this.name}, doe, it's Brocky fresh!
|
||||
`;
|
||||
}
|
||||
|
||||
// attribute change
|
||||
attributeChangedCallback(property, oldValue, newValue) {
|
||||
if (oldValue === newValue) return;
|
||||
this[ property ] = newValue;
|
||||
this.render();
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['name'];
|
||||
}
|
||||
}
|
||||
111
assets/components/download-list-row.js
Normal file
111
assets/components/download-list-row.js
Normal file
@@ -0,0 +1,111 @@
|
||||
export default class DownloadListRow extends HTMLTableRowElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.downloadId = this.getAttribute('download-id');
|
||||
this.imdbId = this.getAttribute('imdb-id');
|
||||
this.mediaTitle = this.getAttribute('media-title');
|
||||
this.url = this.getAttribute('url');
|
||||
this.filename = this.getAttribute('filename');
|
||||
this.status = this.getAttribute('status');
|
||||
this.progress = this.getAttribute('progress');
|
||||
this.mediaType = this.getAttribute('media-type');
|
||||
this.episodeId = this.getAttribute('episode-id');
|
||||
this.createdAt = this.getAttribute('created-at');
|
||||
this.updatedAt = this.getAttribute('updated-at');
|
||||
|
||||
// this.previewContent = this.previewContent.bind(this);
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['download-id', 'imdb-id', 'media-title', 'url', 'filename', 'status', 'progress', 'media-type', 'episode-id', 'created-at', 'updated-at'];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (oldValue !== newValue) {
|
||||
this[name] = newValue;
|
||||
this.setAttribute(name, newValue);
|
||||
this.setPreviewContent();
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewContent() {
|
||||
this.previewContent = `
|
||||
<table class="table-auto flex flex-row">
|
||||
<thead>
|
||||
<tr class="flex flex-col">
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">ID</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">IMDB ID</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Title</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">URL</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Filename</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Status</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Progress</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Media Type</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode ID</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Created At</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Updated At</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="flex flex-col">
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('download-id') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('imdb-id') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-title') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('url') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('filename') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('status') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('progress') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-type') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode-id') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('created-at') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('updated-at') ?? "-"}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
115
assets/components/monitor-list-row.js
Normal file
115
assets/components/monitor-list-row.js
Normal file
@@ -0,0 +1,115 @@
|
||||
export default class MonitorListRow extends HTMLTableRowElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.downloadId = this.getAttribute('monitor-id');
|
||||
this.imdbId = this.getAttribute('imdb-id');
|
||||
this.mediaTitle = this.getAttribute('media-title');
|
||||
this.url = this.getAttribute('url');
|
||||
this.filename = this.getAttribute('filename');
|
||||
this.status = this.getAttribute('status');
|
||||
this.progress = this.getAttribute('progress');
|
||||
this.mediaType = this.getAttribute('media-type');
|
||||
this.episodeId = this.getAttribute('episode-id');
|
||||
this.createdAt = this.getAttribute('created-at');
|
||||
this.updatedAt = this.getAttribute('updated-at');
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['download-id', 'imdb-id', 'media-title', 'url', 'filename', 'status', 'progress', 'media-type', 'episode-id', 'created-at', 'updated-at'];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (oldValue !== newValue) {
|
||||
this[name] = newValue;
|
||||
this.setAttribute(name, newValue);
|
||||
this.setPreviewContent();
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewContent() {
|
||||
this.previewContent = `
|
||||
<table class="table-auto flex flex-row">
|
||||
<thead>
|
||||
<tr class="flex flex-col">
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">ID</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">IMDB ID</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Title</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Season</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Status</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Search Count</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Media Type</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode ID</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Created At</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Updated At</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Downloaded At</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="flex flex-col">
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('monitor-id') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('imdb-id') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-title') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('season') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('status') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('search-count') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-type') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode-id') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('created-at') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('last-search') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('downloaded-at') ?? "-"}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
35
assets/components/preview-content-dialog.js
Normal file
35
assets/components/preview-content-dialog.js
Normal file
@@ -0,0 +1,35 @@
|
||||
export default class PreviewContentDialog extends HTMLDialogElement {
|
||||
#headingEl;
|
||||
#contentEl;
|
||||
#closeBtnEl;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#headingEl = this.querySelector('.modal-heading');
|
||||
this.#contentEl = this.querySelector('.modal-content');
|
||||
this.#closeBtnEl = this.querySelector('.modal-close');
|
||||
|
||||
this.setHeading = this.setHeading.bind(this);
|
||||
this.setContent = this.setContent.bind(this);
|
||||
|
||||
this.#closeBtnEl.addEventListener('click', () => this.close());
|
||||
document.addEventListener('hidePreviewContentModal', () => this.close());
|
||||
document.addEventListener('showPreviewContentModal', (event) => {
|
||||
this.display(event.detail);
|
||||
});
|
||||
}
|
||||
|
||||
setHeading(heading) {
|
||||
this.#headingEl.innerHTML = heading;
|
||||
}
|
||||
|
||||
setContent(content) {
|
||||
this.#contentEl.innerHTML = content;
|
||||
}
|
||||
|
||||
display({ heading, content }) {
|
||||
this.setHeading(heading);
|
||||
this.setContent(content);
|
||||
this.showModal();
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,43 @@ export default class extends Controller {
|
||||
// Here you can add event listeners on the element or target elements,
|
||||
// add or remove classes, attributes, dispatch custom events, etc.
|
||||
// this.fooTarget.addEventListener('click', this._fooBar)
|
||||
// this.element.addEventListener('click', (event) => {
|
||||
// let previewContentModal = document.querySelector('#previewContentModal');
|
||||
// // previewContentModal.setHeading(event.target.dataset['title']);
|
||||
// // previewContentModal.setContent('<p>Testing this here thingy-ma-bob!</p>');
|
||||
// // previewContentModal.showModal();
|
||||
// let content, heading = ""
|
||||
// if (event.target.tagName !== "TR") {
|
||||
// content = event.target.parentElement.previewContent();
|
||||
// heading = event.target.parentElement.mediaTitle;
|
||||
// } else {
|
||||
// content = event.target.previewContent();
|
||||
// heading = event.target.mediaTitle;
|
||||
// }
|
||||
//
|
||||
// document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
|
||||
// })
|
||||
}
|
||||
|
||||
downloadTargetConnected(target) {
|
||||
let downloads = this.element.querySelectorAll('tbody tr');
|
||||
if (downloads.length > 5) {
|
||||
target.classList.add('hidden');
|
||||
}
|
||||
|
||||
downloads.forEach(download => {
|
||||
download.addEventListener('click', (event) => {
|
||||
let content, heading = ""
|
||||
if (event.target.tagName !== "TR") {
|
||||
content = event.target.parentElement.previewContent;
|
||||
heading = "Download # " + event.target.parentElement.downloadId + " - \"" + event.target.parentElement.mediaTitle + "\"";
|
||||
} else {
|
||||
content = event.target.previewContent;
|
||||
heading = "Download # " + event.target.downloadId + " - \"" + event.target.mediaTitle + "\"";
|
||||
}
|
||||
|
||||
if (null !== content && undefined !== content && "" !== content) {
|
||||
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pauseDownload(data) {
|
||||
|
||||
@@ -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,6 +6,29 @@ import { Controller } from '@hotwired/stimulus';
|
||||
*/
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['monitorList']
|
||||
|
||||
monitorListTargetConnected(target) {
|
||||
let monitors = this.element.querySelectorAll('tbody tr');
|
||||
|
||||
monitors.forEach(monitor => {
|
||||
monitor.addEventListener('click', (event) => {
|
||||
let content, heading = ""
|
||||
if (event.target.tagName !== "TR") {
|
||||
content = event.target.parentElement.previewContent;
|
||||
heading = "Monitor for \"" + event.target.parentElement.mediaTitle+ "\"";
|
||||
} else {
|
||||
content = event.target.previewContent;
|
||||
heading = "Monitor for \"" + event.target.mediaTitle + "\"";
|
||||
}
|
||||
|
||||
if (null !== content && undefined !== content && "" !== content) {
|
||||
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
deleteMonitor(data) {
|
||||
fetch(`/api/monitor/${data.params.id}`, {method: 'DELETE'})
|
||||
.then(res => res.json())
|
||||
|
||||
@@ -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,
|
||||
@@ -30,73 +27,8 @@ export default class extends Controller {
|
||||
this.optionsLoaded = true;
|
||||
this.options = this.element.querySelectorAll('tbody tr');
|
||||
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
|
||||
this.dispatch('optionsLoaded', {detail: {options: this.options}})
|
||||
this.loadingIconOutlet.toggleIcon();
|
||||
this.resultCountEl.innerText = this.options.length;
|
||||
}
|
||||
|
||||
// Keeps compatible with Filter & TV Shows
|
||||
isActive() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async filter(activeFilter) {
|
||||
let firstIncluded = true;
|
||||
let count = 0;
|
||||
let selectedCount = 0;
|
||||
|
||||
this.options.forEach((option) => {
|
||||
const optionHeader = document.querySelector(`[data-option-id="${option.dataset['localId']}"]`)
|
||||
const props = {
|
||||
"resolution": option.querySelector('#resolution').textContent.trim(),
|
||||
"codec": option.querySelector('#codec').textContent.trim(),
|
||||
"provider": option.querySelector('#provider').textContent.trim(),
|
||||
"quality": option.dataset['quality'],
|
||||
"languages": JSON.parse(option.dataset['languages']),
|
||||
}
|
||||
|
||||
let include = true;
|
||||
option.classList.add('r-tablerow');
|
||||
option.classList.remove('hidden');
|
||||
optionHeader.classList.add('r-tablerow');
|
||||
optionHeader.classList.remove('hidden');
|
||||
option.querySelector('input[type="checkbox"]').checked = false;
|
||||
|
||||
for (let [key, value] of Object.entries(activeFilter)) {
|
||||
if (value === "" || key === "season") {
|
||||
continue;
|
||||
}
|
||||
if (key === "codec" && value === "h264") {
|
||||
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
|
||||
include = false;
|
||||
}
|
||||
} else if (key === "codec" && value === "h265") {
|
||||
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
|
||||
include = false;
|
||||
}
|
||||
} else if (key === "language") {
|
||||
if (!props["languages"].includes(value)) {
|
||||
include = false;
|
||||
}
|
||||
} else if (props[key] !== value) {
|
||||
include = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (false === include) {
|
||||
option.classList.remove('r-tablerow');
|
||||
option.classList.add('hidden');
|
||||
optionHeader.classList.remove('r-tablerow');
|
||||
optionHeader.classList.add('hidden');
|
||||
} else if (true === firstIncluded) {
|
||||
count = 1;
|
||||
selectedCount = selectedCount + 1;
|
||||
option.querySelector('input[type="checkbox"]').checked = true;
|
||||
firstIncluded = false;
|
||||
} else {
|
||||
count = count + 1;
|
||||
}
|
||||
});
|
||||
this.resultCountEl.innerText = count;
|
||||
this.loadingIconOutlet.toggleIcon();
|
||||
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ export default class extends Controller {
|
||||
});
|
||||
if (window.location.hash) {
|
||||
let targetElement = document.querySelector(window.location.hash);
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster');
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,149 +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 listTargetConnected() {
|
||||
this.options = this.element.querySelectorAll('tbody tr');
|
||||
if (this.options.length > 0) {
|
||||
this.options.forEach((option) =>
|
||||
listTargetConnected() {
|
||||
this.element.options = this.element.querySelectorAll('tbody tr');
|
||||
if (this.element.options.length > 0) {
|
||||
this.element.options.forEach((option) =>
|
||||
option.querySelector('.download-btn').dataset['title'] = this.titleValue
|
||||
);
|
||||
this.options[0].querySelector('input[type="checkbox"]').checked = true;
|
||||
this.dispatch('optionsLoaded', {detail: {options: this.options}})
|
||||
this.loadingIconOutlet.increaseCount();
|
||||
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']),
|
||||
"quality": option.dataset['quality'],
|
||||
}
|
||||
|
||||
let include = true;
|
||||
option.classList.add('r-tablerow');
|
||||
option.classList.remove('hidden');
|
||||
optionHeader.classList.add('r-tablerow');
|
||||
optionHeader.classList.remove('hidden');
|
||||
option.querySelector('input[type="checkbox"]').checked = false;
|
||||
|
||||
for (let [key, value] of Object.entries(activeFilter)) {
|
||||
if (value === "" || key === "season") {
|
||||
continue;
|
||||
}
|
||||
if (key === "codec" && value === "h264") {
|
||||
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
|
||||
include = false;
|
||||
}
|
||||
} else if (key === "codec" && value === "h265") {
|
||||
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
|
||||
include = false;
|
||||
}
|
||||
} else if (key === "language") {
|
||||
if (!props["languages"].includes(value)) {
|
||||
include = false;
|
||||
}
|
||||
} else if (props[key] !== value) {
|
||||
include = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (false === include) {
|
||||
option.classList.remove('r-tablerow');
|
||||
option.classList.add('hidden');
|
||||
optionHeader.classList.remove('r-tablerow');
|
||||
optionHeader.classList.add('hidden');
|
||||
} else if (true === firstIncluded) {
|
||||
count = 1;
|
||||
selectedCount = selectedCount + 1;
|
||||
option.querySelector('input[type="checkbox"]').checked = true;
|
||||
firstIncluded = false;
|
||||
} else {
|
||||
count = count + 1;
|
||||
}
|
||||
|
||||
this.countTarget.innerText = count;
|
||||
});
|
||||
this.loadingIconOutlet.increaseCount();
|
||||
}
|
||||
}
|
||||
|
||||
57
assets/controllers/upcoming_episodes_controller.js
Normal file
57
assets/controllers/upcoming_episodes_controller.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { Calendar } from '@fullcalendar/core';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
calendar = null;
|
||||
|
||||
initialize() {
|
||||
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.calendar = new Calendar(this.element, {
|
||||
plugins: [ dayGridPlugin, timeGridPlugin ],
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||
},
|
||||
editable: true, // Allow events to be dragged and resized
|
||||
events: '/api/events', // Symfony route to fetch events
|
||||
eventDrop: function(info) {
|
||||
// Handle event drop (e.g., update event in database via AJAX)
|
||||
},
|
||||
eventResize: function(info) {
|
||||
// Handle event resize (e.g., update event in database via AJAX)
|
||||
}
|
||||
});
|
||||
this.calendar.render();
|
||||
// this.calendar = new Calendar(this.element, {
|
||||
// plugins: [ dayGridPlugin, timeGridPlugin, listPlugin ],
|
||||
// initialView: 'dayGridMonth',
|
||||
// headerToolbar: {
|
||||
// left: 'prev,next today',
|
||||
// center: 'title',
|
||||
// right: 'dayGridMonth,timeGridWeek,listWeek'
|
||||
// }
|
||||
// });
|
||||
// this.calendar.render();
|
||||
// calendar.render();
|
||||
}
|
||||
|
||||
// Add custom controller actions here
|
||||
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
|
||||
|
||||
disconnect() {
|
||||
// Called anytime its element is disconnected from the DOM
|
||||
// (on page change, when it's removed from or moved in the DOM, etc.)
|
||||
|
||||
// Here you should remove all event listeners added in "connect()"
|
||||
// this.fooTarget.removeEventListener('click', this._fooBar)
|
||||
}
|
||||
}
|
||||
1
assets/icons/solar/calendar-linear.svg
Normal file
1
assets/icons/solar/calendar-linear.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none"><path stroke="currentColor" stroke-width="1.5" d="M2 12c0-3.771 0-5.657 1.172-6.828S6.229 4 10 4h4c3.771 0 5.657 0 6.828 1.172S22 8.229 22 12v2c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14z"/><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M7 4V2.5M17 4V2.5M2.5 9h19"/><path fill="currentColor" d="M18 17a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-5 4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-5 4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0"/></g></svg>
|
||||
|
After Width: | Height: | Size: 653 B |
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 |
@@ -27,6 +27,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--fc-border-color: #a65b27;
|
||||
--fc-page-bg-color: #a65b27;
|
||||
}
|
||||
|
||||
/* Prevent scrolling while dialog is open */
|
||||
body:has(dialog[data-dialog-target="dialog"][open]) {
|
||||
overflow: hidden;
|
||||
@@ -55,6 +60,10 @@ dialog {
|
||||
}
|
||||
}
|
||||
|
||||
dialog[open] {
|
||||
animation: fade-in 100ms ease-in forwards;
|
||||
}
|
||||
|
||||
/* Add animations */
|
||||
dialog[data-dialog-target="dialog"][open] {
|
||||
animation: fade-in 200ms forwards;
|
||||
@@ -68,6 +77,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
|
||||
}
|
||||
@@ -123,10 +142,63 @@ dialog[data-dialog-target="dialog"][closing] {
|
||||
|
||||
#search .ts-dropdown {
|
||||
background: unset;
|
||||
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
|
||||
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md z-20
|
||||
}
|
||||
|
||||
#search .ts-dropdown .ts-dropdown-content .option.active {
|
||||
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;
|
||||
}
|
||||
|
||||
.fc-col-header-cell {
|
||||
@apply bg-orange-500/60 text-white;
|
||||
}
|
||||
|
||||
23
bin/phpunit
Executable file
23
bin/phpunit
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (!ini_get('date.timezone')) {
|
||||
ini_set('date.timezone', 'UTC');
|
||||
}
|
||||
|
||||
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
|
||||
if (PHP_VERSION_ID >= 80000) {
|
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||
} else {
|
||||
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
|
||||
require PHPUNIT_COMPOSER_INSTALL;
|
||||
PHPUnit\TextUI\Command::main();
|
||||
}
|
||||
} else {
|
||||
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
|
||||
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
|
||||
}
|
||||
@@ -43,6 +43,8 @@
|
||||
"symfony/mailer": "7.3.*",
|
||||
"symfony/mercure-bundle": "^0.3.9",
|
||||
"symfony/messenger": "7.3.*",
|
||||
"symfony/notifier": "7.3.*",
|
||||
"symfony/ntfy-notifier": "7.3.*",
|
||||
"symfony/runtime": "7.3.*",
|
||||
"symfony/scheduler": "7.3.*",
|
||||
"symfony/security-bundle": "7.3.*",
|
||||
@@ -104,6 +106,7 @@
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"tail": "docker compose exec app ./bin/console tailwind:build --watch",
|
||||
"sym": "docker compose exec app ./bin/console"
|
||||
},
|
||||
"conflict": {
|
||||
@@ -117,6 +120,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^12.3",
|
||||
"symfony/maker-bundle": "^1.62",
|
||||
"symfony/stopwatch": "7.3.*",
|
||||
"symfony/web-profiler-bundle": "7.3.*"
|
||||
|
||||
1710
composer.lock
generated
1710
composer.lock
generated
File diff suppressed because it is too large
Load Diff
13
config/packages/notifier.yaml
Normal file
13
config/packages/notifier.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
framework:
|
||||
notifier:
|
||||
chatter_transports:
|
||||
texter_transports:
|
||||
ntfy: '%notification.ntfy.dsn%'
|
||||
channel_policy:
|
||||
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
||||
urgent: ['email']
|
||||
high: ['email']
|
||||
medium: ['email']
|
||||
low: ['email']
|
||||
admin_recipients:
|
||||
- { email: admin@example.com }
|
||||
@@ -44,6 +44,10 @@ parameters:
|
||||
auth.oidc.client_secret: '%env(OIDC_CLIENT_SECRET)%'
|
||||
auth.oidc.bypass_form_login: '%env(bool:OIDC_BYPASS_FORM_LOGIN)%'
|
||||
|
||||
# Notifications
|
||||
notification.transport: '%env(NOTIFICATION_TRANSPORT)%'
|
||||
notification.ntfy.dsn: '%env(NTFY_DSN)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
|
||||
@@ -6,7 +6,6 @@ services:
|
||||
environment:
|
||||
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
|
||||
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
|
||||
tty: true
|
||||
deploy:
|
||||
replicas: 2
|
||||
volumes:
|
||||
@@ -16,6 +15,10 @@ services:
|
||||
- mercure_config:/config
|
||||
depends_on:
|
||||
- database
|
||||
logging:
|
||||
driver: "gelf"
|
||||
options:
|
||||
gelf-address: "tcp://192.168.1.197:12202"
|
||||
|
||||
|
||||
worker:
|
||||
@@ -29,6 +32,10 @@ services:
|
||||
replicas: 2
|
||||
depends_on:
|
||||
- app
|
||||
logging:
|
||||
driver: "gelf"
|
||||
options:
|
||||
gelf-address: "tcp://192.168.1.197:12203"
|
||||
|
||||
|
||||
scheduler:
|
||||
@@ -40,6 +47,11 @@ services:
|
||||
command: -vv
|
||||
depends_on:
|
||||
- app
|
||||
logging:
|
||||
driver: "gelf"
|
||||
options:
|
||||
gelf-address: "tcp://192.168.1.197:12204"
|
||||
|
||||
|
||||
|
||||
redis:
|
||||
|
||||
@@ -67,4 +67,7 @@ return [
|
||||
'pulltorefreshjs' => [
|
||||
'version' => '0.1.22',
|
||||
],
|
||||
'@ungap/custom-elements' => [
|
||||
'version' => '1.3.0',
|
||||
],
|
||||
];
|
||||
|
||||
35
migrations/Version20250823173128.php
Normal file
35
migrations/Version20250823173128.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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 Version20250823173128 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 monitor ADD air_date DATETIME DEFAULT NULL
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE monitor DROP air_date
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
44
phpunit.dist.xml
Normal file
44
phpunit.dist.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
colors="true"
|
||||
failOnDeprecation="true"
|
||||
failOnNotice="true"
|
||||
failOnWarning="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Project Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source ignoreSuppressionOfDeprecations="true"
|
||||
ignoreIndirectDeprecations="true"
|
||||
restrictNotices="true"
|
||||
restrictWarnings="true"
|
||||
>
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
|
||||
<deprecationTrigger>
|
||||
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||
<function>trigger_deprecation</function>
|
||||
</deprecationTrigger>
|
||||
</source>
|
||||
|
||||
<extensions>
|
||||
</extensions>
|
||||
</phpunit>
|
||||
@@ -38,6 +38,12 @@ final class ConfigResolver
|
||||
|
||||
#[Autowire(param: 'auth.oidc.bypass_form_login')]
|
||||
private ?bool $authOidcBypassFormLogin = null,
|
||||
|
||||
#[Autowire(param: 'notification.transport')]
|
||||
private ?string $notificationTransport = null,
|
||||
|
||||
#[Autowire(param: 'notification.ntfy.dsn')]
|
||||
private ?string $notificationNtfyDsn = null,
|
||||
) {}
|
||||
|
||||
public function validate(): bool
|
||||
@@ -54,6 +60,12 @@ final class ConfigResolver
|
||||
$valid = false;
|
||||
}
|
||||
|
||||
if (null !== $this->notificationTransport) {
|
||||
if (null === $this->notificationNtfyDsn || "" === $this->notificationNtfyDsn) {
|
||||
$this->messages[] = "Your NOTIFICATION_TRANSPORT is set to 'ntfy' but you don't have the NTFY_DSN environment variable set.";
|
||||
}
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Base\Framework\Controller;
|
||||
|
||||
use App\Monitor\Action\Command\MonitorTvShowCommand;
|
||||
use App\Monitor\Action\Handler\MonitorTvShowHandler;
|
||||
use App\Tmdb\Tmdb;
|
||||
use App\User\Framework\Entity\User;
|
||||
@@ -48,4 +49,10 @@ final class IndexController extends AbstractController
|
||||
'message' => 'Email sent!'
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/test')]
|
||||
public function monitorTvShow(): Response
|
||||
{
|
||||
return $this->render('index/test.html.twig', []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,53 @@
|
||||
|
||||
namespace App\Base\Service;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Mercure\HubInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
use Symfony\Component\Notifier\Notification\Notification;
|
||||
use Symfony\Component\Notifier\NotifierInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
readonly class Broadcaster
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(param: 'notification.transport')]
|
||||
private string $notificationTransport,
|
||||
#[Autowire(service: 'twig')]
|
||||
private Environment $renderer,
|
||||
private HubInterface $hub,
|
||||
private RequestStack $requestStack,
|
||||
private NotifierInterface $notifier,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function alert(string $title, string $message, string $type = "success"): void
|
||||
public function alert(string $title, string $message, string $type = "success", bool $sendPush = false): void
|
||||
{
|
||||
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
|
||||
$update = new Update(
|
||||
$userAlertTopic,
|
||||
$this->renderer->render('broadcast/Alert.stream.html.twig', [
|
||||
'alert_id' => uniqid(),
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'type' => $type,
|
||||
])
|
||||
);
|
||||
$this->hub->publish($update);
|
||||
try {
|
||||
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
|
||||
$update = new Update(
|
||||
$userAlertTopic,
|
||||
$this->renderer->render('broadcast/Alert.stream.html.twig', [
|
||||
'alert_id' => uniqid(),
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'type' => $type,
|
||||
])
|
||||
);
|
||||
$this->hub->publish($update);
|
||||
} catch (\Throwable $exception) {
|
||||
// ToDo: look for better handling to get message to end user
|
||||
}
|
||||
|
||||
if (true === $sendPush && in_array($this->notificationTransport, ['ntfy'])) {
|
||||
try {
|
||||
$notification = new Notification($title, ['push'])->content($message);
|
||||
$this->notifier->send($notification);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger->error('Unable to send push notification: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Base\Service;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use Nihilarr\PTN;
|
||||
use App\Base\Util\PTN;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
246
src/Base/Util/PTN.php
Normal file
246
src/Base/Util/PTN.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
/**
|
||||
* Parse Torrent Name (PTN)
|
||||
*
|
||||
* PHP port of parse-torrent-name written in Python.
|
||||
*
|
||||
* Javascript version by jzjzjzj
|
||||
* https://github.com/jzjzjzj/parse-torrent-name
|
||||
*
|
||||
* Python version by divijbindlish
|
||||
* https://github.com/divijbindlish/parse-torrent-name
|
||||
*
|
||||
* Copyright (c) 2014 - 2018, British Columbia Institute of Technology
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* @package PTN
|
||||
* @author Drew Smith
|
||||
* @copyright copyright (c) 2018, Nihilarr (https://www.nihilarr.com)
|
||||
* @license http://opensource.org/licenses/MIT MIT License
|
||||
* @link https://gitlab.com/nihilarr/parse-torrent-name
|
||||
* @version 0.0.1
|
||||
*/
|
||||
|
||||
namespace App\Base\Util;
|
||||
|
||||
class PTN {
|
||||
|
||||
public $torrent;
|
||||
public $excess_raw;
|
||||
public $group_raw;
|
||||
public $start;
|
||||
public $end;
|
||||
public $title_raw;
|
||||
public $parts;
|
||||
|
||||
public $patterns = array(
|
||||
array('season' => '(s?([0-9]{1,3}))[ex]'),
|
||||
array('episode' => '([ex]([0-9]{1,3})(?:[^0-9]|$))'),
|
||||
array('year' => '([\[\(]?((?:19[0-9]|20[01])[0-9])[\]\)]?)'),
|
||||
array('resolution' => '([0-9]{3,4}p)'),
|
||||
array('quality' => '((?:PPV\.)?[HP]DTV|(?:HD)?CAM|B[DR]Rip|(?:HD-?)?TS|(?:PPV )?WEB-?DL(?: DVDRip)?|HDRip|DVDRip|DVDRIP|CamRip|W[EB]BRip|BluRay|DvDScr|hdtv|telesync)'),
|
||||
array('codec' => '(xvid|[hx]\.?26[45])'),
|
||||
array('audio' => '(MP3|DD5\.?1|Dual[\- ]Audio|LiNE|DTS|AAC[.-]LC|AAC(?:\.?2\.0)?|AC3(?:\.5\.1)?)'),
|
||||
array('group' => '(- ?([^-]+(?:-={[^-]+-?$)?))$'),
|
||||
array('region' => 'R[0-9]'),
|
||||
array('extended' => '(EXTENDED(:?.CUT)?)'),
|
||||
array('hardcoded' => 'HC'),
|
||||
array('proper' => 'PROPER'),
|
||||
array('repack' => 'REPACK'),
|
||||
array('container' => '(MKV|AVI|MP4)'),
|
||||
array('widescreen' => 'WS'),
|
||||
array('website' => '^(\[ ?([^\]]+?) ?\])'),
|
||||
array('language' => '(rus\.eng|ita\.eng)'),
|
||||
array('sbs' => '(?:Half-)?SBS'),
|
||||
array('unrated' => 'UNRATED'),
|
||||
array('size' => '(\d+(?:\.\d+)?(?:GB|MB))'),
|
||||
array('3d' => '3D')
|
||||
);
|
||||
|
||||
public $types = array(
|
||||
'season' => 'integer',
|
||||
'episode' => 'integer',
|
||||
'year' => 'integer',
|
||||
'extended' => 'boolean',
|
||||
'hardcoded' => 'boolean',
|
||||
'proper' => 'boolean',
|
||||
'repack' => 'boolean',
|
||||
'widescreen' => 'boolean',
|
||||
'unrated' => 'boolean',
|
||||
'3d' => 'boolean'
|
||||
);
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function parse($name) {
|
||||
$this->parts = array();
|
||||
$this->torrent = array('name' => $name);
|
||||
$this->excess_raw = $name;
|
||||
$this->group_raw = '';
|
||||
$this->start = 0;
|
||||
$this->end = null;
|
||||
$this->title_raw = null;
|
||||
|
||||
foreach($this->patterns as $patterns_single) {
|
||||
foreach($patterns_single as $key => $pattern) {
|
||||
if(!in_array($key, array('season', 'episode', 'website'))) {
|
||||
$pattern = "\b{$pattern}\b";
|
||||
}
|
||||
|
||||
$clean_name = str_replace('_', ' ', $this->torrent['name']);
|
||||
if(preg_match("/{$pattern}/i", $clean_name, $match) == 0) break;
|
||||
|
||||
$index = array();
|
||||
if(is_array($match)) {
|
||||
array_shift($match);
|
||||
}
|
||||
if(sizeof($match) == 0) break;
|
||||
if(sizeof($match) > 1) {
|
||||
$index['raw'] = 0;
|
||||
$index['clean'] = 1;
|
||||
}
|
||||
else {
|
||||
$index['raw'] = 0;
|
||||
$index['clean'] = 0;
|
||||
}
|
||||
|
||||
if(isset($this->types[$key]) && $this->types[$key] == 'boolean') {
|
||||
$clean = true;
|
||||
}
|
||||
else {
|
||||
$clean = $match[$index['clean']];
|
||||
if(isset($this->types[$key]) && $this->types[$key] == 'integer') {
|
||||
$clean = (int)$clean;
|
||||
}
|
||||
}
|
||||
|
||||
if($key == 'group') {
|
||||
if((isset($this->patterns[5][1]) && preg_match_all("/{$this->patterns[5][1]}/i", $clean)) ||
|
||||
(isset($this->patterns[4][1]) && preg_match_all("/{$this->patterns[4][1]}/", $clean))) {
|
||||
break;
|
||||
}
|
||||
if(preg_match('/[^ ]+ [^ ]+ .+/', $clean)) {
|
||||
$key = 'episodeName';
|
||||
}
|
||||
}
|
||||
if($key == 'episode') {
|
||||
$sub_pattern = $this->escape_regex($match[$index['raw']]);
|
||||
$this->torrent['map'] = preg_replace("/{$sub_pattern}/", '{episode}', $this->torrent['name']);
|
||||
}
|
||||
|
||||
$this->part($key, $match, $match[$index['raw']], $clean);
|
||||
}
|
||||
}
|
||||
|
||||
$raw = $this->torrent['name'];
|
||||
if(!is_null($this->end)) {
|
||||
$raw = explode('(', substr($raw, $this->start, $this->end - $this->start));
|
||||
$raw = $raw[0];
|
||||
}
|
||||
|
||||
$clean = preg_replace("/^ -/", '', $raw);
|
||||
if(strpos($clean, ' ') === false && strpos($clean, '.') !== false) {
|
||||
$clean = str_replace('.', ' ', $clean);
|
||||
}
|
||||
$clean = str_replace('_', ' ', $clean);
|
||||
$clean = trim(preg_replace("/([\[\(_]|- )$/", '', $clean));
|
||||
|
||||
$this->part('title', array(), $raw, $clean);
|
||||
|
||||
$clean = preg_replace("/(^[-\. ()]+)|([-\. ]+$)/", '', $this->excess_raw);
|
||||
$clean = preg_replace("/[\(\)\/]/", ' ', $clean);
|
||||
$match = preg_split("/\.\.+| +/", $clean);
|
||||
if(sizeof($match) > 0 && is_array($match[0])) {
|
||||
$match = $match[0];
|
||||
}
|
||||
|
||||
$clean = $match;
|
||||
$clean = array_filter($clean, function($var) {
|
||||
return $var != '-' ? true : false;
|
||||
});
|
||||
$clean = array_filter($clean, function($var) {
|
||||
return trim($var, '-');
|
||||
});
|
||||
$clean = array_values($clean);
|
||||
|
||||
if(sizeof($clean) > 0) {
|
||||
$group_pattern = $clean[sizeof($clean) - 1] . $this->group_raw;
|
||||
if(strpos($this->torrent['name'], $group_pattern) == strlen($this->torrent['name']) - strlen($group_pattern)) {
|
||||
$this->late('group', array_pop($clean) . $this->group_raw);
|
||||
}
|
||||
|
||||
if(isset($this->torrent['map']) && sizeof($clean) > 0) {
|
||||
$episode_name_pattern = '{episode}' . preg_replace("/_+$/", '', $clean[0]);
|
||||
|
||||
if(strpos($this->torrent['map'], $episode_name_pattern) != -1) {
|
||||
$this->late('episodeName', array_shift($clean));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(sizeof($clean) != 0) {
|
||||
if(sizeof($clean) == 1) {
|
||||
$clean = $clean[0];
|
||||
}
|
||||
$this->part('excess', array(), $this->excess_raw, $clean);
|
||||
}
|
||||
return $this->parts;
|
||||
}
|
||||
|
||||
private function escape_regex($subject) {
|
||||
return preg_replace("/[\-\[\]{}()*+?.,\\\^$|#\s]/", "\\\\$&", $subject);
|
||||
}
|
||||
|
||||
private function part($name, $match, $raw, $clean) {
|
||||
# The main core instructuions
|
||||
$this->parts[$name] = $clean;
|
||||
|
||||
if(sizeof($match) > 0) {
|
||||
# The instructions for extracting title
|
||||
$index = strpos($this->torrent['name'], $match[0]);
|
||||
if($index == 0) {
|
||||
$this->start = strlen($match[0]);
|
||||
}
|
||||
elseif(is_null($this->end) || $index < $this->end) {
|
||||
$this->end = $index;
|
||||
}
|
||||
}
|
||||
if($name != 'excess') {
|
||||
if($name == 'group') {
|
||||
$this->group_raw = $raw;
|
||||
}
|
||||
|
||||
if(!is_null($raw)) {
|
||||
$this->excess_raw = str_replace($raw, '', $this->excess_raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function late($name, $clean) {
|
||||
if($name == 'group') {
|
||||
$this->part($name, array(), null, $clean);
|
||||
}
|
||||
elseif($name == 'episodeName') {
|
||||
$clean = preg_replace("/[\._]/", ' ', $clean);
|
||||
$clean = preg_replace("/_+$/", '', $clean);
|
||||
$this->part($name, array(), null, trim($clean));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||
use App\User\Dto\UserPreferencesFactory;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use Nihilarr\PTN;
|
||||
use App\Base\Util\PTN;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Download\Downloader;
|
||||
|
||||
use App\Base\Service\Broadcaster;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -19,6 +20,7 @@ class ProcessDownloader implements DownloaderInterface
|
||||
private EntityManagerInterface $entityManager,
|
||||
private MediaFiles $mediaFiles,
|
||||
private CacheInterface $cache,
|
||||
private readonly Broadcaster $broadcaster,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -82,6 +84,7 @@ class ProcessDownloader implements DownloaderInterface
|
||||
});
|
||||
if ($downloadEntity->getStatus() !== 'Paused') {
|
||||
$downloadEntity->setProgress(100);
|
||||
$this->alertComplete($downloadEntity);
|
||||
}
|
||||
} catch (ProcessFailedException $exception) {
|
||||
$downloadEntity->setStatus('Failed');
|
||||
@@ -105,4 +108,15 @@ class ProcessDownloader implements DownloaderInterface
|
||||
|
||||
throw new \Exception("There is no download path for media type: $mediaType");
|
||||
}
|
||||
|
||||
private function alertComplete(Download $download): void
|
||||
{
|
||||
if ("tvshows" === $download->getMediaType()) {
|
||||
$message = '"' . $download->getTitle() . '" - ' . $download->getEpisodeId() . ' has finished downloading.';
|
||||
} else {
|
||||
$message = '"' . $download->getTitle() . '" has finished downloading.';
|
||||
}
|
||||
|
||||
$this->broadcaster->alert('Success', $message, sendPush: true);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\User\Framework\Entity\User;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Timestampable\Traits\TimestampableEntity;
|
||||
use Nihilarr\PTN;
|
||||
use App\Base\Util\PTN;
|
||||
use Symfony\Component\Serializer\Attribute\Ignore;
|
||||
use Symfony\UX\Turbo\Attribute\Broadcast;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Download\Framework\Entity\Download;
|
||||
use App\User\Framework\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Nihilarr\PTN;
|
||||
use App\Base\Util\PTN;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Base\Service\MediaFiles;
|
||||
use App\Library\Action\Command\LibrarySearchCommand;
|
||||
use App\Library\Action\Result\LibrarySearchResult;
|
||||
use App\Library\Dto\MediaFileDto;
|
||||
use Nihilarr\PTN;
|
||||
use App\Base\Util\PTN;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Monitor\Action\Handler;
|
||||
use App\Base\Util\EpisodeId;
|
||||
use App\Download\Action\Command\DownloadMediaCommand;
|
||||
use App\Download\DownloadOptionEvaluator;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\Monitor\Action\Command\MonitorMovieCommand;
|
||||
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
|
||||
@@ -44,6 +43,11 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
|
||||
|
||||
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
|
||||
|
||||
if (null === $monitor->getAirDate() && null !== $episodeData->episodeAirDate && "" !== $episodeData->episodeAirDate) {
|
||||
$monitor->setAirDate(Carbon::parse($episodeData->episodeAirDate));
|
||||
}
|
||||
|
||||
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) {
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now');
|
||||
return new MonitorTvEpisodeResult(
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Tmdb\Tmdb;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Nihilarr\PTN;
|
||||
use App\Base\Util\PTN;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
@@ -11,9 +11,10 @@ use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Tmdb\Tmdb;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Nihilarr\PTN;
|
||||
use App\Base\Util\PTN;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
@@ -47,6 +48,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
||||
&& null !== $episode->season
|
||||
)
|
||||
;
|
||||
|
||||
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
|
||||
|
||||
// Compare against list from TMDB
|
||||
@@ -95,6 +97,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
||||
->setMonitorType('tvepisode')
|
||||
->setSeason($episode['season_number'])
|
||||
->setEpisode($episode['episode_number'])
|
||||
->setAirDate($episode['air_date'] !== null && $episode['air_date'] !== "" ? Carbon::parse($episode['air_date']) : null)
|
||||
->setCreatedAt(new DateTimeImmutable())
|
||||
->setSearchCount(0)
|
||||
->setStatus('New');
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Monitor\Framework\Controller;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Base\Service\Broadcaster;
|
||||
use App\Monitor\Action\Handler\AddMonitorHandler;
|
||||
use App\Monitor\Action\Handler\DeleteMonitorHandler;
|
||||
use App\Monitor\Action\Input\AddMonitorInput;
|
||||
use App\Monitor\Action\Input\DeleteMonitorInput;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Monitor\Framework\Scheduler\MonitorDispatcher;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -65,4 +67,45 @@ class ApiController extends AbstractController
|
||||
'message' => 'Manually dispatched MonitorDispatcher'
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/monitor/upcoming-episodes', name: 'api.monitor.upcoming-episodes', methods: ['GET'])]
|
||||
public function upcomingEpisodes(MonitorRepository $repository): Response
|
||||
{
|
||||
$colors = [
|
||||
'blue' => '#007bff',
|
||||
'indigo' => '#6610f2',
|
||||
'purple' => '#6f42c1',
|
||||
'pink' => '#e83e8c',
|
||||
'red' => '#dc3545',
|
||||
'orange' => '#fd7e14',
|
||||
'yellow' => '#ffc107',
|
||||
'green' => '#28a745',
|
||||
'teal' => '#20c997',
|
||||
'cyan' => '#17a2b8',
|
||||
];
|
||||
|
||||
$eventColors = [];
|
||||
$monitors = $repository->whereAirDateNotNull();
|
||||
$monitors = Map::from($monitors)->map(function ($monitor) use (&$eventColors, $colors) {
|
||||
if (!array_key_exists($monitor->getImdbId(), $eventColors)) {
|
||||
$eventColors[$monitor->getImdbId()] = $colors[array_rand($colors)];
|
||||
}
|
||||
return [
|
||||
'id' => $monitor->getId(),
|
||||
'title' => $monitor->getTitle() . ' (S' . str_pad($monitor->getSeason(), 2, '0', STR_PAD_LEFT) . 'E' . str_pad($monitor->getEpisode(), 2, '0', STR_PAD_LEFT) . ')',
|
||||
'start' => $monitor->getAirDate()->format('Y-m-d H:i:s'),
|
||||
'groupId' => $monitor->getImdbId(),
|
||||
'allDay' => true,
|
||||
'backgroundColor' => $eventColors[$monitor->getImdbId()],
|
||||
'borderColor' => $eventColors[$monitor->getImdbId()],
|
||||
];
|
||||
});
|
||||
|
||||
return $this->json([
|
||||
'status' => 200,
|
||||
'data' => [
|
||||
'episodes' => $monitors->toArray(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,10 @@ class WebController extends AbstractController
|
||||
{
|
||||
return $this->render('monitor/index.html.twig');
|
||||
}
|
||||
|
||||
#[Route('/monitors/upcoming-episodes', name: 'app.monitor.upcoming-episodes', methods: ['GET'])]
|
||||
public function upcomingEpisodes()
|
||||
{
|
||||
return $this->render('monitor/upcoming-episodes.html.twig');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ class Monitor
|
||||
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTimeInterface $lastSearch = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTime $airDate = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
@@ -257,6 +260,17 @@ class Monitor
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAirDate(): ?\DateTimeInterface
|
||||
{
|
||||
return $this->airDate;
|
||||
}
|
||||
|
||||
public function setAirDate(?\DateTimeInterface $airDate): static
|
||||
{
|
||||
$this->airDate = $airDate;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeChild(self $child): static
|
||||
{
|
||||
if ($this->children->removeElement($child)) {
|
||||
|
||||
@@ -33,4 +33,12 @@ class MonitorRepository extends ServiceEntityRepository
|
||||
|
||||
return $this->paginator->paginate($query, $page, $perPage);
|
||||
}
|
||||
|
||||
public function whereAirDateNotNull()
|
||||
{
|
||||
$query = $this->createQueryBuilder('m')
|
||||
->andWhere('m.airDate IS NOT NULL')
|
||||
->getQuery();
|
||||
return $query->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,6 +29,13 @@ 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,
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
@@ -264,13 +286,18 @@ class Tmdb
|
||||
|
||||
private function parseTvShow(array $data, string $posterBasePath): TmdbResult
|
||||
{
|
||||
if (!in_array($data['first_air_date'], ['', null,])) {
|
||||
$airDate = (new \DateTime($data['first_air_date']))->format('Y-m-d');
|
||||
} else {
|
||||
$airDate = null;
|
||||
}
|
||||
return new TmdbResult(
|
||||
imdbId: $data['external_ids']['imdb_id'],
|
||||
tmdbId: $data['id'],
|
||||
title: $data['name'],
|
||||
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
|
||||
description: $data['overview'],
|
||||
year: (new \DateTime($data['first_air_date']))->format('Y'),
|
||||
year: $airDate,
|
||||
mediaType: "tvshows",
|
||||
episodes: $data['episodes'],
|
||||
);
|
||||
@@ -278,6 +305,11 @@ class Tmdb
|
||||
|
||||
private function parseEpisode(array $data, string $posterBasePath): TmdbResult
|
||||
{
|
||||
if (!in_array($data['air_date'], ['', null,])) {
|
||||
$airDate = (new \DateTime($data['air_date']))->format('Y-m-d');
|
||||
} else {
|
||||
$airDate = null;
|
||||
}
|
||||
return new TmdbResult(
|
||||
imdbId: $data['external_ids']['imdb_id'],
|
||||
tmdbId: $data['id'],
|
||||
@@ -287,12 +319,17 @@ class Tmdb
|
||||
year: (new \DateTime($data['air_date']))->format('Y'),
|
||||
mediaType: "tvshows",
|
||||
episodes: null,
|
||||
episodeAirDate: (new \DateTime($data['air_date']))->format('m/d/Y'),
|
||||
episodeAirDate: $airDate,
|
||||
);
|
||||
}
|
||||
|
||||
private function parseMovie(array $data, string $posterBasePath): TmdbResult
|
||||
{
|
||||
if (!in_array($data['release_date'], ['', null,])) {
|
||||
$airDate = (new \DateTime($data['release_date']))->format('Y-m-d');
|
||||
} else {
|
||||
$airDate = null;
|
||||
}
|
||||
return new TmdbResult(
|
||||
imdbId: $data['external_ids']['imdb_id'],
|
||||
tmdbId: $data['id'],
|
||||
@@ -301,7 +338,7 @@ class Tmdb
|
||||
description: $data['overview'],
|
||||
year: (new \DateTime($data['release_date']))->format('Y'),
|
||||
mediaType: "movies",
|
||||
episodeAirDate: (new \DateTime($data['release_date']))->format('m/d/Y'),
|
||||
episodeAirDate: $airDate,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class GetTvShowOptionsHandler implements HandlerInterface
|
||||
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
|
||||
|
||||
return new GetTvShowOptionsResult(
|
||||
parentShow: $parentShow,
|
||||
media: $media,
|
||||
file: MediaFileDto::fromSplFileInfo($file),
|
||||
season: $command->season,
|
||||
|
||||
@@ -9,6 +9,7 @@ use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
class GetTvShowOptionsResult implements ResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public TmdbResult $parentShow,
|
||||
public TmdbResult $media,
|
||||
public MediaFileDto|false $file,
|
||||
public string $season,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Torrentio\Result;
|
||||
|
||||
use App\User\Database\CountryLanguages;
|
||||
use Nihilarr\PTN;
|
||||
use App\Base\Util\PTN;
|
||||
|
||||
class ResultFactory
|
||||
{
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class UtilExtension
|
||||
#[AsTwigFunction('episode_anchor')]
|
||||
public function episodeAnchor($season, $episode): ?string
|
||||
{
|
||||
return "episode_" . $season . "_" . $episode;
|
||||
return "episode_" . (int) $season . "_" . (int) $episode;
|
||||
}
|
||||
|
||||
#[AsTwigFunction('extract_from_episode_id')]
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\User\Database;
|
||||
class CodecList
|
||||
{
|
||||
public static $codecs = [
|
||||
'-',
|
||||
'h264',
|
||||
'h265/HEVC',
|
||||
];
|
||||
@@ -20,6 +21,9 @@ class CodecList
|
||||
foreach (static::$codecs as $codec) {
|
||||
$result[$codec] = $codec;
|
||||
}
|
||||
|
||||
$result['h265/HEVC'] = 'h265';
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class ProviderList
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
$result = [];
|
||||
$result = ['-' => '-'];
|
||||
foreach (static::$providers as $provider) {
|
||||
$result[$provider] = $provider;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class QualityList
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
$result = ['n/a' => null];
|
||||
$result = ['n/a' => null, '-' => '-'];
|
||||
foreach (array_keys(static::$qualities) as $quality) {
|
||||
$result[$quality] = $quality;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ class ResolutionList
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
$result = [];
|
||||
$result = ['-' => '-'];
|
||||
foreach (static::$resolutions as $resolution) {
|
||||
$result[$resolution] = $resolution;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ class PreferencesController extends AbstractController
|
||||
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
|
||||
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
|
||||
|
||||
// dd($form);
|
||||
|
||||
return $this->render(
|
||||
'user/preferences.html.twig',
|
||||
[
|
||||
@@ -52,8 +54,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;
|
||||
}
|
||||
}
|
||||
|
||||
36
symfony.lock
36
symfony.lock
@@ -86,6 +86,21 @@
|
||||
"phpstan.dist.neon"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "12.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "11.1",
|
||||
"ref": "c6658a60fc9d594805370eacdf542c3d6b5c0869"
|
||||
},
|
||||
"files": [
|
||||
".env.test",
|
||||
"phpunit.dist.xml",
|
||||
"tests/bootstrap.php",
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"spomky-labs/pwa-bundle": {
|
||||
"version": "1.2.5"
|
||||
},
|
||||
@@ -217,6 +232,27 @@
|
||||
"config/packages/messenger.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/notifier": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.0",
|
||||
"ref": "178877daf79d2dbd62129dd03612cb1a2cb407cc"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/notifier.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/ntfy-notifier": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "0d5e496659d7361bb4e6648eb8332f8cf097533d"
|
||||
}
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
|
||||
@@ -20,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",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
{% block javascripts %}
|
||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="flex flex-col bg-stone-700">
|
||||
@@ -30,5 +31,6 @@
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<twig:PreviewModal id="previewModal" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,14 +14,16 @@
|
||||
{% 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>
|
||||
{% if entity.progress >= 50 %}
|
||||
{% set text_color = "text-black dark:text-black" %}
|
||||
{% else %}
|
||||
{% set text_color = "text-black dark:text-white" %}
|
||||
{% endif %}
|
||||
<div class="background {{ text_color }} text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||
style="width: {{ entity.progress }}%">
|
||||
</div>
|
||||
<div class="number {{ text_color }} font-bold text-center z-40"
|
||||
>{{ entity.progress }}%</div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
<turbo-stream action="update" target="action_buttons_{{ id }}">
|
||||
@@ -53,7 +55,11 @@
|
||||
|
||||
<turbo-stream action="prepend" target="alert_list">
|
||||
<template>
|
||||
<twig:Alert title="Finished downloading" message="{{ entity.title }}" alert_id="{{ entity.id }}" data-controller="alert" />
|
||||
{% if entity.mediaType == "tvshows" %}
|
||||
<twig:Alert title="Success" message="{{ entity.title }} - ({{ entity.episodeId }}) has finished downloading." alert_id="{{ entity.id }}" data-controller="alert" />
|
||||
{% else %}
|
||||
<twig:Alert title="Success" message="{{ entity.title }} has finished downloading." alert_id="{{ entity.id }}" data-controller="alert" />
|
||||
{% endif %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
<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">
|
||||
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Info</span>
|
||||
|
||||
<h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3>
|
||||
|
||||
<twig:ux:icon name="ic:twotone-cancel" style="text-align:right" width="16.75px" height="16.75px" class="modal-close rounded-full align-end text-red-600 hover:text-red-700" />
|
||||
|
||||
<span class="sr-only">Info</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm w-[300px] font-bold overflow-hidden text-wrap">
|
||||
{{ message }}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<div{{ attributes }}>
|
||||
<div class="flex flex-col bg-sky-950 border-neutral-700 border-t-4 border-t-orange-500 rounded-xl
|
||||
backdrop-filter backdrop-blur-md bg-opacity-40 z-10
|
||||
">
|
||||
<div class="flex flex-col bg-sky-950/40 border-neutral-700 border-t-4 border-t-orange-500 rounded-xl">
|
||||
<div class="p-4 md:p-5">
|
||||
<h3 class="mb-4 text-lg font-bold text-white">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<div class="{{ contentClass|default('flex flex-col overflow-hidden rounded-md') }}">
|
||||
<div class="{{ contentClass|default('flex flex-col rounded-md') }}">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,45 +7,45 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
|
||||
<table id="downloads" class="divide-y divide-gray-200 dark:divide-gray-800 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
|
||||
<thead>
|
||||
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
|
||||
<tr class="bg-orange-500/80 text-gray-800 dark:text-stone-800 text-xs font-medium uppercase">
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate">
|
||||
class="px-6 py-3 truncate text-start">
|
||||
Title
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
|
||||
class="px-6 py-3 truncate text-start {{ isWidget == true ? "hidden" : "r-tablecell" }}">
|
||||
Filename
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
|
||||
class="px-6 py-3 truncate text-start {{ isWidget == true ? "hidden" : "r-tablecell" }}">
|
||||
Media type
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
|
||||
class="px-6 py-3 text-start">
|
||||
Progress
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
|
||||
class="px-6 py-3 text-start">
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50">
|
||||
<tbody id="{{ table_body_id }}" class="dark:text-white divide-y divide-gray-200 dark:divide-gray-900" data-download-list-target="download">
|
||||
{% if this.downloads.items|length > 0 %}
|
||||
{% for download in this.downloads.items %}
|
||||
<twig:DownloadListRow download="{{ download }}" isWidget="{{ isWidget }}" />
|
||||
{% endfor %}
|
||||
{% if this.isWidget == true and this.downloads.items|length > this.perPage %}
|
||||
<tr id="download_view_all">
|
||||
<td class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black truncate" colspan="100%">
|
||||
<td class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black" colspan="100%">
|
||||
<a href="{{ path('app_downloads') }}">View All Downloads</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr id="{{ table_body_id }}_no_downloads">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium text-gray-800 dark:text-stone-800" colspan="100%">
|
||||
<tr id="{{ table_body_id }}_no_downloads" class="text-black dark:text-white dark:bg-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium" colspan="100%">
|
||||
No downloads
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<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">
|
||||
<tr{{ attributes }} is="download-list-row" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900" id="ad_download_{{ download.id }}" data-title="{{ download.title }}"
|
||||
download-id="{{ download.id }}"
|
||||
imdb-id="{{ download.imdbId }}"
|
||||
media-title="{{ download.title }}"
|
||||
url="{{ download.url }}"
|
||||
filename="{{ download.filename }}"
|
||||
status="{{ download.status }}"
|
||||
progress="{{ download.progress }}"
|
||||
media-type="{{ download.mediaType }}"
|
||||
episode-id="{{ download.episodeId }}"
|
||||
created-at="{{ download.createdAt|date('m/d/Y g:i a') }}"
|
||||
updated-at="{{ download.updatedAt|date('m/d/Y g:i a') }}"
|
||||
data-filename="{{ download.filename }}" data-media-type="{{ download.mediaType }}" data-status="{{ download.status }}" data-progress="{{ download.progress }}"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium truncate">
|
||||
{% if download.mediaType == "movies" %}
|
||||
{% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType} %}
|
||||
{% set route = path('app_search_result', routeParams) %}
|
||||
@@ -18,23 +31,24 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] {{ isWidget == true ? "hidden" : "r-tablecell" }} truncate">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium max-w-[60ch] {{ isWidget == true ? "hidden" : "r-tablecell" }} truncate">
|
||||
{{ download.filename }}
|
||||
</td>
|
||||
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
|
||||
{{ download.mediaType }}
|
||||
</td>
|
||||
|
||||
<td class="whitespace-nowrap gap-2 text-sm align-middle text-gray-800 dark:text-gray-50">
|
||||
{% if download.progress < 100 %}
|
||||
<div class="flex flex-row items-center justify-center">
|
||||
<div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-16 h-6 align-middle overflow-hidden">
|
||||
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||
style="width:{{ download.progress }}%">
|
||||
<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 dark:text-white font-bold text-center z-40"
|
||||
>{{ download.progress }}%</div>
|
||||
</div>
|
||||
<div class="text-black font-bold text-center z-40 ml-[-42px]">{{ download.progress }}%</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
@@ -4,110 +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 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 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>
|
||||
@@ -6,11 +6,16 @@
|
||||
<div class="md:flex md:items-center md:gap-12">
|
||||
<nav aria-label="Global" class="md:block">
|
||||
<ul class="flex items-center gap-6 text-sm">
|
||||
<li>
|
||||
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false" title="View upcoming episodes of the shows you're subscribed to.">
|
||||
<twig:ux:icon name="solar:calendar-linear" width="25px" class="text-orange-500" />
|
||||
</a>
|
||||
</li>
|
||||
<li class="hidden">
|
||||
<twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/>
|
||||
</li>
|
||||
<li class="hidden md:block">
|
||||
<a href="{{ path('app_logout') }}">
|
||||
<a href="{{ path('app_logout') }}" title="Log out of Torsearch.">
|
||||
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" />
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -4,55 +4,57 @@
|
||||
<twig:DownloadSearch search_path="app_search" placeholder="Find {{ type == "complete" ? "a" : "an" }} {{ type }} monitor..." />
|
||||
</div>
|
||||
{% endif %}
|
||||
<table id="monitor_list" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Monitor\\Framework\\Entity\\Monitor') }}>
|
||||
<table id="monitor_list" class="divide-y divide-gray-200 dark:divide-gray-800 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Monitor\\Framework\\Entity\\Monitor') }}
|
||||
{{ stimulus_target('monitor_list', 'monitorList') }}
|
||||
>
|
||||
<thead>
|
||||
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
|
||||
<tr class="bg-orange-500/80 text-gray-800 dark:text-stone-800 text-xs font-medium uppercase">
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase truncate">
|
||||
class="px-6 py-3 text-start truncate">
|
||||
Title
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase">
|
||||
class="px-6 py-3 text-start">
|
||||
ID
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
|
||||
class="hidden md:table-cell px-6 py-3 text-start">
|
||||
Search Count
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
|
||||
class="hidden md:table-cell px-6 py-3 text-start">
|
||||
Created at
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
|
||||
class="hidden md:table-cell px-6 py-3 text-start">
|
||||
Last Search Date
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
|
||||
class="hidden md:table-cell px-6 py-3 text-start">
|
||||
Type
|
||||
</th>
|
||||
<th scope="col"
|
||||
class="px-6 py-3 text-start text-xs font-medium uppercase">
|
||||
class="px-6 py-3 text-start">
|
||||
Status
|
||||
</th>
|
||||
<th class="hidden md:table-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="monitors" class="divide-y divide-gray-50">
|
||||
<tbody id="monitors" class="dark:text-white divide-y divide-gray-200 dark:divide-gray-900">
|
||||
{% if this.monitors.items|length > 0 %}
|
||||
{% for monitor in this.monitors.items %}
|
||||
<twig:MonitorListRow :monitor="monitor" isWidget="{{ this.isWidget }}" />
|
||||
{% endfor %}
|
||||
{% if this.isWidget and this.monitors.items|length > 5 %}
|
||||
<tr id="monitor_view_all">
|
||||
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black min-w-[50ch] max-w-[50ch] truncate">
|
||||
<td colspan="100%" class="py-2 whitespace-nowrap bg-gray-400 dark:bg-gray-700 uppercase text-xs font-medium text-center text-black dark:text-white min-w-[50ch] max-w-[50ch] truncate">
|
||||
<a href="{{ path('app_monitors') }}">View All Monitors</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<tr id="active_monitors_no_monitors">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-stone-800" colspan="100%">
|
||||
<td class="px-6 py-4 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900 text-center text-xs uppercase font-medium" colspan="100%">
|
||||
No monitors
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
<tr{{ attributes }} id="monitor_{{ monitor.id }}" class="hover:bg-gray-200">
|
||||
<tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900"
|
||||
monitor-id="{{ monitor.id }}"
|
||||
imdb-id="{{ monitor.imdbId }}"
|
||||
media-title="{{ monitor.title }}"
|
||||
season="{{ monitor.season }}"
|
||||
episode="{{ monitor.episode }}"
|
||||
status="{{ monitor.status }}"
|
||||
search-count="{{ monitor.searchCount }}"
|
||||
media-type="{{ monitor.monitorType|monitor_type }}"
|
||||
episode-id="{{ monitor|monitor_media_id }}"
|
||||
created-at="{{ monitor.createdAt|date('m/d/Y g:i a') }}"
|
||||
last-search="{{ monitor.lastSearch|date('m/d/Y g:i a') }}"
|
||||
downloaded-at="{{ monitor.downloadedAt|date('m/d/Y g:i a') }}"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 truncate">
|
||||
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
|
||||
class="mr-1 hover:underline rounded-md"
|
||||
@@ -13,24 +26,24 @@
|
||||
{% 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">
|
||||
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate dark:text-white">
|
||||
{{ monitor.title }}
|
||||
</a>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor|monitor_media_id }}
|
||||
</td>
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor.searchCount }}
|
||||
</td>
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor.createdAt|date('m/d/Y h:i a') }}
|
||||
</td>
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor.lastSearch|date('m/d/Y h:i a') }}
|
||||
</td>
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if monitor.monitorType == "tvshow" %}
|
||||
<twig:StatusBadge color="blue" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
|
||||
{% elseif monitor.monitorType == "tvseason" %}
|
||||
@@ -39,7 +52,7 @@
|
||||
<twig:StatusBadge color="fuchsia" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if monitor.status == "New" %}
|
||||
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
|
||||
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}
|
||||
@@ -59,11 +72,11 @@
|
||||
{{ monitor.title }}
|
||||
</td>
|
||||
{% if monitor|monitor_media_id != "-" %}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor|monitor_media_id }}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if monitor.monitorType == "tvshow" %}
|
||||
<twig:StatusBadge color="blue" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
|
||||
{% elseif monitor.monitorType == "tvseason" %}
|
||||
@@ -72,7 +85,7 @@
|
||||
<twig:StatusBadge color="fuchsia" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if monitor.status == "New" %}
|
||||
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
|
||||
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}
|
||||
|
||||
10
templates/components/PreviewModal.html.twig
Normal file
10
templates/components/PreviewModal.html.twig
Normal file
@@ -0,0 +1,10 @@
|
||||
<dialog{{ attributes }} is="preview-content-dialog" class="py-3 px-4 w-full md:w-[50rem] rounded-md dark:bg-gray-950/80 dark:border-2 dark:border-orange-500 dark:text-white backdrop-filter backdrop-blur-3xl">
|
||||
<div class="flex flex-row justify-end">
|
||||
<twig:ux:icon name="ic:twotone-cancel" width="16.75px" height="16.75px" class="modal-close rounded-full align-middle text-red-600 hover:text-red-700" />
|
||||
</div>
|
||||
<h2 class="modal-heading mb-4 text-2xl font-bold text-orange-500">{{ heading|default('') }}</h2>
|
||||
|
||||
<div class="modal-content mb-4">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -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>
|
||||
@@ -3,7 +3,8 @@
|
||||
>
|
||||
<div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4">
|
||||
{% for episode in this.getEpisodes().items %}
|
||||
<div id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results"
|
||||
<episode-container id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results"
|
||||
show-title="{{ this.title }}"
|
||||
data-tv-results-loading-icon-outlet=".loading-icon"
|
||||
data-download-button-outlet=".download-btn"
|
||||
{{ stimulus_controller('tv_results', {
|
||||
@@ -15,77 +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 class="inline-block overflow-hidden rounded-lg">
|
||||
<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>
|
||||
</episode-container>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% set paginator = this.episodes %}
|
||||
|
||||
9
templates/index/test.html.twig
Normal file
9
templates/index/test.html.twig
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block h2 %}Test Test{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div>
|
||||
<!-- Well what are you doing here? -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,10 @@
|
||||
{% block h2 %}Monitors{% endblock %}
|
||||
|
||||
{% block action_buttons %}
|
||||
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false"
|
||||
class="h-6 bg-orange-500/80 hover:bg-orange-600/80 px-2 text-white rounded-ms text-sm font-semibold">
|
||||
Upcoming Episodes
|
||||
</a>
|
||||
<twig:ActionButton action="monitorDispatch" text="Run Monitors" />
|
||||
{% endblock %}
|
||||
|
||||
|
||||
47
templates/monitor/upcoming-episodes.html.twig
Normal file
47
templates/monitor/upcoming-episodes.html.twig
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block h2 %}Upcoming Episodes{% endblock %}
|
||||
|
||||
{% block action_buttons %}
|
||||
<a href="{{ path('app.monitor.upcoming-episodes') }}"
|
||||
class="h-6 bg-orange-500/80 hover:bg-orange-600/80 px-2 text-white rounded-ms text-sm font-semibold">
|
||||
Upcoming Episodes
|
||||
</a>
|
||||
<twig:ActionButton action="monitorDispatch" text="Run Monitors" />
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{{ turbo_page_requires_reload() }}
|
||||
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
|
||||
|
||||
<div class="p-4">
|
||||
<twig:Card title="Upcoming episodes of shows your monitoring">
|
||||
<div id="calendar" class="text-white">
|
||||
</div>
|
||||
</twig:Card>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getView() {
|
||||
if (window.innerWidth < 768) {
|
||||
return 'listWeek';
|
||||
} else {
|
||||
return 'dayGridMonth';
|
||||
}
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
let data = await fetch('/api/monitor/upcoming-episodes');
|
||||
data = (await data.json())['data'];
|
||||
|
||||
const calendarEl = document.getElementById('calendar');
|
||||
const calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: getView(),
|
||||
events: data['episodes'],
|
||||
windowResize: function(arg) {
|
||||
this.changeView(getView());
|
||||
}
|
||||
});
|
||||
calendar.render();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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,7 +81,7 @@
|
||||
<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"
|
||||
>
|
||||
@@ -91,7 +91,7 @@
|
||||
target: 'movie_results_frame',
|
||||
block: 'movie_results'
|
||||
}) }}" />
|
||||
</div>
|
||||
</movie-container>
|
||||
{% elseif "tvshows" == results.media.mediaType %}
|
||||
<twig:TvEpisodeList
|
||||
results="results"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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') }}">
|
||||
|
||||
13
tests/Download/DownloadOptionEvaluatorTest.php
Normal file
13
tests/Download/DownloadOptionEvaluatorTest.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Download;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DownloadOptionEvaluatorTest extends TestCase
|
||||
{
|
||||
public function testEpisodeExists(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
178
tests/Monitor/MonitorTvShowHandlerTest.php
Normal file
178
tests/Monitor/MonitorTvShowHandlerTest.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Monitor;
|
||||
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Monitor\Action\Command\MonitorTvShowCommand;
|
||||
use App\Monitor\Action\Handler\MonitorTvEpisodeHandler;
|
||||
use App\Monitor\Action\Handler\MonitorTvShowHandler;
|
||||
use App\Monitor\Action\Result\MonitorTvShowResult;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Tmdb\Tmdb;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class MonitorTvShowHandlerTest extends TestCase
|
||||
{
|
||||
private MonitorTvShowHandler $handler;
|
||||
private MonitorRepository $monitorRepository;
|
||||
private EntityManagerInterface $entityManager;
|
||||
private MonitorTvEpisodeHandler $episodeHandler;
|
||||
private MediaFiles $mediaFiles;
|
||||
private LoggerInterface $logger;
|
||||
private Tmdb $tmdb;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->monitorRepository = $this->createMock(MonitorRepository::class);
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$this->episodeHandler = $this->createMock(MonitorTvEpisodeHandler::class);
|
||||
$this->mediaFiles = $this->createMock(MediaFiles::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->tmdb = $this->createMock(Tmdb::class);
|
||||
|
||||
$this->handler = new MonitorTvShowHandler(
|
||||
$this->monitorRepository,
|
||||
$this->entityManager,
|
||||
$this->episodeHandler,
|
||||
$this->mediaFiles,
|
||||
$this->logger,
|
||||
$this->tmdb
|
||||
);
|
||||
}
|
||||
|
||||
public function testEpisodeExists(): void
|
||||
{
|
||||
// Arrange
|
||||
$monitor = $this->createMock(Monitor::class);
|
||||
$monitor->method('getId')->willReturn(1);
|
||||
$monitor->method('getTmdbId')->willReturn('63770');
|
||||
$monitor->method('getSeason')->willReturn(10);
|
||||
$monitor->method('getTitle')->willReturn('The Late Show with Stephen Colbert');
|
||||
|
||||
$this->monitorRepository->expects($this->once())
|
||||
->method('find')
|
||||
->with(1)
|
||||
->willReturn($monitor);
|
||||
|
||||
$this->tmdb->expects($this->once())
|
||||
->method('seasonDetails')
|
||||
->with(63770, 10)
|
||||
->willReturn((object)['episodes' => [$this->getTmdbEpisode()]]);
|
||||
|
||||
$this->mediaFiles->expects($this->once())
|
||||
->method('findEpisodes')
|
||||
->willReturn($this->getDownloadedEpisodes());
|
||||
|
||||
// Act
|
||||
$command = new MonitorTvShowCommand(1);
|
||||
$result = $this->handler->handle($command);
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(MonitorTvShowResult::class, $result);
|
||||
$this->assertEquals('OK', $result->status);
|
||||
$this->assertTrue(isset($result->result['monitor']));
|
||||
$this->assertSame($monitor, $result->result['monitor']);
|
||||
}
|
||||
|
||||
public function testEpisodeDoesNotExist(): void
|
||||
{
|
||||
// Arrange
|
||||
$monitor = $this->createMock(Monitor::class);
|
||||
$monitor->method('getId')->willReturn(1);
|
||||
$monitor->method('getTmdbId')->willReturn(63770);
|
||||
$monitor->method('getSeason')->willReturn(10);
|
||||
$monitor->method('getTitle')->willReturn('The Late Show with Stephen Colbert');
|
||||
|
||||
$this->monitorRepository->expects($this->once())
|
||||
->method('find')
|
||||
->with(1)
|
||||
->willReturn($monitor);
|
||||
|
||||
$this->tmdb->expects($this->once())
|
||||
->method('seasonDetails')
|
||||
->with(63770, 10)
|
||||
->willReturn((object)['episodes' => []]);
|
||||
|
||||
$this->mediaFiles->expects($this->once())
|
||||
->method('findEpisodes')
|
||||
->willReturn([]);
|
||||
|
||||
// Act
|
||||
$command = new MonitorTvShowCommand(1);
|
||||
$result = $this->handler->handle($command);
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(MonitorTvShowResult::class, $result);
|
||||
$this->assertEquals('OK', $result->status);
|
||||
$this->assertTrue(isset($result->result['monitor']));
|
||||
$this->assertSame($monitor, $result->result['monitor']);
|
||||
}
|
||||
|
||||
public function testHandleWithInvalidMonitorId(): void
|
||||
{
|
||||
// Arrange
|
||||
$this->monitorRepository->expects($this->once())
|
||||
->method('find')
|
||||
->with(999)
|
||||
->willReturn(null);
|
||||
|
||||
// Act & Assert
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Monitor not found');
|
||||
|
||||
$command = new MonitorTvShowCommand(999);
|
||||
$this->handler->handle($command);
|
||||
}
|
||||
|
||||
private function getTmdbEpisode(): array
|
||||
{
|
||||
return [
|
||||
"air_date" => "2016-05-13",
|
||||
"episode_number" => 142,
|
||||
"episode_type" => "standard",
|
||||
"id" => 1192242,
|
||||
"name" => "Matt Bomer, Zach Woods, Nick Griffin",
|
||||
"overview" => "",
|
||||
"production_code" => "",
|
||||
"runtime" => 45,
|
||||
"season_number" => 1,
|
||||
"show_id" => 63770,
|
||||
"still_path" => null,
|
||||
"vote_average" => 0.0,
|
||||
"vote_count" => 0,
|
||||
"crew" => [],
|
||||
"guest_stars" => [],
|
||||
"poster" => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function getDownloadedEpisodes(): array
|
||||
{
|
||||
$episode1 = (object)[
|
||||
"season" => 10,
|
||||
"episode" => 142,
|
||||
"resolution" => "1080p",
|
||||
"codec" => "h264",
|
||||
"group" => "jebaited.mkv",
|
||||
"container" => "mkv",
|
||||
"title" => "42 stephen colbert 2025 07 21 sandra oh",
|
||||
"episodeName" => "E1 web",
|
||||
];
|
||||
|
||||
$episode2 = (object)[
|
||||
'season' => 10,
|
||||
'episode' => 142,
|
||||
'resolution' => '1080p',
|
||||
'codec' => 'x265',
|
||||
'group' => 'MeGusta[EZTVx.to].mkv',
|
||||
'container' => 'mkv',
|
||||
'title' => '42 Stephen Colbert 2025 07 21 Sandra Oh',
|
||||
'episodeName' => 'E1 HEVC'
|
||||
];
|
||||
|
||||
return [$episode1, $episode2];
|
||||
}
|
||||
}
|
||||
13
tests/bootstrap.php
Normal file
13
tests/bootstrap.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
if (method_exists(Dotenv::class, 'bootEnv')) {
|
||||
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
|
||||
}
|
||||
|
||||
if ($_SERVER['APP_DEBUG']) {
|
||||
umask(0000);
|
||||
}
|
||||
Reference in New Issue
Block a user