Compare commits

..

2 Commits

Author SHA1 Message Date
14739500f4 fix: cleaner monitor logging 2025-07-13 14:19:11 -05:00
0aee4ae7df wip: base layout of config with validation 2025-07-12 23:10:13 -05:00
153 changed files with 2008 additions and 5140 deletions

11
.env
View File

@@ -52,9 +52,10 @@ 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=
SMTP_HOST=
SMTP_USER=
SMTP_PASS=
SMTP_PORT=
SMTP_FROM=
SMTP_FROM_NAME=""

View File

@@ -1,3 +0,0 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

6
.gitignore vendored
View File

@@ -18,9 +18,3 @@ bolt.db
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
.php-cs-fixer.cache
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

View File

@@ -6,7 +6,6 @@ import './bootstrap.js';
* which should already be in your base.html.twig.
*/
import './styles/app.css';
import PullToRefresh from 'pulltorefreshjs';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
@@ -18,3 +17,4 @@ var observer = new MutationObserver(function(mutations) {
});
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});

22
assets/bootstrap.js vendored
View File

@@ -1,26 +1,10 @@
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 Popover from '@stimulus-components/popover'
import Dialog from '@stimulus-components/dialog'
import Dropdown from '@stimulus-components/dropdown'
const app = startStimulusApp();
// register any custom, 3rd party controllers here
app.register('popover', Popover);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);
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'});

View File

@@ -1,111 +0,0 @@
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>
`;
}
}

View File

@@ -1,187 +0,0 @@
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));
}
}

View File

@@ -1,76 +0,0 @@
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;
}
}

View File

@@ -1,115 +0,0 @@
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>
`;
}
}

View File

@@ -1,36 +0,0 @@
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;
}
}

View File

@@ -1,35 +0,0 @@
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();
}
}

View File

@@ -7,7 +7,7 @@ import { getComponent } from '@symfony/ux-live-component';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['download', 'deleteFileInput']
static targets = ['download']
async initialize() {
this.component = await getComponent(this.element);
@@ -20,43 +20,13 @@ 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');
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}}))
}
})
})
if (downloads.length > 5) {
target.classList.add('hidden');
}
}
pauseDownload(data) {
@@ -72,8 +42,7 @@ export default class extends Controller {
}
deleteDownload(data) {
const deleteFileInput = document.querySelector(`#delete_file_${data.params.id}`)
fetch(`/api/download/${data.params.id}?deleteFile=${deleteFileInput.checked}`, {method: 'DELETE'})
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}

View File

@@ -14,22 +14,7 @@ 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() {
@@ -41,8 +26,6 @@ export default class extends Controller {
if (this.countValue === this.totalValue) {
this.toggleIcon();
this.countValue = 0;
console.log('filtering')
document.getElementById('filter').filterResults();
}
}
}

View File

@@ -6,29 +6,6 @@ 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())

View File

@@ -6,6 +6,9 @@ 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,
@@ -17,18 +20,87 @@ export default class extends Controller {
options = []
optionsLoaded = false
resultCountEl = null
async connect() {
this.resultCountEl = document.querySelector('#movie_results_count');
await this.setOptions();
}
async listTargetConnected() {
this.optionsLoaded = true;
this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.resultCountEl.innerText = this.options.length;
this.loadingIconOutlet.toggleIcon();
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}}));
async setOptions() {
if (false === this.optionsLoaded) {
this.optionsLoaded = true;
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
.then(res => res.text())
.then(response => {
this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.toggleIcon();
});
}
}
// 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;
}
});
}
}

View File

@@ -24,7 +24,6 @@ export default class extends Controller {
toggle() {
this.element.parentElement.classList.toggle('hidden');
this.element.classList.toggle('animate__slideInLeft');
this.element.classList.toggle('fixed');
this.element.classList.toggle('z-20');
}

View File

@@ -6,23 +6,24 @@ 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": "",
}
defaultOptions = '<option value="-">-</option>';
static outlets = ['tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'loadingIcon', 'selectAll', 'downloadSelected']
static outlets = ['movie-results', 'tv-results', 'tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
static values = {
'imdbId': String,
'media-type': String,
@@ -31,77 +32,133 @@ 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;
}
}
setTimerToStopLoadingIcon() {
setTimeout(() => this.loadingIconTarget.hideIcon(), 10000);
await this.filter();
}
// Event is fired from movies/tvshows controllers to populate this data
async loadOptions({detail: { options }}) {
await options.forEach((option) => {
option.filter({detail: {activeFilter: this.activeFilter }});
this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset);
this.addQualities(option, option.dataset);
})
await this.filter();
}
selectAllEpisodes() {
document.dispatchEvent(new CustomEvent('selectEpisodeForDownload', {
detail: {
select: this.selectAllTarget.checked,
addLanguages(option, props) {
const languages = Object.assign([], JSON.parse(props['languages']));
languages.forEach((language) => {
if (!this.languages.includes(language)) {
this.languages.push(language);
}
}));
});
const preferred = this.languageTarget.dataset.preferred;
if (preferred) {
this.languageTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.languageTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.languageTarget.innerHTML = '<option value="">n/a</option>';
}
this.languageTarget.innerHTML += this.languages.sort()
.map((language) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === language) {
return;
}
return '<option value="'+language+'">'+language+'</option>';
})
.join();
}
downloadSelectedEpisodes() {
document.dispatchEvent(new CustomEvent('downloadSelectedEpisodes', {}));
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();
}
filter() {
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() {
const downloadSeasonSpan = document.querySelector("#downloadSeasonModal");
const currentSeason = this.activeFilter['season'];
let results = [];
this.activeFilter = {
"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),
"resolution": this.resolutionTarget.value,
"codec": this.codecTarget.value,
"language": this.languageTarget.value,
"provider": this.providerTarget.value,
"quality": this.qualityTarget.value,
}
if ("tvshows" === this.mediaTypeValue) {
downloadSeasonSpan.innerText = this.seasonTarget.value;
if ("movies" === this.mediaTypeValue) {
results = this.movieResultsOutlets;
await results.forEach((list) => list.filter(this.activeFilter));
} else if ("tvshows" === this.mediaTypeValue) {
results = this.tvResultsOutlets;
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: {
@@ -110,15 +167,20 @@ export default class extends Controller {
})
}
#fetchValuesFromNodeList(nodeList) {
return [...nodeList].map(option => option.value)
selectAllEpisodes() {
this.tvResultsOutlets.forEach((episode) => {
if (episode.isActive()) {
episode.selectEpisodeForDownload()
}
});
}
#serializeSelectOptions(options) {
return this.defaultOptions + options.sort()
.map((option) => {
return '<option value="' + option + '">' + option + '</option>'
})
.join();
downloadSelectedEpisodes() {
this.tvResultsOutlets.forEach(episode => {
if (episode.isActive() && episode.isSelected()) {
episode.download();
}
});
this.selectAllTarget.checked = false;
}
}

View File

@@ -12,11 +12,6 @@ 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);
}

View File

@@ -14,13 +14,6 @@ export default class extends Controller {
this.component.on('render:finished', (component) => {
console.log(component);
});
if (window.location.hash) {
let targetElement = document.querySelector(window.location.hash);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster');
}
}
}
setSeason(season) {
@@ -32,7 +25,6 @@ export default class extends Controller {
paginate(event) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.set('episodeNumber', null);
this.component.action('paginate', {page: event.params.page});
this.component.render();
}

View File

@@ -18,23 +18,167 @@ export default class extends Controller {
active: Boolean,
};
static targets = ['list', 'count', 'episodeSelector',]
static targets = ['list', 'count', 'episodeSelector', 'toggleButton', 'listContainer']
static outlets = ['loading-icon']
options = []
optionsLoaded = false
isOpen = false
listTargetConnected() {
this.element.options = this.element.querySelectorAll('tbody tr');
if (this.element.options.length > 0) {
this.element.options.forEach((option) =>
option.querySelector('.download-btn').dataset['title'] = this.titleValue
);
this.element.options[0].querySelector('input[type="checkbox"]').checked = true;
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.element.options}}));
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
async connect() {
await this.setOptions();
}
async setOptions() {
if (this.optionsLoaded === false) {
this.optionsLoaded = true;
let response;
try {
response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
} catch (error) {
console.log('There was an error', error);
}
if (response?.ok) {
response = await response.text()
this.listContainerTarget.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
}
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.increaseCount();
} else {
console.log(`HTTP Response Code: ${response?.status}`)
}
}
this.loadingIconOutlet.increaseCount();
}
//
// async clearCache() {
// await fetch(`/torrentio/tvshows/clear/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
// .then(res => res.text())
// .then(response => {});
// }
async setActive() {
if (false === this.optionsLoaded) {
await this.setOptions();
}
}
async setInActive() {
this.episodeSelectorTarget.checked = false;
}
isActive() {
return this.activeValue;
}
isSelected() {
return this.episodeSelectorTarget.checked;
}
selectEpisodeForDownload() {
if (true === this.isActive() && this.episodeSelectorTarget.disabled === false) {
this.episodeSelectorTarget.checked = !this.episodeSelectorTarget.checked;
}
}
toggleList() {
this.listTarget.classList.toggle('options-table');
this.listTarget.classList.toggle('hidden');
this.toggleButtonTarget.classList.toggle('rotate-90');
this.toggleButtonTarget.classList.toggle('-rotate-90');
}
download() {
this.options.forEach(option => {
const optionSelector = option.querySelector('input[type="checkbox"]');
if (true === optionSelector.checked) {
const downloadBtn = option.querySelector('button.download-btn');
const downloadBtnController = this.application.getControllerForElementAndIdentifier(downloadBtn, 'download-button');
downloadBtnController.download();
optionSelector.checked = false;
this.episodeSelectorTarget.checked = false;
}
})
}
async filter(activeFilter, currentSeason, newSeason) {
if (currentSeason !== activeFilter['season']) {
if (this.seasonValue === newSeason) {
await this.setActive();
} else {
await this.setInActive();
}
}
if (false === this.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const optionHeader = document.querySelector(`[data-option-id="${option.dataset['localId']}"]`)
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden');
optionHeader.classList.add('r-tablerow');
optionHeader.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
optionHeader.classList.remove('r-tablerow');
optionHeader.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
this.countTarget.innerText = count;
});
}
}

View File

@@ -1,57 +0,0 @@
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)
}
}

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 653 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 127 B

View File

@@ -27,11 +27,6 @@
}
}
: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;
@@ -60,10 +55,6 @@ dialog {
}
}
dialog[open] {
animation: fade-in 100ms ease-in forwards;
}
/* Add animations */
dialog[data-dialog-target="dialog"][open] {
animation: fade-in 200ms forwards;
@@ -74,17 +65,7 @@ dialog[data-dialog-target="dialog"][closing] {
}
.text-input {
@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
@apply bg-gray-50 text-gray-50 p-1 bg-transparent border-b-2 border-orange-400
}
.submit-button {
@@ -142,63 +123,10 @@ 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 z-20
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
}
#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;
}

View File

@@ -1,23 +0,0 @@
#!/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';
}

View File

@@ -43,8 +43,7 @@
"symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.3.*",
"symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*",
"symfony/monolog-bundle": "^3.10",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
@@ -106,7 +105,6 @@
"post-update-cmd": [
"@auto-scripts"
],
"tail": "docker compose exec app ./bin/console tailwind:build --watch",
"sym": "docker compose exec app ./bin/console"
},
"conflict": {
@@ -120,7 +118,6 @@
},
"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.*"

1764
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,4 +23,5 @@ return [
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true],
SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,78 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
- monitor
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
monitor:
type: stream
action_level: debug
path: "%kernel.logs_dir%/monitors.log"
channels: [monitor]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
monitor:
type: stream
action_level: info
path: "%kernel.logs_dir%/monitors.log"
channels: [monitor]
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json
monitor:
type: stream
action_level: info
path: "%kernel.logs_dir%/monitors.log"
channels: [monitor]

View File

@@ -1,13 +0,0 @@
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 }

View File

@@ -10,9 +10,9 @@ pwa:
theme_color: "#083344"
description: Torsearch provides a simple and intuitive way to manage your personal media library.
icons:
- src: "/icon.png"
- src: "icon.png"
sizes: [ 192 ]
- src: "/icon.png"
- src: "icon.png"
sizes: [ 192 ]
purpose: maskable
categories:

View File

@@ -6,14 +6,6 @@ controllersBase:
defaults:
schemes: [ 'https' ]
controllersLibrary:
resource:
path: ../src/Library/Framework/Controller/
namespace: App\Library\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersSearch:
resource:
path: ../src/Search/Framework/Controller/

View File

@@ -6,6 +6,7 @@
parameters:
# App
app.url: '%env(APP_URL)%'
app.env: '%env(default:app.default.env:APP_ENV)%'
app.version: '%env(default:app.default.version:APP_VERSION)%'
# Debrid Services
@@ -32,6 +33,7 @@ parameters:
app.cache.redis.host.default: 'redis://redis'
# Various configs
app.default.env: 'prod'
app.default.version: '0.dev'
app.default.timezone: 'America/Chicago'
@@ -44,10 +46,6 @@ 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:

View File

@@ -6,6 +6,7 @@ services:
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
tty: true
deploy:
replicas: 2
volumes:
@@ -15,10 +16,6 @@ services:
- mercure_config:/config
depends_on:
- database
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12202"
worker:
@@ -32,10 +29,6 @@ services:
replicas: 2
depends_on:
- app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12203"
scheduler:
@@ -47,11 +40,6 @@ services:
command: -vv
depends_on:
- app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12204"
redis:

View File

@@ -64,10 +64,4 @@ return [
'version' => '2.4.3',
'type' => 'css',
],
'pulltorefreshjs' => [
'version' => '0.1.22',
],
'@ungap/custom-elements' => [
'version' => '1.3.0',
],
];

View File

@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250724042107 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE preference_option DROP FOREIGN KEY FK_607C52FD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference_option
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE preference_option (id INT AUTO_INCREMENT NOT NULL, preference_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, value VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, enabled TINYINT(1) NOT NULL, INDEX IDX_607C52FD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE preference_option ADD CONSTRAINT FK_607C52FD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id)
SQL);
}
}

View File

@@ -1,35 +0,0 @@
<?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);
}
}

View File

@@ -1,44 +0,0 @@
<?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>

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Base\Config;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class AppConfig implements ConfigInterface
{
private array $messages = [];
public function __construct(
#[Autowire(param: 'app.env')]
private readonly ?string $appEnv = null,
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
#[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null,
#[Autowire(param: 'app.meta_provider.tmdb.key')]
private readonly ?string $tmdbApiKey = null,
#[Autowire(param: 'media.movies_path')]
private readonly ?string $moviesPath = null,
#[Autowire(param: 'media.tvshows_path')]
private readonly ?string $tvshowsPath = null,
) {}
public function isValid(): bool
{
$valid = true;
if (false === $this->isVariableValid('APP_ENV', $this->appEnv)) {
$valid = false;
}
if (false === $this->isVariableValid('APP_URL', $this->appUrl)) {
$valid = false;
}
if (false === $this->isVariableValid('REAL_DEBRID_KEY', $this->realDebridApiKey)) {
$valid = false;
}
if (false === $this->isVariableValid('TMDB_API', $this->tmdbApiKey)) {
$valid = false;
}
if (false === $this->isVariableValid('MOVIES_PATH', $this->moviesPath)) {
$valid = false;
}
if (false === $this->isVariableValid('TVSHOWS_PATH', $this->tvshowsPath)) {
$valid = false;
}
return $valid;
}
public function getMessages(): array
{
return $this->messages;
}
private function isVariableValid($key, $value): bool
{
if ("" === $value || null === $value) {
$this->messages[] = "Your system is misconfigured. Please set the $key environment variable appropriately.";
return false;
}
return true;
}
public function getAppEnv(): ?string
{
return $this->appEnv;
}
public function getAppUrl(): ?string
{
return $this->appUrl;
}
public function getRealDebridApiKey(): ?string
{
return $this->realDebridApiKey;
}
public function getTmdbApiKey(): ?string
{
return $this->tmdbApiKey;
}
public function getMoviesPath(): ?string
{
return $this->moviesPath;
}
public function getTvshowsPath(): ?string
{
return $this->tvshowsPath;
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Base\Config\Auth;
use App\Base\Config\ConfigInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class OidcConfig implements ConfigInterface
{
private array $messages = [];
public function __construct(
#[Autowire(param: 'auth.method')]
private readonly ?string $authMethod = null,
#[Autowire(param: 'auth.oidc.well_known_url')]
private readonly ?string $wellKnownUrl = null,
#[Autowire(param: 'auth.oidc.client_id')]
private readonly ?string $clientId = null,
#[Autowire(param: 'auth.oidc.client_secret')]
private readonly ?string $clientSecret = null,
#[Autowire(param: 'auth.oidc.bypass_form_login')]
private readonly ?bool $bypassFormLogin = null,
) {}
public function isEnabled(): bool
{
return "oidc" === strtolower($this->authMethod);
}
public function isValid(): bool
{
$valid = true;
if (true === $this->isEnabled()) {
if (false === $this->isVariableValid("OIDC_CLIENT_ID", $this->clientId)) {
$valid = false;
}
if (false === $this->isVariableValid("OIDC_CLIENT_SECRET", $this->clientSecret)) {
$valid = false;
}
if (false === $this->isVariableValid("OIDC_WELL_KNOWN_URL", $this->wellKnownUrl)) {
$valid = false;
}
}
return $valid;
}
public function getWellKnownUrl(): ?string
{
return $this->wellKnownUrl;
}
public function getClientId(): ?string
{
return $this->clientId;
}
public function getClientSecret(): ?string
{
return $this->clientSecret;
}
public function getBypassFormLogin(): ?bool
{
return $this->bypassFormLogin;
}
public function getMessages(): array
{
return $this->messages;
}
private function isVariableValid(string $key, mixed $value): bool
{
if ("" === $value || null === $value) {
$this->messages[] = "Your OIDC is misconfigured. Please set the $key environment variable to the required value.";
return true;
}
return false;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Base\Config;
use App\Base\Config\Auth\OidcConfig;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class AuthConfig implements ConfigInterface
{
const AUTH_METHODS = ['form_login', 'oidc'];
private array $messages = [];
public function __construct(
#[Autowire(param: 'auth.method')]
private readonly ?string $authMethod,
private readonly OidcConfig $oidcConfig,
) {}
public function isMethod(string $method): bool
{
return $this->getAuthMethod() === strtolower($method);
}
public function getAuthMethod(): string
{
return strtolower($this->authMethod);
}
public function getOidcConfig(): OidcConfig
{
return $this->oidcConfig;
}
public function isValid(): bool
{
$valid = true;
if (null === $this->getAuthMethod() || "" === $this->getAuthMethod()) {
$this->messages[] = "Your auth method is missing. Please set the AUTH_METHOD environment variable to your desired value. Valid options: [form_login, oidc].";
return false;
}
if (!in_array($this->getAuthMethod(), self::AUTH_METHODS, true)) {
$this->messages[] = "Your auth method is incorrect. Please set the AUTH_METHOD environment variable to your desired value. Valid options: [form_login, oidc].";
return false;
}
if ("oidc" === $this->getAuthMethod()) {
if (false === $this->oidcConfig->isValid()) {
$this->messages += $this->oidcConfig->getMessages();
$valid = false;
}
}
return $valid;
}
public function getMessages(): array
{
return $this->messages;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Base\Config;
interface ConfigInterface
{
/** Validates the config values are present and as expected */
public function isValid(): bool;
/** Holds the error messages so they can be logged */
public function getMessages(): array;
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Base\Config;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class SmtpConfig implements ConfigInterface
{
private array $messages = [];
/*
* SMTP is considered enabled if any of
* the parameters are set. If none are set,
* then the User must not need it.
*/
private bool $isEnabled = false;
public function __construct(
#[Autowire(env: 'SMTP_HOST')]
private readonly ?string $smtpHost,
#[Autowire(env: 'SMTP_USER')]
private readonly ?string $smtpUser,
#[Autowire(env: 'SMTP_PASS')]
private readonly ?string $smtpPass,
#[Autowire(env: 'SMTP_PORT')]
private readonly ?string $smtpPort,
#[Autowire(env: 'SMTP_FROM')]
private readonly ?string $smtpFrom,
#[Autowire(env: 'SMTP_FROM_NAME')]
private readonly ?string $smtpFromName,
) {
foreach (func_get_args() as $key => $value) {
if ("" !== $value && $value !== null) {
$this->isEnabled = true;
}
}
}
public function isEnabled(): bool
{
return $this->isEnabled;
}
public function getMessages(): array
{
return $this->messages;
}
public function isValid(): bool
{
if (false === $this->isEnabled) {
return true;
}
$valid = true;
$params = [
'SMTP_HOST' => $this->smtpHost,
'SMTP_USER' => $this->smtpUser,
'SMTP_PASS' => $this->smtpPass,
'SMTP_PORT' => $this->smtpPort,
'SMTP_FROM' => $this->smtpFrom,
'SMTP_FROM_NAME' => $this->smtpFromName,
];
foreach ($params as $key => $value) {
if (false === $this->isVariableValid($key, $value)) {
$valid = false;
}
}
return $valid;
}
private function isVariableValid($key, $value): bool
{
if ("" === $value || null === $value) {
$this->messages[] = "Your SMTP is misconfigured. Please set the $key environment variable appropriately.";
return false;
}
return true;
}
public function getSmtpHost(): ?string
{
return $this->smtpHost;
}
public function getSmtpUser(): ?string
{
return $this->smtpUser;
}
public function getSmtpPass(): ?string
{
return $this->smtpPass;
}
public function getSmtpPort(): ?string
{
return $this->smtpPort;
}
public function getSmtpFrom(): ?string
{
return $this->smtpFrom;
}
public function getSmtpFromName(): ?string
{
return $this->smtpFromName;
}
}

View File

@@ -2,67 +2,57 @@
namespace App\Base;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use App\Base\Config\AppConfig;
use App\Base\Config\AuthConfig;
use App\Base\Config\SmtpConfig;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class ConfigResolver
{
private array $messages = [];
public function __construct(
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
#[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null,
#[Autowire(param: 'app.meta_provider.tmdb.key')]
private readonly ?string $tmdbApiKey = null,
#[Autowire(param: 'media.movies_path')]
private readonly ?string $moviesPath = null,
#[Autowire(param: 'media.tvshows.path')]
private readonly ?string $tvshowsPath = null,
#[Autowire(param: 'auth.method')]
private readonly ?string $authMethod = null,
#[Autowire(param: 'auth.oidc.well_known_url')]
private readonly ?string $authOidcWellKnownUrl = null,
#[Autowire(param: 'auth.oidc.client_id')]
private readonly ?string $authOidcClientId = null,
#[Autowire(param: 'auth.oidc.client_secret')]
private readonly ?string $authOidcClientSecret = null,
#[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,
private readonly LoggerInterface $logger,
private readonly TagAwareCacheInterface $cache,
private readonly AuthConfig $authConfig,
private readonly SmtpConfig $smtpConfig,
private readonly AppConfig $appConfig,
) {}
public function validate(): bool
{
if ("prod" === strtolower($this->appConfig->getAppEnv())) {
return $this->cache->get('app.valid_config', function () {
return $this->doValidate();
});
} else {
return $this->doValidate();
}
}
private function doValidate(): bool
{
$valid = true;
if (null === $this->realDebridApiKey || "" === $this->realDebridApiKey) {
$this->messages[] = "Your Real Debrid API key is missing. Please set it to the 'REAL_DEBRID_KEY' environment variable.";
if (false === $this->appConfig->isValid()) {
$this->messages += $this->appConfig->getMessages();
$valid = false;
}
if (null === $this->tmdbApiKey || "" === $this->tmdbApiKey) {
$this->messages[] = "Your TMDB API key is missing. Please set it to the 'TMDB_API' environment variable.";
if (false === $this->authConfig->isValid()) {
$this->messages += $this->authConfig->getMessages();
$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.";
if (false === $this->smtpConfig->isValid()) {
$this->messages += $this->smtpConfig->getMessages();
$valid = false;
}
if (false === $valid) {
foreach ($this->messages as $message) {
$this->logger->error('> [ConfigResolver] ' . $message);
}
}
@@ -74,34 +64,18 @@ final class ConfigResolver
return $this->messages;
}
public function getAuthConfig(): AuthConfig
{
return $this->authConfig;
}
public function authIs(string $method): bool
{
if (strtolower($method) === strtolower($this->getAuthMethod())) {
return true;
}
return false;
return $this->authConfig->isMethod($method);
}
public function getAuthMethod(): string
{
return strtolower($this->authMethod);
}
public function bypassFormLogin(): bool
{
return $this->authOidcBypassFormLogin;
}
public function getAuthConfig(): array
{
return [
'method' => $this->authMethod,
'oidc' => [
'well_known_url' => $this->authOidcWellKnownUrl,
'client_id' => $this->authOidcClientId,
'client_secret' => $this->authOidcClientSecret,
'bypass_form_login' => $this->authOidcBypassFormLogin,
]
];
return $this->authConfig->getAuthMethod();
}
}

View File

@@ -20,15 +20,17 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class SeedDatabaseCommand extends Command
{
private PreferencesRepository $preferenceRepository;
private PreferenceOptionRepository $preferenceOptionRepository;
private UserRepository $userRepository;
public function __construct(
PreferencesRepository $preferenceRepository,
PreferenceOptionRepository $preferenceOptionRepository,
UserRepository $userRepository,
) {
parent::__construct();
$this->preferenceRepository = $preferenceRepository;
$this->preferenceOptionRepository = $preferenceOptionRepository;
$this->userRepository = $userRepository;
}
@@ -37,6 +39,7 @@ class SeedDatabaseCommand extends Command
$io = new SymfonyStyle($input, $output);
$this->seedPreferences($io);
$this->seedPreferenceOptions($io);
$this->updateUserPreferences($io);
return Command::SUCCESS;
@@ -137,4 +140,72 @@ class SeedDatabaseCommand extends Command
],
];
}
private function seedPreferenceOptions(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Seeding preference options...');
$options = $this->getPreferenceOptions();
foreach ($options as $option) {
if ($this->preferenceOptionRepository->findBy([
'preference' => $option['preference_id'],
'name' => $option['name'],
'value' => $option['value'],
'enabled' => $option['enabled'],
])) {
continue;
}
$this->preferenceOptionRepository->getEntityManager()->persist(
(new \App\User\Framework\Entity\PreferenceOption())
->setPreference($this->preferenceRepository->find($option['preference_id']))
->setName($option['name'])
->setValue($option['value'])
->setEnabled($option['enabled'])
);
}
$this->preferenceOptionRepository->getEntityManager()->flush();
}
private function getPreferenceOptions(): array
{
return [
[
'preference_id' => 'resolution',
'name' => '720p',
'value' => '720p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '1080p',
'value' => '1080p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '2160p',
'value' => '2160p',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => '-',
'value' => '-',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h264',
'value' => 'h264',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h265/HEVC',
'value' => 'h265',
'enabled' => true
]
];
}
}

View File

@@ -2,7 +2,6 @@
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;
@@ -49,10 +48,4 @@ final class IndexController extends AbstractController
'message' => 'Email sent!'
]);
}
#[Route('/test')]
public function monitorTvShow(): Response
{
return $this->render('index/test.html.twig', []);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Base\Framework\EventListener;
use App\Base\ConfigResolver;
use App\Base\Service\Broadcaster;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
final class KernelRequestValidateConfig
{
public function __construct(
private readonly ConfigResolver $configResolver,
private readonly Broadcaster $broadcaster,
) {}
#[AsEventListener(event: 'kernel.request', priority: 20)]
public function validateConfig(RequestEvent $event): void
{
if (false === $this->configResolver->validate()) {
$this->broadcaster->systemAlert('Ruh-roh', 'It looks like your system is misconfigured. Please search the application logs for strings starting with "[error] > [ConfigResolver]" to find more information.');
}
}
}

View File

@@ -2,53 +2,48 @@
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", bool $sendPush = false): void
public function alert(string $title, string $message, string $type = "success"): void
{
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
}
$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);
}
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());
}
}
public function systemAlert(string $title, string $message, string $type = "warning"): void
{
$update = new Update(
'system_alerts',
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);
}
}

View File

@@ -4,8 +4,7 @@ namespace App\Base\Service;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use App\Base\Util\PTN;
use Psr\Log\LoggerInterface;
use Nihilarr\PTN;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
@@ -22,7 +21,6 @@ class MediaFiles
private string $tvShowsPath;
private Filesystem $filesystem;
private LoggerInterface $logger;
public function __construct(
#[Autowire(param: 'media.base_path')]
@@ -35,14 +33,12 @@ class MediaFiles
string $tvShowsPath,
Filesystem $filesystem,
LoggerInterface $logger,
) {
$this->finder = new Finder();
$this->basePath = $basePath;
$this->moviesPath = $moviesPath;
$this->tvShowsPath = $tvShowsPath;
$this->filesystem = $filesystem;
$this->logger = $logger;
}
public function getPathByType(string $mediaType): string
@@ -144,7 +140,7 @@ class MediaFiles
return $path;
}
public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber): SplFileInfo|false
public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber)
{
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);
@@ -224,26 +220,4 @@ class MediaFiles
{
$this->filesystem->chmod($filepath, $permissions);
}
/**
* @param string $filepath
* @return bool
* Returns true if file was deleted
* Returns false is file not found or was not deleted
*/
public function removeFile(string $filepath): bool
{
if (true === $this->filesystem->exists($filepath)) {
try {
$this->filesystem->remove($filepath);
return true;
} catch (\Throwable $exception) {
$this->logger->error($exception->getMessage(), ['file' => $filepath]);
return false;
}
}
$this->logger->warning('> [MediaFiles] Attempted to remove file, but it did not exist.', ['file' => $filepath]);
return false;
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Base\Util;
class ImdbMatcher
{
public static function isMatch(string $imdbId): bool
{
return preg_match('/^tt\d{7}$/', $imdbId);
}
}

View File

@@ -1,246 +0,0 @@
<?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));
}
}
}

View File

@@ -11,6 +11,5 @@ class DeleteDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
public bool $deleteFile = false,
) {}
}

View File

@@ -5,8 +5,6 @@ namespace App\Download\Action\Handler;
use App\Download\Action\Command\DeleteDownloadCommand;
use App\Download\Action\Result\DeleteDownloadResult;
use App\Download\Framework\Repository\DownloadRepository;
use App\Library\Action\Command\DeleteMediaFileCommand;
use App\Library\Action\Handler\DeleteMediaFileHandler;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -16,26 +14,13 @@ readonly class DeleteDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private DeleteMediaFileHandler $deleteMediaFileHandler,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$download = $this->downloadRepository->find($command->downloadId);
if (true === $command->deleteFile) {
$deletedFileResult = $this->deleteMediaFileHandler->handle(new DeleteMediaFileCommand(
filename: $download->getFilename(),
downloadId: $command->downloadId
));
}
$this->downloadRepository->delete($command->downloadId);
return new DeleteDownloadResult(
status: 200,
message: 'Success',
download: $download,
deleteMediaFileResult: $deletedFileResult ?? null
);
return new DeleteDownloadResult(200, 'Success', $download);
}
}

View File

@@ -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 App\Base\Util\PTN;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;

View File

@@ -3,7 +3,6 @@
namespace App\Download\Action\Input;
use App\Download\Action\Command\DeleteDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
@@ -14,15 +13,12 @@ class DeleteDownloadInput implements InputInterface
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
#[SourceQuery('deleteFile')]
public bool $deleteFile = false,
) {}
public function toCommand(): CommandInterface
{
return new DeleteDownloadCommand(
$this->downloadId,
$this->deleteFile,
);
}
}

View File

@@ -12,6 +12,5 @@ class DeleteDownloadResult implements ResultInterface
public int $status,
public string $message,
public Download $download,
public ?DeleteMediaFileResult $deleteMediaFileResult = null,
) {}
}

View File

@@ -1,14 +0,0 @@
<?php
namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class DeleteMediaFileResult implements ResultInterface
{
public function __construct(
public string $message,
public string $filepath,
public bool $isDeleted,
) {}
}

View File

@@ -3,80 +3,92 @@
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 $filter): ?TorrentioResult
{
$matches = Map::from($results)->filter(function ($result) use ($filter) {
if (false === $this->validateFilterItems($result, $filter)) {
return false;
}
if (false === $this->validateSize($result, $filter)) {
return false;
}
return true;
});
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
public function evaluateOptions(array $results, UserPreferences $userPreferences): ?TorrentioResult
{
$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));
$bestMatches = [];
$matches = [];
foreach ($results as $result) {
if (!in_array($userPreferences->language, $result->languages)) {
continue;
}
if ($result->resolution === $userPreferences->resolution
&& $result->codec === $userPreferences->codec
) {
$bestMatches[] = $result;
}
if ($userPreferences->resolution === '2160p'
&& $userPreferences->codec === $result->codec
&& $result->resolution === '1080p'
) {
$matches[] = $result;
}
if ($userPreferences->codec === 'h264'
&& $userPreferences->resolution === $result->resolution
&& $result->codec === 'h265'
) {
$matches[] = $result;
}
if (($userPreferences->codec === null )
&& ($userPreferences->resolution === null )) {
$matches[] = $result;
}
}
if ($size > $sizeLow && $size < $sizeHigh) {
return true;
$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;
}
}
return false;
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();
}
return null;
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Download\Downloader;
use App\Base\Service\Broadcaster;
use App\Base\Service\MediaFiles;
use App\Download\Framework\Entity\Download;
use Doctrine\ORM\EntityManagerInterface;
@@ -20,7 +19,6 @@ class ProcessDownloader implements DownloaderInterface
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private CacheInterface $cache,
private readonly Broadcaster $broadcaster,
) {}
/**
@@ -84,7 +82,6 @@ class ProcessDownloader implements DownloaderInterface
});
if ($downloadEntity->getStatus() !== 'Paused') {
$downloadEntity->setProgress(100);
$this->alertComplete($downloadEntity);
}
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
@@ -108,15 +105,4 @@ 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);
}
}

View File

@@ -66,7 +66,7 @@ class ApiController extends AbstractController
message: "{$result->download->getTitle()} has been deleted.",
);
return $this->json($result);
return $this->json(['status' => 200, 'message' => 'Download Deleted']);
}
#[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])]

View File

@@ -6,8 +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 App\Base\Util\PTN;
use Symfony\Component\Serializer\Attribute\Ignore;
use Nihilarr\PTN;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
@@ -45,7 +44,6 @@ class Download
#[ORM\Column(length: 255, nullable: true)]
private ?string $episodeId = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'downloads')]
private ?User $user = null;

View File

@@ -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 App\Base\Util\PTN;
use Nihilarr\PTN;
use Symfony\Component\Security\Core\User\UserInterface;
/**

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Library\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DeleteMediaFileCommand>
*/
class DeleteMediaFileCommand implements CommandInterface
{
public function __construct(
public string $filename,
public ?int $downloadId = null,
) {}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Library\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class LibrarySearchCommand implements CommandInterface
{
public function __construct(
public ?string $term = null,
public ?string $title = null,
public ?string $imdbId = null,
public ?string $season = null,
public ?string $episode = null,
) {}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Library\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Download\Action\Result\DeleteMediaFileResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Library\Action\Command\DeleteMediaFileCommand;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements HandlerInterface<DeleteMediaFileCommand,DeleteMediaFileResult>
*/
class DeleteMediaFileHandler implements HandlerInterface
{
public function __construct(
private readonly DownloadRepository $downloadRepository,
private readonly MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $downloadRecord */
$downloadRecord = $this->downloadRepository->find($command->downloadId);
$filepath = $this->getFullFilepath($downloadRecord);
$result = $this->mediaFiles->removeFile($filepath);
return new DeleteMediaFileResult(
message: true === $result ? 'File removed' : 'File not removed',
filepath: $filepath,
isDeleted: $result
);
}
private function getFullFilepath(Download $download): string
{
return $this->mediaFiles->getPathByType($download->getMediaType()) . DIRECTORY_SEPARATOR . $download->getFilename();
}
}

View File

@@ -1,83 +0,0 @@
<?php
namespace App\Library\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Library\Action\Command\LibrarySearchCommand;
use App\Library\Action\Result\LibrarySearchResult;
use App\Library\Dto\MediaFileDto;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements HandlerInterface<LibrarySearchCommand,LibrarySearchHandler>
*/
class LibrarySearchHandler implements HandlerInterface
{
private array $searchTypes = [
'episode_by_title' => 'episodeByTitle',
'movie_by_title' => 'movieByTitle',
];
public function __construct(
private readonly MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$searchType = $this->getSearchType($command);
$function = $this->searchTypes[$searchType];
return $this->$function($command);
}
private function getSearchType(CommandInterface $command): ?string
{
if (!is_null($command->title) &&
is_null($command->imdbId) &&
is_null($command->season) &&
is_null($command->episode)
) {
return 'movie_by_title';
}
if ((!is_null($command->title) || is_null($command->imdbId)) &&
!is_null($command->season) &&
!is_null($command->episode)
) {
return 'episode_by_title';
}
return null;
}
private function episodeByTitle(CommandInterface $command): ?LibrarySearchResult
{
$result = $this->mediaFiles->episodeExists(
$command->title,
(int) $command->season,
(int) $command->episode,
);
$exists = $result instanceof \SplFileInfo;
return new LibrarySearchResult(
input: $command,
exists: $exists,
file: true === $exists ? MediaFileDto::fromSplFileInfo($result) : null,
ptn: true === $exists ? (object) new PTN()->parse($result->getFilename()) : null,
);
}
private function movieByTitle(CommandInterface $command): ?LibrarySearchResult
{
$result = $this->mediaFiles->movieExists($command->title);
$exists = $result instanceof \SplFileInfo;
return new LibrarySearchResult(
input: $command,
exists: $exists,
file: true === $exists ? MediaFileDto::fromSplFileInfo($result) : null,
ptn: true === $exists ? (object) new PTN()->parse($result->getFilename()) : null,
);
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Library\Action\Input;
use App\Library\Action\Command\DeleteMediaFileCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<DeleteMediaFileInput,DeleteMediaFileCommand>
*/
class DeleteMediaFileInput implements InputInterface
{
public function __construct(
#[SourceRequest('filename')]
public string $filename,
#[SourceRequest('downloadId', nullify: true)]
public ?int $downloadId = null,
) {}
public function toCommand(): CommandInterface
{
return new DeleteMediaFileCommand(
$this->filename,
$this->downloadId,
);
}
}

View File

@@ -1,38 +0,0 @@
<?php
namespace App\Library\Action\Input;
use App\Library\Action\Command\LibrarySearchCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<LibrarySearchInput, LibrarySearchCommand>
*/
class LibrarySearchInput implements InputInterface
{
public function __construct(
#[SourceQuery('term', nullify: true)]
private ?string $term = null,
#[SourceQuery('title', nullify: true)]
private ?string $title = null,
#[SourceQuery('imdbId', nullify: true)]
private ?string $imdbId = null,
#[SourceQuery('season', nullify: true)]
private ?string $season = null,
#[SourceQuery('episode', nullify: true)]
private ?string $episode = null,
) {}
public function toCommand(): CommandInterface
{
return new LibrarySearchCommand(
term: $this->term,
title: $this->title,
imdbId: $this->imdbId,
season: $this->season,
episode: $this->episode,
);
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Library\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements ResultInterface
*/
class DeleteMediaFileResult implements ResultInterface
{
public function __construct(
public string $status,
) {}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Library\Action\Result;
use App\Library\Dto\MediaFileDto;
use OneToMany\RichBundle\Contract\ResultInterface;
class LibrarySearchResult implements ResultInterface
{
public function __construct(
public object|array $input,
public bool $exists,
public ?MediaFileDto $file = null,
public ?object $ptn = null,
) {}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Library\Dto;
readonly class MediaFileDto
{
public function __construct(
public string $path,
public string $filename,
public string $extension,
public string $size,
) {}
public static function fromSplFileInfo(\SplFileInfo|false $fileInfo): self|false
{
return false === $fileInfo ? false : new static(
path: $fileInfo->getRealPath(),
filename: $fileInfo->getFilename(),
extension: $fileInfo->getExtension(),
size: $fileInfo->getSize(),
);
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Library\Framework\Controller;
use App\Library\Action\Handler\LibrarySearchHandler;
use App\Library\Action\Input\LibrarySearchInput;
use App\Library\Action\Result\LibrarySearchResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboBundle;
class Api extends AbstractController
{
#[Route('/api/library/search', name: 'api.library.search', methods: ['GET'])]
public function search(LibrarySearchInput $input, LibrarySearchHandler $handler, Request $request): Response
{
$result = $handler->handle($input->toCommand());
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($result, $request);
}
return $this->json($handler->handle($input->toCommand()));
}
private function sendFragmentResponse(LibrarySearchResult $result, Request $request): Response
{
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock(
'search/fragments.html.twig',
$request->query->get('block'),
[
'result' => $result,
'target' => $request->query->get('target')
]
);
}
}

View File

@@ -1,76 +0,0 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorMovieHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $movieMonitorRepository,
private GetMovieOptionsHandler $getMovieOptionsHandler,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Security $security,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler');
/** @var Monitor $monitor */
$monitor = $this->movieMonitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
$this->logger->info('> [MonitorMovieHandler] Searching for "' . $monitor->getTitle() . '" download options');
$results = $this->getMovieOptionsHandler->handle(
new GetMovieOptionsCommand($monitor->getTmdbId(), $monitor->getImdbId())
);
$this->logger->info('> [MonitorMovieHandler] Found ' . count($results->results) . ' download options');
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
if (null !== $result) {
$this->logger->info('> [MonitorMovieHandler] 1 result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand(
$result->url,
$monitor->getTitle(),
$result->filename,
'movies',
$monitor->getImdbId(),
$monitor->getUser()->getId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeIMmutable());
} else {
$monitor->setStatus('Active');
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->incrementSearchCount();
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -15,7 +15,6 @@ use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
use Carbon\Carbon;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -28,7 +27,6 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
public function __construct(
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private DownloadOptionEvaluator $downloadOptionEvaluator,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
@@ -43,11 +41,6 @@ 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(

View File

@@ -12,7 +12,7 @@ use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use App\Base\Util\PTN;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;

View File

@@ -11,27 +11,29 @@ 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 App\Base\Util\PTN;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvShowHandler implements HandlerInterface
{
public function __construct(
#[Target('monitorLogger')]
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private Tmdb $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
@@ -48,7 +50,6 @@ 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
@@ -97,7 +98,6 @@ 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');

View File

@@ -2,13 +2,11 @@
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;
@@ -67,45 +65,4 @@ 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(),
]
]);
}
}

View File

@@ -28,10 +28,4 @@ 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');
}
}

View File

@@ -56,9 +56,6 @@ 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;
@@ -260,17 +257,6 @@ 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)) {

View File

@@ -33,12 +33,4 @@ 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();
}
}

View File

@@ -9,6 +9,7 @@ use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Framework\Repository\MonitorRepository;
use Carbon\Carbon;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
@@ -16,13 +17,14 @@ use Symfony\Component\Scheduler\Attribute\AsCronTask;
class MonitorDispatcher
{
public function __construct(
#[Target('monitorLogger')]
private readonly LoggerInterface $logger,
private readonly MonitorRepository $monitorRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke() {
$this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher');
$this->logger->info('[MonitorDispatcher] > Executing MonitorDispatcher');
$this->cleanupStuckMonitors();
@@ -34,15 +36,19 @@ class MonitorDispatcher
];
$monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]);
$this->logger->info('[MonitorDispatcher] ' . count($monitors) . ' monitors found');
foreach ($monitors as $monitor) {
$this->logger->info('[MonitorDispatcher] - Evaluating monitor ' . $monitor->getId() . ' for "' . $monitor->getTitle() . '"');
$monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$command = $monitorHandlers[$monitor->getMonitorType()];
$this->logger->info('[MonitorDispatcher] Dispatching ' . $command . ' for ' . $monitor->getTitle());
$this->logger->info('[MonitorDispatcher] Dispatching ' . $command . ' for ' . $monitor->getTitle());
$this->bus->dispatch(new $command($monitor->getId()));
}
$this->logger->info('[MonitorDispatcher] < Complete');
}
private function cleanupStuckMonitors(): void

View File

@@ -11,6 +11,5 @@ class GetMediaInfoCommand implements CommandInterface
public string $imdbId,
public string $mediaType,
public ?int $season = null,
public ?int $episode = null,
) {}
}

View File

@@ -2,6 +2,7 @@
namespace App\Search\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Result\GetMediaInfoResult;
use App\Tmdb\Tmdb;
@@ -14,12 +15,19 @@ class GetMediaInfoHandler implements HandlerInterface
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly MediaFiles $mediaFiles
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
return new GetMediaInfoResult($media, $command->season, $command->episode);
if ("tvshows" === $command->mediaType) {
foreach ($media->episodes[$command->season] as $key => $episode) {
$media->episodes[$command->season][$key]['file'] = $this->mediaFiles->episodeExists($media->title, $command->season, $episode['episode_number']);
}
}
return new GetMediaInfoResult($media, $command->season);
}
}

View File

@@ -2,8 +2,6 @@
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;
@@ -19,13 +17,6 @@ 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)

View File

@@ -19,9 +19,6 @@ class GetMediaInfoInput implements InputInterface
#[SourceRoute('season', nullify: true)]
public ?int $season,
#[SourceRoute('episode', nullify: true)]
public ?int $episode,
) {}
public function toCommand(): CommandInterface
@@ -29,10 +26,6 @@ class GetMediaInfoInput implements InputInterface
if ("tvshows" === $this->mediaType && null === $this->season) {
$this->season = 1;
}
if ("tvshows" === $this->mediaType && null === $this->episode) {
$this->episode = 1;
}
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season, $this->episode);
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season);
}
}

View File

@@ -11,6 +11,5 @@ class GetMediaInfoResult implements ResultInterface
public function __construct(
public TmdbResult $media,
public ?int $season,
public ?int $episode,
) {}
}

View File

@@ -1,13 +0,0 @@
<?php
namespace App\Search\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class RedirectToMediaResult implements ResultInterface
{
public function __construct(
public string $imdbId,
public string $mediaType,
) {}
}

View File

@@ -1,213 +0,0 @@
<?php
namespace App\Search\Framework\Command;
use Aimeos\Map;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Handler\DownloadMediaHandler;
use App\Download\Framework\Repository\DownloadRepository;
use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Command\SearchCommand as CommandInput;
use App\Search\Action\Result\SearchResult;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Result\TorrentioResult;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand('search')]
class SearchCommand extends Command
{
private SearchHandler $searchHandler;
private GetTvShowOptionsHandler $getTvShowOptionsHandler;
private GetMovieOptionsHandler $getMovieOptionsHandler;
private UserRepository $userRepository;
private DownloadRepository $downloadRepository;
private DownloadMediaHandler $downloadMediaHandler;
private MessageBusInterface $bus;
public function __construct(SearchHandler $searchHandler, GetTvShowOptionsHandler $getTvShowOptionsHandler,
GetMovieOptionsHandler $getMovieOptionsHandler,
UserRepository $userRepository,
DownloadRepository $downloadRepository,
DownloadMediaHandler $downloadMediaHandler,
MessageBusInterface $bus,
?string $name = null
) {
parent::__construct($name);
$this->searchHandler = $searchHandler;
$this->getTvShowOptionsHandler = $getTvShowOptionsHandler;
$this->getMovieOptionsHandler = $getMovieOptionsHandler;
$this->userRepository = $userRepository;
$this->downloadRepository = $downloadRepository;
$this->downloadMediaHandler = $downloadMediaHandler;
$this->bus = $bus;
}
protected function configure()
{
$this->addArgument('term', InputArgument::REQUIRED);
$this->addOption('local', 'l', InputOption::VALUE_NONE, 'Perform the download locally instead of submitting it to the queue.');
}
public function run(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$command = new CommandInput($input->getArgument('term'));
// Perform search
$mediaOptions = $this->searchHandler->handle($command);
// Render results and ask the User to pick one
$mediaChoice = $this->askToChooseMediaOption($io, $output, $mediaOptions);
// Find download options based on the User's choice
$downloadOptions = $this->fetchDownloadOptions($mediaChoice);
// Render results and ask the User to pick one
$downloadChoice = $this->askToChooseDownloadOption($io, $output, $downloadOptions);
// Have user confirm download action
$confirmation = $this->askToConfirmDownload($io, $output, $downloadChoice);
// Begin download or submit to the queue
if (true === $confirmation) {
$downloadLocally = $input->getOption('local');
$this->submitDownload($io, $mediaChoice, $downloadChoice, $downloadLocally);
} else {
$io->success('No results found.');
}
$io->success('Success!');
return Command::SUCCESS;
}
private function askToChooseMediaOption(SymfonyStyle $io, OutputInterface $output, SearchResult $result): TmdbResult
{
$table = new Table($output)
->setHeaders(['ID', 'Title', 'Year', 'IMDb ID', 'Description'])
->setRows(
Map::from($result->results)
->map(fn ($result, $index) => [$index, $result->title, $result->year, $result->imdbId, substr($result->description, 0, 80) . '...'])
->toArray()
);
$table->render();
$question = new Question('Enter the ID of the correct result: ');
$choiceId = $io->askQuestion($question);
$choice = $result->results[$choiceId];
$io->info('You chose: ' . $choice->title);
$io->table(
['ID', 'Title', 'Year', 'IMDb ID', 'Description'],
[
[$choiceId, $choice->title, $choice->year, $choice->imdbId, substr($choice->description, 0, 80) . '...'],
]
);
return $choice;
}
private function fetchDownloadOptions(TmdbResult $result): array
{
$handlers = [
'movies' => $this->getMovieOptionsHandler,
'tvshows' => $this->getTvShowOptionsHandler,
];
$handler = $handlers[$result->mediaType];
if ("movies" === $result->mediaType) {
$command = new GetMovieOptionsCommand(
$result->tmdbId,
$result->imdbId
);
} else {
$command = new GetTvShowOptionsCommand(
$result->tmdbId,
$result->imdbId,
1,
1
);
}
$result = $handler->handle($command);
return $result->results;
}
private function askToChooseDownloadOption(SymfonyStyle $io, OutputInterface $output, array $options): TorrentioResult
{
$table = new Table($output)
->setHeaders(['ID', 'Size', 'Resolution', 'Codec', 'Seeders', 'Provider', 'Language'])
->setRows(
Map::from($options)
->map(fn (TorrentioResult $result, $index) => [$index, $result->size, $result->resolution, $result->codec, $result->seeders, $result->provider, implode(', ', $result->languages)])
->toArray()
);
$table->render();
$question = new Question('Enter the ID of the item to download: ');
$choiceId = $io->askQuestion($question);
$choice = $options[$choiceId];
$io->info('You chose: ' . $choice->title);
return $choice;
}
private function askToConfirmDownload(SymfonyStyle $io, OutputInterface $output, TorrentioResult $downloadOption): bool
{
$question = new ChoiceQuestion('Are you sure you want to download the above file?', ['yes', 'no']);
$choice = $io->askQuestion($question);
return $choice === 'yes';
}
private function submitDownload(SymfonyStyle $io, TmdbResult $mediaChoice, TorrentioResult $downloadOption, bool $downloadLocally = false): void
{
$io->writeln('> Adding download record');
$user = $this->userRepository->find(1);
$download = $this->downloadRepository->insert(
$user,
$downloadOption->url,
$downloadOption->title,
$downloadOption->filename,
$mediaChoice->imdbId,
$mediaChoice->mediaType,
);
$io->writeln('> Download record added: ' . $download->getId());
$downloadCommand = new DownloadMediaCommand(
$download->getUrl(),
$download->getTitle(),
$download->getFilename(),
$download->getMEdiaType(),
$download->getImdbId(),
$download->getUser()->getId(),
$download->getId()
);
if (true === $downloadLocally) {
$io->writeln('> Beginning local download');
$this->downloadMediaHandler->handle($downloadCommand);
} else {
$io->writeln('> Submitting download to queue');
$this->bus->dispatch($downloadCommand);
}
$io->writeln('> Download added to queue');
}
}

View File

@@ -6,7 +6,6 @@ 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;
@@ -29,24 +28,16 @@ final class WebController extends AbstractController
): Response {
$results = $this->searchHandler->handle($searchInput->toCommand());
if ($results instanceof RedirectToMediaResult) {
return $this->redirectToRoute('app_search_result', [
'mediaType' => $results->mediaType,
'imdbId' => $results->imdbId,
]);
}
return $this->render('search/results.html.twig', [
'results' => $results,
]);
}
#[Route('/result/{mediaType}/{imdbId}/{season}/{episode?}', name: 'app_search_result')]
#[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $input,
?int $season = null,
): Response
{
): Response {
$result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
@@ -61,4 +52,32 @@ final class WebController extends AbstractController
]
]);
}
private function warmDownloadOptionCache(TmdbResult $result)
{
if ($result->mediaType === 'tvshows') {
// dispatches a job to get the download options
// for each episode and load them in cache
foreach ($result->episodes as $season => $episodes) {
// Only do the first 2 seasons, so we reduce
// getting rate-limited by Torrentio
if ($season > 2) {
return;
}
foreach ($episodes as $episode) {
$this->bus->dispatch(new GetTvShowOptionsCommand(
tmdbId: $result->tmdbId,
imdbId: $result->imdbId,
season: $season,
episode: $episode['episode_number'],
));
}
}
} elseif ($result->mediaType === 'movies') {
$this->bus->dispatch(new GetMovieOptionsCommand(
$result->tmdbId,
$result->imdbId,
));
}
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Tmdb\Framework\Controller;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -18,28 +17,17 @@ class ApiController extends AbstractController
$results = [];
$term = $request->query->get('query') ?? null;
$term = trim($term);
if (null !== $term) {
if (ImdbMatcher::isMatch($term)) {
$tmdbResult = $tmdb->findByImdbId($term);
$results = [
[
'data' => $tmdbResult,
'text' => $tmdbResult->title,
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
]
$tmdbResults = $tmdb->search($term);
foreach ($tmdbResults as $tmdbResult) {
/** @var TmdbResult $tmdbResult */
$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",
];
}
}
}

View File

@@ -185,28 +185,6 @@ 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);
@@ -286,18 +264,13 @@ 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: $airDate,
year: (new \DateTime($data['first_air_date']))->format('Y'),
mediaType: "tvshows",
episodes: $data['episodes'],
);
@@ -305,11 +278,6 @@ 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'],
@@ -319,17 +287,12 @@ class Tmdb
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
episodeAirDate: $airDate,
episodeAirDate: (new \DateTime($data['air_date']))->format('m/d/Y'),
);
}
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'],
@@ -338,7 +301,6 @@ class Tmdb
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
episodeAirDate: $airDate,
);
}

View File

@@ -3,7 +3,6 @@
namespace App\Torrentio\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
@@ -28,9 +27,8 @@ 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),
file: $file,
season: $command->season,
episode: $command->episode,
results: $this->torrentio->fetchEpisodeResults(

View File

@@ -2,16 +2,15 @@
namespace App\Torrentio\Action\Result;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Finder\SplFileInfo;
class GetTvShowOptionsResult implements ResultInterface
{
public function __construct(
public TmdbResult $parentShow,
public TmdbResult $media,
public MediaFileDto|false $file,
public bool|SplFileInfo $file,
public string $season,
public string $episode,
public array $results

View File

@@ -9,15 +9,12 @@ use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Exception\TorrentioRateLimitException;
use Carbon\Carbon;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\UX\Turbo\TurboBundle;
final class WebController extends AbstractController
{
@@ -27,9 +24,8 @@ final class WebController extends AbstractController
private readonly Broadcaster $broadcaster,
) {}
#[Cache(expires: 3600, public: false, mustRevalidate: true)]
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache, Request $request): Response
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.movies.%s.%s",
@@ -37,22 +33,17 @@ final class WebController extends AbstractController
$input->imdbId
);
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getMovieOptionsHandler->handle($input->toCommand());
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
});
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
@@ -63,18 +54,13 @@ final class WebController extends AbstractController
);
try {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getTvShowOptionsHandler->handle($input->toCommand());
});
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
// });
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
@@ -87,16 +73,29 @@ final class WebController extends AbstractController
}
}
private function sendFragmentResponse(ResultInterface $result, Request $request): Response
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock(
'torrentio/fragments.html.twig',
$request->query->get('block'),
[
'results' => $result,
'target' => $request->query->get('target')
]
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
$cache->delete($cacheId);
$this->broadcaster->alert(
title: 'Success',
message: 'Torrentio cache Cleared.'
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Torrentio\Result;
use App\User\Database\CountryLanguages;
use App\Base\Util\PTN;
use Nihilarr\PTN;
class ResultFactory
{

View File

@@ -3,27 +3,16 @@
namespace App\Twig\Components;
use Aimeos\Map;
use App\User\Database\CodecList;
use App\User\Database\QualityList;
use App\User\Database\ResolutionList;
use App\User\Dto\PreferenceOptions;
use App\User\Dto\PreferenceOptionsFactory;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Form\UserMediaPreferencesForm;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsLiveComponent]
final class Filter extends AbstractController
final class Filter
{
use DefaultActionTrait;
use ComponentWithFormTrait;
public array $preferences = [];
@@ -32,25 +21,15 @@ final class Filter extends AbstractController
public array $reverseMappedQualities = [];
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly Security $security,
) {
$this->preferences = (array) PreferenceOptionsFactory::createSelectOptions();
$this->userPreferences = (array) UserPreferencesFactory::createFromUser($security->getUser());
$this->preferences = Map::from($this->preferencesRepository->findEnabled())
->rekey(fn($element) => $element->getId())
->map(fn($element) => $element->getPreferenceOptions()->toArray())
->toArray();
$this->userPreferences = Map::from($this->security->getUser()->getUserPreferenceValues())
->toArray();
$this->reverseMappedQualities = QualityList::getAsReverseMap();
}
public function getResolutionOptions()
{
return ResolutionList::asSelectOptions();
}
public function getCodecOptions()
{
return CodecList::asSelectOptions();
}
protected function instantiateForm(): FormInterface
{
return $this->createForm(UserMediaPreferencesForm::class, UserPreferencesFactory::createFromUser($this->getUser()));
}
}

View File

@@ -6,13 +6,11 @@ use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\TvEpisodePaginator;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class TvEpisodeList
final class TvEpisodeList
{
use DefaultActionTrait;
use PaginateTrait;
@@ -29,12 +27,6 @@ final class TvEpisodeList
#[LiveProp(writable: true)]
public int $season = 1;
#[LiveProp(writable: true)]
public int $reloadCount = 0;
#[LiveProp(writable: true)]
public ?int $episodeNumber = null;
public function __construct(
private GetMediaInfoHandler $getMediaInfoHandler,
) {}
@@ -42,14 +34,6 @@ final class TvEpisodeList
public function getEpisodes()
{
$results = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand($this->imdbId, "tvshows", $this->season));
if (null !== $this->episodeNumber) {
$this->pageNumber = ceil($this->episodeNumber / $this->perPage);
$this->episodeNumber = null;
}
$this->reloadCount++;
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Twig\Dto;
class EpisodeIdDto
{
public function __construct(
public string $season,
public string $episode,
) {}
public function asEpisodeId(): string
{
return "S". str_pad($this->season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($this->episode, 2, "0", STR_PAD_LEFT);
}
public function __toString(): string
{
if ("" !== $this->season && "" !== $this->episode) {
return $this->asEpisodeId();
}
return "";
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Twig\Extensions;
use App\Base\Service\MediaFiles;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Twig\Dto\EpisodeIdDto;
use ChrisUllyott\FileSize;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
@@ -48,7 +47,7 @@ class UtilExtension
}
#[AsTwigFilter('episode_id_from_results')]
public function episodeIdFromResults($result): ?string
public function episodeId($result): ?string
{
if (!$result instanceof GetTvShowOptionsResult) {
return null;
@@ -57,49 +56,4 @@ class UtilExtension
return "S". str_pad($result->season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($result->episode, 2, "0", STR_PAD_LEFT);
}
#[AsTwigFunction('episode_id')]
public function episodeId($season, $episode): ?string
{
return "S". str_pad($season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($episode, 2, "0", STR_PAD_LEFT);
}
#[AsTwigFunction('episode_anchor')]
public function episodeAnchor($season, $episode): ?string
{
return "episode_" . (int) $season . "_" . (int) $episode;
}
#[AsTwigFunction('extract_from_episode_id')]
public function extractFromEpisodeId(?string $episodeId): ?EpisodeIdDto
{
if (null === $episodeId) {
return new EpisodeIdDto("", "");
}
// Capture season
$seasonMatch = [];
preg_match('/[sS]\d\d(\d)?(\d)?/', $episodeId, $seasonMatch);
if (empty($seasonMatch)) {
$season = "";
} else {
$season = str_replace(['S', 's'], '', $seasonMatch[0]);
}
// Capture episode
$episodeMatch = [];
preg_match('/[eE]\d\d(\d)?(\d)?/', $episodeId, $episodeMatch);
if (empty($episodeMatch)) {
$episode = "";
} else {
$episode = str_replace(['E', 'e'], '', $episodeMatch[0]);
}
if (null === $season && null === $episode) {
return new EpisodeIdDto("", "");
}
return new EpisodeIdDto($season, $episode);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\User\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
use Symfony\Component\Form\FormInterface;
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesCommand implements CommandInterface
@@ -15,15 +14,4 @@ class SaveUserMediaPreferencesCommand implements CommandInterface
public string $language,
public string $provider,
) {}
public static function fromUserMediaPreferencesForm(FormInterface $form): self
{
return new static(
resolution: \implode(',', $form->get('resolution')->getData()),
codec: \implode(',', $form->get('codec')->getData()),
quality: \implode(',', $form->get('quality')->getData()),
language: \implode(',', $form->get('language')->getData()),
provider: \implode(',', $form->get('provider')->getData()),
);
}
}

Some files were not shown because too many files have changed in this diff Show More