Compare commits

..

45 Commits

Author SHA1 Message Date
08e376babc fix: download data preview modal style 2025-08-02 23:01:39 -05:00
2becc98d61 feat: download data preview modal 2025-08-02 22:46:21 -05:00
0430dba6a9 fix: bad PTN parsing causing monitors of episodes with 3 digit episodes to download multiple times 2025-08-01 21:34:03 -05:00
beed7d6940 fix: season/episode regex parser 2025-08-01 15:21:05 -05:00
924472ed56 fix: applies pre-filter to download options 2025-08-01 14:12:28 -05:00
7dd61355b7 fix: backend download option evaluator used by monitor service 2025-08-01 14:10:33 -05:00
2a1f69edd4 fix: multi-choice filter styles 2025-07-26 18:31:48 -05:00
9db0bfd4c6 fix: filter style tweaks, loading icon patch 2025-07-26 17:39:28 -05:00
18a165fc40 wip: filter uses form object 2025-07-26 15:46:43 -05:00
0e13b74b3b fix: multi-choice filter styles 2025-07-26 14:16:12 -05:00
f9ec089f8b fix: working multi-choice filtering 2025-07-26 10:20:37 -05:00
87e72ec55e fix: adds ImdbMatcher 2025-07-25 21:35:29 -05:00
23a88ec6bb fix: search by imdb id 2025-07-24 23:31:03 -05:00
d33a961f2d fix: codec filter 2025-07-24 17:09:30 -05:00
566886ef0e feat: uses web components to simplify javascript logic 2025-07-24 17:05:19 -05:00
65acd5d21b wip: working 'download selected' button 2025-07-24 16:47:08 -05:00
a27fcf334a wip: download single episode 2025-07-24 16:24:21 -05:00
56c5156380 wip: working movies & tvshows w/ filtering 2025-07-24 15:52:42 -05:00
18b00fc5ae fix: performs filtering in web component 2025-07-24 11:54:28 -05:00
e39faa3398 wip: working episode-container 2025-07-24 11:16:22 -05:00
2a9bacea8c fix: removes preference options db table 2025-07-24 00:02:38 -05:00
0988517bd0 fix: separates preference options from db to files 2025-07-23 23:19:19 -05:00
d1ae26db45 fix: ajax form submit, alert render on mobile 2025-07-23 22:48:36 -05:00
75e9c1e8c3 fix: separates get/post routes 2025-07-23 21:37:31 -05:00
93f5b716b2 fix: creates form for user media preferences 2025-07-23 21:17:18 -05:00
8ff9cddbb0 fix: duplicate z-index 2025-07-23 09:44:51 -05:00
b0d7bfefd7 fix: animates mobile menu transition 2025-07-23 09:40:05 -05:00
df4bb3b736 fix: progress bar alignment 2025-07-23 08:36:57 -05:00
265d782f99 fix: deep links from monitors 2025-07-22 23:01:52 -05:00
dc9242d96e fix: links to episodes from downloads 2025-07-22 22:49:07 -05:00
24355a4b30 fix: supports linking directly to tv season 2025-07-20 10:26:12 -05:00
3b0ba81ce3 fix: bad type checking in media file dto 2025-07-17 22:17:55 -05:00
dc2845d74d fix: bad caching causing turbo frames not to reload properly 2025-07-16 20:11:30 -05:00
5e722dcbc7 fix: deletes media file when download deleted 2025-07-15 23:53:19 -05:00
a126871af8 fix: allows local or queued downloads 2025-07-15 10:29:32 -05:00
70f551cea9 feat: command to download media 2025-07-15 10:17:42 -05:00
4824c2d344 fix: adds pull-to-refresh to pwa 2025-07-14 21:11:57 -05:00
c09c7ad030 chore: remove unused code 2025-07-14 20:55:47 -05:00
f610297294 fix: turbo frame for tvshow results 2025-07-14 20:54:12 -05:00
f2971eee9c wip: returns movie results in turbo frames 2025-07-14 16:20:36 -05:00
47108af1f8 fix: pre-loads media postes 2025-07-14 15:32:08 -05:00
f7163b5e00 fix: typo in download list season id 2025-07-13 22:42:39 -05:00
31e364d691 fix: renders media exists badge on movie results 2025-07-13 22:21:04 -05:00
b42981b2a1 chore: cleanup 2025-07-13 21:27:54 -05:00
accfa9c9bf fix: missing/exists badge on tvshows results 2025-07-13 21:13:42 -05:00
135 changed files with 4509 additions and 2065 deletions

8
.env
View File

@@ -51,11 +51,3 @@ OIDC_CLIENT_ID="Enter your OIDC client id"
OIDC_CLIENT_SECRET="Enter your OIDC client secret"
OIDC_BYPASS_FORM_LOGIN=false
###< drenso/symfony-oidc-bundle ###
SMTP_HOST=
SMTP_USER=
SMTP_PASS=
SMTP_PORT=
SMTP_FROM=
SMTP_FROM_NAME=""

3
.env.test Normal file
View File

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

6
.gitignore vendored
View File

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

View File

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

19
assets/bootstrap.js vendored
View File

@@ -1,10 +1,23 @@
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 MovieContainer from "./components/movie-container.js";
import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover'
import Dialog from '@stimulus-components/dialog'
import Dropdown from '@stimulus-components/dropdown'
import Popover from '@stimulus-components/popover';
import Dialog from '@stimulus-components/dialog';
import Dropdown from '@stimulus-components/dropdown';
import 'animate.css';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
app.register('popover', Popover);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);
customElements.define('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'});

View File

@@ -0,0 +1,110 @@
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);
}
}
previewContent() {
return `
<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

@@ -0,0 +1,187 @@
export default class DownloadOptionTr extends HTMLTableRowElement {
H264_CODECS = {
'h264': 'h264',
'h.264': 'h264',
'x264': 'h264',
}
H265_CODECS = {
'h265': 'h265',
'h.265': 'h265',
'x265': 'h265',
'hevc': 'h265',
}
#downloadBtnEl;
#selectEpisodeInputEl;
url;
size;
quality;
resolution;
codec;
seeders;
provider;
languages;
mediaType;
season;
episode;
filename;
imdbId;
episodeId;
mediaTitle;
constructor() {
super();
this.url = this.getAttribute('url');
this.size = this.getAttribute('size');
this.quality = this.getAttribute('quality');
this.resolution = this.getAttribute('resolution');
this.codec = this.getAttribute('codec');
this.seeders = this.getAttribute('seeders');
this.provider = this.getAttribute('provider');
this.filename = this.getAttribute('filename');
this.imdbId = this.getAttribute('imdb-id');
this.languages = JSON.parse(this.getAttribute('languages'));
this.mediaType = this.getAttribute('media-type');
this.mediaTitle = this.getAttribute('media-title');
this.season = this.getAttribute('season') ?? null;
this.episode = this.getAttribute('episode') ?? null;
this.episodeId = this.getAttribute('episode-id') ?? null;
this.#downloadBtnEl = this.querySelector('.download-btn');
this.#selectEpisodeInputEl = this.querySelector('input[type="checkbox"]');
this.#downloadBtnEl.addEventListener('click', () => this.download());
}
get isSelected() {
return this.#selectEpisodeInputEl.checked;
}
set isSelected(value) {
this.#selectEpisodeInputEl.checked = value;
}
filter({ detail: { activeFilter } }) {
const optionHeader = document.querySelector(`[data-option-id="${this.dataset['localId']}"]`)
let include = true;
this.classList.add('r-tablerow');
this.classList.remove('hidden');
optionHeader.classList.add('r-tablerow');
optionHeader.classList.remove('hidden');
this.querySelector('input[type="checkbox"]').checked = false;
if (!this.#validateResolutions(activeFilter.resolution)) {
include = false;
}
if (!this.#validateCodecs(activeFilter.codec)) {
include = false;
}
if (!this.#validateLanguages(activeFilter.language)) {
include = false;
}
if (!this.#validateQualities(activeFilter.quality)) {
include = false;
}
if (!this.#validateProviders(activeFilter.provider)) {
include = false;
}
if (false === include) {
this.classList.remove('r-tablerow');
this.classList.add('hidden');
optionHeader.classList.remove('r-tablerow');
optionHeader.classList.add('hidden');
}
return include;
}
download() {
fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
url: this.url,
title: this.mediaTitle,
filename: this.filename,
mediaType: this.mediaType,
imdbId: this.imdbId,
episodeId: this.episodeId
})
})
.then(res => res.json())
.then(json => {
console.log(json)
})
}
#validateResolutions(selectedOptions) {
return this.#validateIntersection(selectedOptions, this.resolution.trim().split(','));
}
#validateCodecs(selectedOptions) {
if (this.#validateIntersection(selectedOptions, Object.keys(this.H264_CODECS))) {
return this.#validateIntersection(
selectedOptions,
[...this.codec.trim().split(','), '', 'n/a']
);
}
if (this.#validateIntersection(selectedOptions, Object.keys(this.H265_CODECS))) {
return this.#validateIntersection(
selectedOptions,
[...this.codec.trim().split(','), '', 'n/a']
);
}
return false;
}
#validateQualities(selectedOptions) {
return this.#validateIntersection(selectedOptions, this.quality.trim().split(','));
}
#validateProviders(selectedOptions) {
return this.#validateIntersection(selectedOptions, this.provider.trim().split(','));
}
#validateLanguages(selectedOptions) {
return this.#validateIntersection(selectedOptions, this.languages);
}
#validateIntersection(selectedOptions, localOptions) {
if (selectedOptions === null || selectedOptions === undefined) {
return true;
}
if (typeof selectedOptions === 'string' || selectedOptions instanceof String) {
selectedOptions = [selectedOptions];
}
if (selectedOptions.length === 0 ||
(selectedOptions.length === 1 && selectedOptions[0] === "") ||
(selectedOptions.length === 1 && selectedOptions[0] === "n/a")
) {
return true;
}
return this.#doesIntersect(localOptions, selectedOptions);
}
#doesIntersect(a, b) {
if (a.length === 0 || b.length === 0) {
return false;
}
return this.#intersect(a, b).length > 0;
}
#intersect(a, b) {
return a.filter(Set.prototype.has, new Set(b));
}
}

View File

@@ -0,0 +1,76 @@
export default class EpisodeContainer extends HTMLElement {
options = [];
showTitle;
#episodeSelectorEl;
#resultsToggleBtnEl;
#resultsTableEl;
#resultsCountBadgeEl;
#resultsCountNumberEl;
constructor() {
super();
this.showTitle = this.getAttribute('show-title');
this.#resultsTableEl = this.querySelector('.results-container');
this.#resultsToggleBtnEl = this.querySelector('.dropdown-button');
this.#resultsCountBadgeEl = this.querySelector('.results-count-badge');
this.#resultsCountNumberEl = this.querySelector('.results-count-number');
this.#episodeSelectorEl = this.querySelector('.episode-selector');
this.#resultsToggleBtnEl.addEventListener('click', () => this.toggleResults());
this.#resultsCountBadgeEl.addEventListener('click', () => this.toggleResults());
document.addEventListener('filterDownloadOptions', this.filter.bind(this));
document.addEventListener('downloadSelectedEpisodes', this.downloadSelectedResults.bind(this));
document.addEventListener('selectEpisodeForDownload', (e) => this.selectEpisodeForDownload(e.detail.select));
}
toggleResults() {
this.#resultsToggleBtnEl.classList.toggle('rotate-90');
this.#resultsToggleBtnEl.classList.toggle('-rotate-90');
this.#resultsTableEl.classList.toggle('hidden');
}
selectEpisodeForDownload(select) {
if (this.#episodeSelectorEl.disabled === false) {
this.#episodeSelectorEl.checked = select;
}
}
downloadSelectedResults() {
if (this.#episodeSelectorEl.disabled === false &&
this.#episodeSelectorEl.checked === true
) {
console.log('episode is selected')
this.options.forEach(option => {
if (option.isSelected === true) {
option.download();
}
option.isSelected = false;
})
}
}
filter({ detail: { activeFilter } }) {
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const include = option.filter({ detail: { activeFilter: activeFilter } });
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
this.#resultsCountNumberEl.innerText = count;
}
}

View File

@@ -0,0 +1,36 @@
export default class MovieContainer extends HTMLElement {
#resultsTableEl;
#resultsCountNumberEl;
constructor() {
super();
this.#resultsTableEl = this.querySelector('.results-container');
this.#resultsCountNumberEl = document.querySelector('.results-count-number');
document.addEventListener('filterDownloadOptions', this.filter.bind(this));
}
filter({ detail: { activeFilter } }) {
const options = this.querySelectorAll('tr.download-option');
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
options.forEach((option) => {
const include = option.filter({ detail: { activeFilter: activeFilter } });
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
this.#resultsCountNumberEl.innerText = count;
}
}

View File

@@ -0,0 +1,35 @@
export default class PreviewContentDialog extends HTMLDialogElement {
#headingEl;
#contentEl;
#closeBtnEl;
constructor() {
super();
this.#headingEl = this.querySelector('.modal-heading');
this.#contentEl = this.querySelector('.modal-content');
this.#closeBtnEl = this.querySelector('.modal-close');
this.setHeading = this.setHeading.bind(this);
this.setContent = this.setContent.bind(this);
document.addEventListener('showPreviewContentModal', (event) => {
this.display(event.detail);
});
document.addEventListener('hidePreviewContentModal', (e) => this.close());
this.#closeBtnEl.addEventListener('click', () => this.close());
}
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']
static targets = ['download', 'deleteFileInput']
async initialize() {
this.component = await getComponent(this.element);
@@ -20,13 +20,46 @@ export default class extends Controller {
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
// this.element.addEventListener('click', (event) => {
// let previewContentModal = document.querySelector('#previewContentModal');
// // previewContentModal.setHeading(event.target.dataset['title']);
// // previewContentModal.setContent('<p>Testing this here thingy-ma-bob!</p>');
// // previewContentModal.showModal();
// let content, heading = ""
// if (event.target.tagName !== "TR") {
// content = event.target.parentElement.previewContent();
// heading = event.target.parentElement.mediaTitle;
// } else {
// content = event.target.previewContent();
// heading = event.target.mediaTitle;
// }
//
// document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
// })
}
downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > 5) {
target.classList.add('hidden');
}
console.log(target)
downloads.forEach(download => {
console.log(download)
download.mediaTitle = download.getAttribute('media-title');
download.addEventListener('click', (event) => {
// let previewContentModal = document.querySelector('#previewContentModal');
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}}))
})
})
}
pauseDownload(data) {
@@ -42,7 +75,8 @@ export default class extends Controller {
}
deleteDownload(data) {
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
const deleteFileInput = document.querySelector(`#delete_file_${data.params.id}`)
fetch(`/api/download/${data.params.id}?deleteFile=${deleteFileInput.checked}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}

View File

@@ -14,7 +14,22 @@ export default class extends Controller {
static targets = ['icon']
connect() {
this.element.hideIcon = this.hideIcon.bind(this);
this.element.showIcon = this.showIcon.bind(this);
this.element.toggleIcon = this.toggleIcon.bind(this);
this.element.isVisibile = this.isVisible.bind(this);
}
isVisible() {
return !this.iconTarget.classList.contains('hidden');
}
showIcon() {
this.iconTarget.classList.remove('hidden');
}
hideIcon() {
this.iconTarget.classList.add('hidden');
}
toggleIcon() {
@@ -26,6 +41,8 @@ export default class extends Controller {
if (this.countValue === this.totalValue) {
this.toggleIcon();
this.countValue = 0;
console.log('filtering')
document.getElementById('filter').filterResults();
}
}
}

View File

@@ -6,9 +6,6 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = {
title: String,
tmdbId: String,
@@ -20,87 +17,18 @@ export default class extends Controller {
options = []
optionsLoaded = false
resultCountEl = null
async connect() {
await this.setOptions();
this.resultCountEl = document.querySelector('#movie_results_count');
}
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;
}
});
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}}));
}
}

View File

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

View File

@@ -6,24 +6,23 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
languages = []
providers = []
qualities = []
seasons = []
activeFilter = {
"resolution": "",
"codec": "",
"language": "",
"provider": "",
"quality": "",
"resolution": [],
"codec": [],
"language": [],
"provider": [],
"quality": [],
}
static outlets = ['movie-results', 'tv-results', 'tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
defaultOptions = '<option value="-">-</option>';
static outlets = ['tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'loadingIcon', 'selectAll', 'downloadSelected']
static values = {
'imdbId': String,
'media-type': String,
@@ -32,133 +31,77 @@ export default class extends Controller {
}
async connect() {
await this.setInitialFilter();
this.setTimerToStopLoadingIcon();
this.element.filterResults = this.filter.bind(this);
document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
}
async setInitialFilter() {
const response = await fetch('/api/user/filters');
const filters = await response.json();
if (filters.length > 0) {
this.activeFilter = filters[0];
}
if (this.mediaTypeValue === "tvshows") {
this.activeFilter['season'] = 1;
}
await this.filter();
}
setTimerToStopLoadingIcon() {
setTimeout(() => this.loadingIconTarget.hideIcon(), 10000);
}
// Event is fired from movies/tvshows controllers to populate this data
async loadOptions({detail: { options }}) {
await options.forEach((option) => {
this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset);
this.addQualities(option, option.dataset);
option.filter({detail: {activeFilter: this.activeFilter }});
})
await this.filter();
}
addLanguages(option, props) {
const languages = Object.assign([], JSON.parse(props['languages']));
languages.forEach((language) => {
if (!this.languages.includes(language)) {
this.languages.push(language);
selectAllEpisodes() {
document.dispatchEvent(new CustomEvent('selectEpisodeForDownload', {
detail: {
select: this.selectAllTarget.checked,
}
});
const preferred = this.languageTarget.dataset.preferred;
if (preferred) {
this.languageTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.languageTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.languageTarget.innerHTML = '<option value="">n/a</option>';
}
this.languageTarget.innerHTML += this.languages.sort()
.map((language) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === language) {
return;
}
return '<option value="'+language+'">'+language+'</option>';
})
.join();
}));
}
addProviders(option, props) {
if (!this.providers.includes(props['provider'])) {
this.providers.push(props['provider']);
}
const preferred = this.providerTarget.dataset.preferred;
if (preferred) {
this.providerTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.providerTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.providerTarget.innerHTML = '<option value="">n/a</option>';
}
this.providerTarget.innerHTML += this.providers.sort()
.map((provider) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === provider) {
return;
}
return '<option value="' + provider + '">' + provider + '</option>'
})
.join();
downloadSelectedEpisodes() {
document.dispatchEvent(new CustomEvent('downloadSelectedEpisodes', {}));
}
addQualities(option, props) {
if (!this.qualities.includes(props['quality'])) {
if (props['quality'].toLowerCase() in this.reverseMappedQualitiesValue) {
this.qualities.push(props['quality']);
}
}
const preferred = this.qualityTarget.dataset.preferred;
if (preferred) {
this.qualityTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.qualityTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.qualityTarget.innerHTML = '<option value="">n/a</option>';
}
this.qualityTarget.innerHTML += this.qualities.sort()
.map((quality) => {
const preferred = this.qualityTarget.dataset.preferred;
if (preferred === quality) {
return;
}
return '<option value="' + quality + '">' + quality + '</option>'
})
.join();
}
async filter() {
filter() {
const downloadSeasonSpan = document.querySelector("#downloadSeasonModal");
const currentSeason = this.activeFilter['season'];
let results = [];
this.activeFilter = {
"resolution": this.resolutionTarget.value,
"codec": this.codecTarget.value,
"language": this.languageTarget.value,
"provider": this.providerTarget.value,
"quality": this.qualityTarget.value,
"resolution": this.#fetchValuesFromNodeList(this.resolutionTarget.selectedOptions),
"codec": this.#fetchValuesFromNodeList(this.codecTarget.selectedOptions),
"language": this.#fetchValuesFromNodeList(this.languageTarget.selectedOptions),
"provider": this.#fetchValuesFromNodeList(this.providerTarget.selectedOptions),
"quality": this.#fetchValuesFromNodeList(this.qualityTarget.selectedOptions),
}
if ("movies" === this.mediaTypeValue) {
results = this.movieResultsOutlets;
await results.forEach((list) => list.filter(this.activeFilter));
} else if ("tvshows" === this.mediaTypeValue) {
results = this.tvResultsOutlets;
if ("tvshows" === this.mediaTypeValue) {
downloadSeasonSpan.innerText = this.seasonTarget.value;
this.activeFilter.season = this.seasonTarget.value;
downloadSeasonSpan.innerText = this.activeFilter.season;
await results.forEach((list) => list.filter(this.activeFilter, currentSeason, this.seasonTarget.value));
}
const event = new CustomEvent('filterDownloadOptions', {
detail: {
activeFilter: this.activeFilter
}
})
// Event is picked up by the episode-container
// or movie-container web components
document.dispatchEvent(event);
}
setSeason(event) {
this.tvEpisodeListOutlet.setSeason(event.target.value);
}
uncheckSelectAllBtn() {
this.selectAllTarget.checked = false;
}
downloadSeason() {
fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, {
headers: {
@@ -167,20 +110,15 @@ export default class extends Controller {
})
}
selectAllEpisodes() {
this.tvResultsOutlets.forEach((episode) => {
if (episode.isActive()) {
episode.selectEpisodeForDownload()
}
});
#fetchValuesFromNodeList(nodeList) {
return [...nodeList].map(option => option.value)
}
downloadSelectedEpisodes() {
this.tvResultsOutlets.forEach(episode => {
if (episode.isActive() && episode.isSelected()) {
episode.download();
}
});
this.selectAllTarget.checked = false;
#serializeSelectOptions(options) {
return this.defaultOptions + options.sort()
.map((option) => {
return '<option value="' + option + '">' + option + '</option>'
})
.join();
}
}

View File

@@ -12,6 +12,11 @@ export default class extends Controller {
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
window.location.href = `/search?term=${autocompleteController.tomSelect.lastValue}`
}
document.querySelector("#search-button").addEventListener('click', (event) => {
event.preventDefault();
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
window.location.href = `/search?term=${autocompleteController.tomSelect.lastQuery}`
});
this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect);
this.element.addEventListener('autocomplete:connect', this._onConnect);
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="currentColor" d="m0 11l2-2l5 5L18 3l2 2L7 18z"/></svg>

After

Width:  |  Height:  |  Size: 127 B

View File

@@ -65,7 +65,17 @@ dialog[data-dialog-target="dialog"][closing] {
}
.text-input {
@apply bg-gray-50 text-gray-50 p-1 bg-transparent border-b-2 border-orange-400
@apply bg-gray-50 text-gray-50 px-2 py-1 bg-transparent border-b-2 border-orange-400
}
.text-input[multiple="multiple"] {
@apply bg-transparent backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
}
.text-input option[checked="checked"],
.text-input option[checked],
.text-input option[selected] {
@apply bg-orange-500/60
}
.submit-button {
@@ -130,3 +140,52 @@ dialog[data-dialog-target="dialog"][closing] {
background: unset;
@apply bg-orange-500/80 text-black font-bold rounded-md
}
.progress {
display: grid;
grid-template: 1fr / 1fr;
place-items: center;
}
.progress > * {
grid-column: 1 / 1;
grid-row: 1 / 1;
}
.progress .background {
z-index: 1;
place-self: start;
}
.progress .number {
z-index: 2;
background: transparent;
}
#filter {
.ts-wrapper {
box-shadow: none !important;
padding: 0;
.ts-control {
border: none !important;
box-shadow: none !important;
@apply bg-orange-500/60 backdrop-filter backdrop-blur-md;
}
.item[data-ts-item] {
background-image: none !important;
border: none;
box-shadow: none;
text-shadow: none;
@apply bg-orange-500 rounded-ms font-bold text-black;
}
@apply border border-orange-500 bg-transparent rounded-ms;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
@apply border-l border-l-orange-600 !important;
}
}
.filter-label {
@apply flex flex-col gap-1 justify-between;
}

23
bin/phpunit Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

View File

@@ -43,7 +43,6 @@
"symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.3.*",
"symfony/monolog-bundle": "^3.10",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
@@ -105,6 +104,7 @@
"post-update-cmd": [
"@auto-scripts"
],
"tail": "docker compose exec app ./bin/console tailwind:build --watch",
"sym": "docker compose exec app ./bin/console"
},
"conflict": {
@@ -118,6 +118,7 @@
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^12.3",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"

1826
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,5 +23,4 @@ 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

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

@@ -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,6 +6,14 @@ 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,7 +6,6 @@
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
@@ -33,7 +32,6 @@ parameters:
app.cache.redis.host.default: 'redis://redis'
# Various configs
app.default.env: 'prod'
app.default.version: '0.dev'
app.default.timezone: 'America/Chicago'

View File

@@ -64,4 +64,7 @@ return [
'version' => '2.4.3',
'type' => 'css',
],
'pulltorefreshjs' => [
'version' => '0.1.22',
],
];

View File

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

44
phpunit.dist.xml Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
</extensions>
</phpunit>

View File

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

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

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

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

@@ -1,113 +0,0 @@
<?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,60 +2,58 @@
namespace App\Base;
use App\Base\Config\AppConfig;
use App\Base\Config\AuthConfig;
use App\Base\Config\SmtpConfig;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class ConfigResolver
{
private array $messages = [];
public function __construct(
private readonly LoggerInterface $logger,
private readonly TagAwareCacheInterface $cache,
private readonly AuthConfig $authConfig,
private readonly SmtpConfig $smtpConfig,
private readonly AppConfig $appConfig,
#[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,
) {}
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 (false === $this->appConfig->isValid()) {
$this->messages += $this->appConfig->getMessages();
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.";
$valid = false;
}
if (false === $this->authConfig->isValid()) {
$this->messages += $this->authConfig->getMessages();
if (null === $this->tmdbApiKey || "" === $this->tmdbApiKey) {
$this->messages[] = "Your TMDB API key is missing. Please set it to the 'TMDB_API' environment variable.";
$valid = false;
}
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);
}
}
return $valid;
}
@@ -64,18 +62,34 @@ final class ConfigResolver
return $this->messages;
}
public function getAuthConfig(): AuthConfig
{
return $this->authConfig;
}
public function authIs(string $method): bool
{
return $this->authConfig->isMethod($method);
if (strtolower($method) === strtolower($this->getAuthMethod())) {
return true;
}
return false;
}
public function getAuthMethod(): string
{
return $this->authConfig->getAuthMethod();
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,
]
];
}
}

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Base\Framework\Controller;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\Tmdb;
use App\User\Framework\Entity\User;
@@ -48,4 +49,13 @@ final class IndexController extends AbstractController
'message' => 'Email sent!'
]);
}
#[Route('/test')]
public function monitorTvShow(): Response
{
$this->monitorTvShowHandler->handle(new MonitorTvShowCommand(96));
return $this->json([
'Success' => 'Monitor added'
]);
}
}

View File

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

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

View File

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

246
src/Base/Util/PTN.php Normal file
View File

@@ -0,0 +1,246 @@
<?php
/**
* Parse Torrent Name (PTN)
*
* PHP port of parse-torrent-name written in Python.
*
* Javascript version by jzjzjzj
* https://github.com/jzjzjzj/parse-torrent-name
*
* Python version by divijbindlish
* https://github.com/divijbindlish/parse-torrent-name
*
* Copyright (c) 2014 - 2018, British Columbia Institute of Technology
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* @package PTN
* @author Drew Smith
* @copyright copyright (c) 2018, Nihilarr (https://www.nihilarr.com)
* @license http://opensource.org/licenses/MIT MIT License
* @link https://gitlab.com/nihilarr/parse-torrent-name
* @version 0.0.1
*/
namespace App\Base\Util;
class PTN {
public $torrent;
public $excess_raw;
public $group_raw;
public $start;
public $end;
public $title_raw;
public $parts;
public $patterns = array(
array('season' => '(s?([0-9]{1,3}))[ex]'),
array('episode' => '([ex]([0-9]{1,3})(?:[^0-9]|$))'),
array('year' => '([\[\(]?((?:19[0-9]|20[01])[0-9])[\]\)]?)'),
array('resolution' => '([0-9]{3,4}p)'),
array('quality' => '((?:PPV\.)?[HP]DTV|(?:HD)?CAM|B[DR]Rip|(?:HD-?)?TS|(?:PPV )?WEB-?DL(?: DVDRip)?|HDRip|DVDRip|DVDRIP|CamRip|W[EB]BRip|BluRay|DvDScr|hdtv|telesync)'),
array('codec' => '(xvid|[hx]\.?26[45])'),
array('audio' => '(MP3|DD5\.?1|Dual[\- ]Audio|LiNE|DTS|AAC[.-]LC|AAC(?:\.?2\.0)?|AC3(?:\.5\.1)?)'),
array('group' => '(- ?([^-]+(?:-={[^-]+-?$)?))$'),
array('region' => 'R[0-9]'),
array('extended' => '(EXTENDED(:?.CUT)?)'),
array('hardcoded' => 'HC'),
array('proper' => 'PROPER'),
array('repack' => 'REPACK'),
array('container' => '(MKV|AVI|MP4)'),
array('widescreen' => 'WS'),
array('website' => '^(\[ ?([^\]]+?) ?\])'),
array('language' => '(rus\.eng|ita\.eng)'),
array('sbs' => '(?:Half-)?SBS'),
array('unrated' => 'UNRATED'),
array('size' => '(\d+(?:\.\d+)?(?:GB|MB))'),
array('3d' => '3D')
);
public $types = array(
'season' => 'integer',
'episode' => 'integer',
'year' => 'integer',
'extended' => 'boolean',
'hardcoded' => 'boolean',
'proper' => 'boolean',
'repack' => 'boolean',
'widescreen' => 'boolean',
'unrated' => 'boolean',
'3d' => 'boolean'
);
public function __construct() {}
public function parse($name) {
$this->parts = array();
$this->torrent = array('name' => $name);
$this->excess_raw = $name;
$this->group_raw = '';
$this->start = 0;
$this->end = null;
$this->title_raw = null;
foreach($this->patterns as $patterns_single) {
foreach($patterns_single as $key => $pattern) {
if(!in_array($key, array('season', 'episode', 'website'))) {
$pattern = "\b{$pattern}\b";
}
$clean_name = str_replace('_', ' ', $this->torrent['name']);
if(preg_match("/{$pattern}/i", $clean_name, $match) == 0) break;
$index = array();
if(is_array($match)) {
array_shift($match);
}
if(sizeof($match) == 0) break;
if(sizeof($match) > 1) {
$index['raw'] = 0;
$index['clean'] = 1;
}
else {
$index['raw'] = 0;
$index['clean'] = 0;
}
if(isset($this->types[$key]) && $this->types[$key] == 'boolean') {
$clean = true;
}
else {
$clean = $match[$index['clean']];
if(isset($this->types[$key]) && $this->types[$key] == 'integer') {
$clean = (int)$clean;
}
}
if($key == 'group') {
if((isset($this->patterns[5][1]) && preg_match_all("/{$this->patterns[5][1]}/i", $clean)) ||
(isset($this->patterns[4][1]) && preg_match_all("/{$this->patterns[4][1]}/", $clean))) {
break;
}
if(preg_match('/[^ ]+ [^ ]+ .+/', $clean)) {
$key = 'episodeName';
}
}
if($key == 'episode') {
$sub_pattern = $this->escape_regex($match[$index['raw']]);
$this->torrent['map'] = preg_replace("/{$sub_pattern}/", '{episode}', $this->torrent['name']);
}
$this->part($key, $match, $match[$index['raw']], $clean);
}
}
$raw = $this->torrent['name'];
if(!is_null($this->end)) {
$raw = explode('(', substr($raw, $this->start, $this->end - $this->start));
$raw = $raw[0];
}
$clean = preg_replace("/^ -/", '', $raw);
if(strpos($clean, ' ') === false && strpos($clean, '.') !== false) {
$clean = str_replace('.', ' ', $clean);
}
$clean = str_replace('_', ' ', $clean);
$clean = trim(preg_replace("/([\[\(_]|- )$/", '', $clean));
$this->part('title', array(), $raw, $clean);
$clean = preg_replace("/(^[-\. ()]+)|([-\. ]+$)/", '', $this->excess_raw);
$clean = preg_replace("/[\(\)\/]/", ' ', $clean);
$match = preg_split("/\.\.+| +/", $clean);
if(sizeof($match) > 0 && is_array($match[0])) {
$match = $match[0];
}
$clean = $match;
$clean = array_filter($clean, function($var) {
return $var != '-' ? true : false;
});
$clean = array_filter($clean, function($var) {
return trim($var, '-');
});
$clean = array_values($clean);
if(sizeof($clean) > 0) {
$group_pattern = $clean[sizeof($clean) - 1] . $this->group_raw;
if(strpos($this->torrent['name'], $group_pattern) == strlen($this->torrent['name']) - strlen($group_pattern)) {
$this->late('group', array_pop($clean) . $this->group_raw);
}
if(isset($this->torrent['map']) && sizeof($clean) > 0) {
$episode_name_pattern = '{episode}' . preg_replace("/_+$/", '', $clean[0]);
if(strpos($this->torrent['map'], $episode_name_pattern) != -1) {
$this->late('episodeName', array_shift($clean));
}
}
}
if(sizeof($clean) != 0) {
if(sizeof($clean) == 1) {
$clean = $clean[0];
}
$this->part('excess', array(), $this->excess_raw, $clean);
}
return $this->parts;
}
private function escape_regex($subject) {
return preg_replace("/[\-\[\]{}()*+?.,\\\^$|#\s]/", "\\\\$&", $subject);
}
private function part($name, $match, $raw, $clean) {
# The main core instructuions
$this->parts[$name] = $clean;
if(sizeof($match) > 0) {
# The instructions for extracting title
$index = strpos($this->torrent['name'], $match[0]);
if($index == 0) {
$this->start = strlen($match[0]);
}
elseif(is_null($this->end) || $index < $this->end) {
$this->end = $index;
}
}
if($name != 'excess') {
if($name == 'group') {
$this->group_raw = $raw;
}
if(!is_null($raw)) {
$this->excess_raw = str_replace($raw, '', $this->excess_raw);
}
}
}
private function late($name, $clean) {
if($name == 'group') {
$this->part($name, array(), null, $clean);
}
elseif($name == 'episodeName') {
$clean = preg_replace("/[\._]/", ' ', $clean);
$clean = preg_replace("/_+$/", '', $clean);
$this->part($name, array(), null, trim($clean));
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -3,92 +3,80 @@
namespace App\Download;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\Torrentio\Result\TorrentioResult;
use App\User\Dto\UserPreferences;
class DownloadOptionEvaluator
{
/**
* @param Monitor $monitor
* @param TorrentioResult[] $results
* @param UserPreferences $filter
* @return TorrentioResult|null
* @throws \Throwable
*/
public function evaluateOptions(array $results, UserPreferences $userPreferences): ?TorrentioResult
public function evaluateOptions(array $results, UserPreferences $filter): ?TorrentioResult
{
$sizeLow = 000;
$sizeHigh = 4096;
$bestMatches = [];
$matches = [];
foreach ($results as $result) {
if (!in_array($userPreferences->language, $result->languages)) {
continue;
$matches = Map::from($results)->filter(function ($result) use ($filter) {
if (false === $this->validateFilterItems($result, $filter)) {
return false;
}
if ($result->resolution === $userPreferences->resolution
&& $result->codec === $userPreferences->codec
) {
$bestMatches[] = $result;
if (false === $this->validateSize($result, $filter)) {
return false;
}
if ($userPreferences->resolution === '2160p'
&& $userPreferences->codec === $result->codec
&& $result->resolution === '1080p'
) {
$matches[] = $result;
}
return true;
});
if ($userPreferences->codec === 'h264'
&& $userPreferences->resolution === $result->resolution
&& $result->codec === 'h265'
) {
$matches[] = $result;
}
if (($userPreferences->codec === null )
&& ($userPreferences->resolution === null )) {
$matches[] = $result;
}
}
$sizeMatches = [];
foreach ($bestMatches as $result) {
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
$sizeMatches[] = $result;
}
}
if (!empty($sizeMatches)) {
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}
foreach ($matches as $result) {
$size = 0;
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
$sizeMatches[] = $result;
}
}
if (!empty($sizeMatches)) {
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
if ($matches->count() > 0) {
return Map::from($matches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}
return null;
}
private function validateFilterItems(TorrentioResult $result, UserPreferences $filter): bool
{
if (array_intersect($filter->language, $result->languages) === []) {
return false;
}
$valid = true;
if (null !== $filter->resolution && !in_array($result->resolution, $filter->resolution)) {
$valid = false;
}
if (null !== $filter->codec && in_array($result->codec, $filter->codec)) {
$valid = false;
}
if (null !== $filter->quality && in_array($result->quality, $filter->quality)) {
$valid = false;
}
if (null !== $filter->provider && in_array($result->provider, $filter->provider)) {
$valid = false;
}
return $valid;
}
private function validateSize(TorrentioResult $result, UserPreferences $filter): bool
{
$sizeLow = 000;
$sizeHigh = 4096;
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
return true;
}
return false;
}
}

View File

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

View File

@@ -6,7 +6,8 @@ use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
@@ -44,6 +45,7 @@ class Download
#[ORM\Column(length: 255, nullable: true)]
private ?string $episodeId = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'downloads')]
private ?User $user = null;

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

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

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

@@ -5,6 +5,7 @@ namespace App\Monitor\Action\Handler;
use App\Base\Util\EpisodeId;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
@@ -15,6 +16,7 @@ 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;
@@ -27,6 +29,7 @@ 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,

View File

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

View File

@@ -13,27 +13,24 @@ use App\Tmdb\Tmdb;
use Carbon\Carbon;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
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');
@@ -50,6 +47,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
&& null !== $episode->season
)
;
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB

View File

@@ -9,7 +9,6 @@ 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;
@@ -17,14 +16,13 @@ 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();
@@ -36,19 +34,15 @@ 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,5 +11,6 @@ class GetMediaInfoCommand implements CommandInterface
public string $imdbId,
public string $mediaType,
public ?int $season = null,
public ?int $episode = null,
) {}
}

View File

@@ -2,7 +2,6 @@
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;
@@ -15,19 +14,12 @@ 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);
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);
return new GetMediaInfoResult($media, $command->season, $command->episode);
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Search\Action\Handler;
use App\Base\Util\ImdbMatcher;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Search\Action\Result\SearchResult;
use App\Tmdb\Tmdb;
use OneToMany\RichBundle\Contract\CommandInterface;
@@ -17,6 +19,13 @@ class SearchHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface
{
if (ImdbMatcher::isMatch($command->term)) {
$result = $this->tmdb->findByImdbId($command->term);
return new RedirectToMediaResult(
imdbId: $result->imdbId,
mediaType: $result->mediaType,
);
}
return new SearchResult(
term: $command->term,
results: $this->tmdb->search($command->term)

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
@@ -28,16 +29,24 @@ final class WebController extends AbstractController
): Response {
$results = $this->searchHandler->handle($searchInput->toCommand());
if ($results instanceof RedirectToMediaResult) {
return $this->redirectToRoute('app_search_result', [
'mediaType' => $results->mediaType,
'imdbId' => $results->imdbId,
]);
}
return $this->render('search/results.html.twig', [
'results' => $results,
]);
}
#[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')]
#[Route('/result/{mediaType}/{imdbId}/{season}/{episode?}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $input,
?int $season = null,
): Response {
): Response
{
$result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
@@ -52,32 +61,4 @@ 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,6 +2,7 @@
namespace App\Tmdb\Framework\Controller;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -17,17 +18,28 @@ class ApiController extends AbstractController
$results = [];
$term = $request->query->get('query') ?? null;
$term = trim($term);
if (null !== $term) {
$tmdbResults = $tmdb->search($term);
foreach ($tmdbResults as $tmdbResult) {
/** @var TmdbResult $tmdbResult */
$results[] = [
'data' => $tmdbResult,
'text' => $tmdbResult->title,
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
if (ImdbMatcher::isMatch($term)) {
$tmdbResult = $tmdb->findByImdbId($term);
$results = [
[
'data' => $tmdbResult,
'text' => $tmdbResult->title,
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
]
];
} else {
$tmdbResults = $tmdb->search($term);
foreach ($tmdbResults as $tmdbResult) {
/** @var TmdbResult $tmdbResult */
$results[] = [
'data' => $tmdbResult,
'text' => $tmdbResult->title,
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
];
}
}
}

View File

@@ -185,6 +185,28 @@ class Tmdb
throw new \Exception("No results found for $id");
}
public function findByImdbId(string $imdbId)
{
$finder = new Find($this->client);
$result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);
if (count($result['movie_results']) > 0) {
$result = $result['movie_results'][0];
$mediaType = MediaType::Movie->value;
} elseif (count($result['tv_results']) > 0) {
$result = $result['tv_results'][0];
$mediaType = MediaType::TvShow->value;
} elseif (count($result['tv_episode_results']) > 0) {
$result = $result['tv_episode_results'][0];
$mediaType = MediaType::TvShow->value;
}
$result['media_type'] = $mediaType;
$result = $this->mediaDetails($imdbId, $result['media_type']);
return $result;
}
public function movieDetails(string $id)
{
$client = new MovieRepository($this->client);
@@ -301,6 +323,7 @@ class Tmdb
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
episodeAirDate: (new \DateTime($data['release_date']))->format('m/d/Y'),
);
}

View File

@@ -3,6 +3,7 @@
namespace App\Torrentio\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
@@ -27,8 +28,9 @@ class GetTvShowOptionsHandler implements HandlerInterface
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
return new GetTvShowOptionsResult(
parentShow: $parentShow,
media: $media,
file: $file,
file: MediaFileDto::fromSplFileInfo($file),
season: $command->season,
episode: $command->episode,
results: $this->torrentio->fetchEpisodeResults(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ namespace App\Twig\Extensions;
use App\Base\Service\MediaFiles;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Twig\Dto\EpisodeIdDto;
use ChrisUllyott\FileSize;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
@@ -47,7 +48,7 @@ class UtilExtension
}
#[AsTwigFilter('episode_id_from_results')]
public function episodeId($result): ?string
public function episodeIdFromResults($result): ?string
{
if (!$result instanceof GetTvShowOptionsResult) {
return null;
@@ -56,4 +57,49 @@ 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,6 +3,7 @@
namespace App\User\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
use Symfony\Component\Form\FormInterface;
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesCommand implements CommandInterface
@@ -14,4 +15,15 @@ class SaveUserMediaPreferencesCommand implements CommandInterface
public string $language,
public string $provider,
) {}
public static function fromUserMediaPreferencesForm(FormInterface $form): self
{
return new static(
resolution: \implode(',', $form->get('resolution')->getData()),
codec: \implode(',', $form->get('codec')->getData()),
quality: \implode(',', $form->get('quality')->getData()),
language: \implode(',', $form->get('language')->getData()),
provider: \implode(',', $form->get('provider')->getData()),
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\User\Database;
class CodecList
{
public static $codecs = [
'h264',
'h265/HEVC',
];
public static function getCodecs()
{
return self::$codecs;
}
public static function asSelectOptions(): array
{
return [
'h264' => 'h264',
'h265/HEVC' => 'h265',
];
}
}

View File

@@ -102,7 +102,7 @@ class QualityList
public static function asSelectOptions(): array
{
$result = [];
$result = ['n/a' => null];
foreach (array_keys(static::$qualities) as $quality) {
$result[$quality] = $quality;
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\User\Database;
class ResolutionList
{
public static $resolutions = [
'480p',
'720p',
'1080p',
'2160p',
];
public static function getResolutions()
{
return self::$resolutions;
}
public static function asSelectOptions(): array
{
$result = [];
foreach (static::$resolutions as $resolution) {
$result[$resolution] = $resolution;
}
return $result;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Dto;
class PreferenceOptions
{
public function __construct(
public readonly array $resolutions,
public readonly array $codecs,
public readonly array $languages,
public readonly array $providers,
public readonly array $qualities,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\User\Dto;
use App\User\Database\CodecList;
use App\User\Database\CountryLanguages;
use App\User\Database\ProviderList;
use App\User\Database\QualityList;
use App\User\Database\ResolutionList;
class PreferenceOptionsFactory
{
public static function createSelectOptions(): PreferenceOptions
{
return new PreferenceOptions(
resolutions: ResolutionList::asSelectOptions(),
codecs: CodecList::asSelectOptions(),
languages: CountryLanguages::asSelectOptions(),
providers: ProviderList::asSelectOptions(),
qualities: QualityList::asSelectOptions(),
);
}
}

View File

@@ -6,10 +6,10 @@ class UserPreferences
{
public function __construct(
public readonly ?string $resolution,
public readonly ?string $codec,
public readonly ?string $language,
public readonly ?string $provider,
public readonly ?string $quality,
public readonly ?array $resolution,
public readonly ?array $codec,
public readonly ?array $language,
public readonly ?array $provider,
public readonly ?array $quality,
) {}
}

View File

@@ -12,8 +12,8 @@ class UserPreferencesFactory
public static function createFromUser(UserInterface $user): UserPreferences
{
return new UserPreferences(
resolution: self::getNestedValue($user, 'resolution'),
codec: self::getNestedValue($user, 'codec'),
resolution: self::getValue($user, 'resolution'),
codec: self::getValue($user, 'codec'),
language: self::getValue($user, 'language'),
provider: self::getValue($user, 'provider'),
quality: self::getValue($user, 'quality'),
@@ -27,21 +27,7 @@ class UserPreferencesFactory
if ($value === "") {
return null;
}
$value = explode(',', $value);
return $value;
}
/** @param User $user */
private static function getNestedValue(UserInterface $user, string $preferenceId): ?string
{
$preference = $user->getUserPreference($preferenceId);
if (null === $preference) {
return null;
}
return $preference->getPreference()
->getPreferenceOptions()
->filter(fn (PreferenceOption $option) => (string) $option->getId() === $preference->getPreferenceValue())
->first()
->getValue()
;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\User\Framework\Controller\Api;
use App\User\Dto\UserPreferences;
use App\User\Dto\UserPreferencesFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/user/filters')]
class UserFilterApiController extends AbstractController
{
#[Route('', 'api.user.filters', methods: ['GET'])]
public function getFilters(): Response
{
return $this->json([
UserPreferencesFactory::createFromUser($this->getUser())
]);
}
}

View File

@@ -22,7 +22,7 @@ class LoginController extends AbstractController
return $this->redirectToRoute('app_getting_started');
}
if ($config->authIs('oidc') && true === $config->getAuthConfig()->getOidcConfig()->getBypassFormLogin()) {
if ($config->authIs('oidc') && $config->bypassFormLogin()) {
return $this->redirectToRoute('app_login_oidc');
}

View File

@@ -3,7 +3,6 @@
namespace App\User\Framework\Controller\Web;
use App\Base\ConfigResolver;
use App\Base\Service\Broadcaster;
use Drenso\OidcBundle\OidcClientInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
@@ -16,15 +15,13 @@ class LoginOidcController extends AbstractController
public function __construct(
private ConfigResolver $configResolver,
private Broadcaster $broadcaster,
) {}
#[Route('/login/oidc', name: 'app_login_oidc')]
public function oidcStart(OidcClientInterface $oidcClient): RedirectResponse
{
if (false === $this->configResolver->authIs('oidc')) {
$this->broadcaster->systemAlert('Your authentication must be set to "oidc" in order to login with OIDC.', 'warning');
return $this->redirectToRoute('app_login');
throw new \Exception('You must configure the OIDC environment variables before logging in at this route.');
}
// Redirect to authorization @ OIDC provider

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\User\Framework\Controller\Web;
use App\Base\Service\Broadcaster;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
@@ -14,8 +15,10 @@ use App\User\Database\ProviderList;
use App\User\Database\QualityList;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Form\GettingStartedFilterForm;
use App\User\Framework\Form\UserMediaPreferencesForm;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -29,53 +32,42 @@ class PreferencesController extends AbstractController
#[Route('/user/preferences', 'app_user_preferences', methods: ['GET'])]
public function mediaPreferences(): Response
{
$mediaPreferences = $this->getUser()->getMediaPreferences();
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$languages = CountryLanguages::$languages;
sort($languages);
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::getProviders(),
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
'filterForm' => $this->createForm(GettingStartedFilterForm::class, (array) UserPreferencesFactory::createFromUser($this->getUser())),
'preferences_form' => $form,
]
);
}
#[Route('/user/preferences/media', 'app_save_media_preferences', methods: ['POST'])]
public function saveMediaPreferences(
SaveUserMediaPreferencesInput $input,
SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
#[Route('/user/preferences/media', 'app_user_media_preferences_submit', methods: ['POST'])]
public function mediaPreferencesSubmit(
Request $request,
SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler
): Response
{
$saveUserMediaPreferencesHandler->handle($input->toCommand());
$mediaPreferences = $this->getUser()->getMediaPreferences();
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$form = $this->createForm(UserMediaPreferencesForm::class);
$languages = CountryLanguages::$languages;
sort($languages);
$form->handleRequest($request);
$this->broadcaster->alert(
title: 'Success',
message: 'Your media preferences have been saved.'
);
if ($form->isSubmitted() && $form->isValid()) {
$saveUserMediaPreferencesHandler->handle(
SaveUserMediaPreferencesCommand::fromUserMediaPreferencesForm($form)
);
$this->broadcaster->alert('Success', 'Your media preferences have been saved.');
}
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
'filterForm' => $this->createForm(GettingStartedFilterForm::class ?? null),
'preferences_form' => $form,
]
);
}
@@ -86,11 +78,11 @@ class PreferencesController extends AbstractController
SaveUserDownloadPreferencesHandler $saveUserDownloadPreferencesHandler,
): Response
{
$downloadPreferences = $saveUserDownloadPreferencesHandler->handle($input->toCommand())->downloadPreferences;
$mediaPreferences = $this->getUser()->getMediaPreferences();
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
$languages = CountryLanguages::$languages;
sort($languages);
$saveUserDownloadPreferencesHandler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
@@ -100,12 +92,8 @@ class PreferencesController extends AbstractController
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::getProviders(),
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
'preferences_form' => $form,
]
);
}

View File

@@ -26,17 +26,6 @@ class Preference
#[ORM\Column]
private ?bool $enabled = null;
/**
* @var Collection<int, PreferenceOption>
*/
#[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference', fetch: 'EAGER')]
private Collection $preferenceOptions;
public function __construct()
{
$this->preferenceOptions = new ArrayCollection();
}
public function getId(): ?string
{
return $this->id;
@@ -94,34 +83,4 @@ class Preference
return $this;
}
/**
* @return Collection<int, PreferenceOption>
*/
public function getPreferenceOptions(): Collection
{
return $this->preferenceOptions;
}
public function addPreferenceOption(PreferenceOption $preferenceOption): static
{
if (!$this->preferenceOptions->contains($preferenceOption)) {
$this->preferenceOptions->add($preferenceOption);
$preferenceOption->setPreference($this);
}
return $this;
}
public function removePreferenceOption(PreferenceOption $preferenceOption): static
{
if ($this->preferenceOptions->removeElement($preferenceOption)) {
// set the owning side to null (unless already changed)
if ($preferenceOption->getPreference() === $this) {
$preferenceOption->setPreference(null);
}
}
return $this;
}
}

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