Compare commits

..

7 Commits

39 changed files with 2508 additions and 26 deletions

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'

5
.gitignore vendored
View File

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

7
assets/bootstrap.js vendored
View File

@@ -1,5 +1,9 @@
import '@ungap/custom-elements'
import PreviewContentDialog from "./components/preview-content-dialog.js";
import EpisodeContainer from './components/episode-container.js';
import DownloadOptionTr from './components/download-option-tr.js';
import DownloadListRow from './components/download-list-row.js';
import MonitorListRow from './components/monitor-list-row.js';
import MovieContainer from "./components/movie-container.js";
import { startStimulusApp } from '@symfony/stimulus-bundle';
@@ -14,6 +18,9 @@ app.register('popover', Popover);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);
customElements.define('preview-content-dialog', PreviewContentDialog, {extends: 'dialog'});
customElements.define('episode-container', EpisodeContainer);
customElements.define('movie-container', MovieContainer);
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});
customElements.define('download-list-row', DownloadListRow, {extends: 'tr'});
customElements.define('monitor-list-row', MonitorListRow, {extends: 'tr'});

View File

@@ -0,0 +1,111 @@
export default class DownloadListRow extends HTMLTableRowElement {
constructor() {
super();
this.downloadId = this.getAttribute('download-id');
this.imdbId = this.getAttribute('imdb-id');
this.mediaTitle = this.getAttribute('media-title');
this.url = this.getAttribute('url');
this.filename = this.getAttribute('filename');
this.status = this.getAttribute('status');
this.progress = this.getAttribute('progress');
this.mediaType = this.getAttribute('media-type');
this.episodeId = this.getAttribute('episode-id');
this.createdAt = this.getAttribute('created-at');
this.updatedAt = this.getAttribute('updated-at');
// this.previewContent = this.previewContent.bind(this);
}
static get observedAttributes() {
return ['download-id', 'imdb-id', 'media-title', 'url', 'filename', 'status', 'progress', 'media-type', 'episode-id', 'created-at', 'updated-at'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this[name] = newValue;
this.setAttribute(name, newValue);
this.setPreviewContent();
}
}
setPreviewContent() {
this.previewContent = `
<table class="table-auto flex flex-row">
<thead>
<tr class="flex flex-col">
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">IMDB ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Title</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">URL</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Filename</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Status</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Progress</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Media Type</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Created At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Updated At</div>
</th>
</tr>
</thead>
<tbody>
<tr class="flex flex-col">
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('download-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('imdb-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-title') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('url') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('filename') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('status') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('progress') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-type') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('created-at') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('updated-at') ?? "-"}</div>
</td>
</tr>
</tbody>
</table>
`;
}
}

View File

@@ -0,0 +1,115 @@
export default class MonitorListRow extends HTMLTableRowElement {
constructor() {
super();
this.downloadId = this.getAttribute('monitor-id');
this.imdbId = this.getAttribute('imdb-id');
this.mediaTitle = this.getAttribute('media-title');
this.url = this.getAttribute('url');
this.filename = this.getAttribute('filename');
this.status = this.getAttribute('status');
this.progress = this.getAttribute('progress');
this.mediaType = this.getAttribute('media-type');
this.episodeId = this.getAttribute('episode-id');
this.createdAt = this.getAttribute('created-at');
this.updatedAt = this.getAttribute('updated-at');
}
static get observedAttributes() {
return ['download-id', 'imdb-id', 'media-title', 'url', 'filename', 'status', 'progress', 'media-type', 'episode-id', 'created-at', 'updated-at'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this[name] = newValue;
this.setAttribute(name, newValue);
this.setPreviewContent();
}
}
setPreviewContent() {
this.previewContent = `
<table class="table-auto flex flex-row">
<thead>
<tr class="flex flex-col">
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">IMDB ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Title</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Season</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Status</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Search Count</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Media Type</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Created At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Updated At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Downloaded At</div>
</th>
</tr>
</thead>
<tbody>
<tr class="flex flex-col">
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('monitor-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('imdb-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-title') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('season') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('status') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('search-count') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-type') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('created-at') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('last-search') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('downloaded-at') ?? "-"}</div>
</td>
</tr>
</tbody>
</table>
`;
}
}

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);
this.#closeBtnEl.addEventListener('click', () => this.close());
document.addEventListener('hidePreviewContentModal', () => this.close());
document.addEventListener('showPreviewContentModal', (event) => {
this.display(event.detail);
});
}
setHeading(heading) {
this.#headingEl.innerHTML = heading;
}
setContent(content) {
this.#contentEl.innerHTML = content;
}
display({ heading, content }) {
this.setHeading(heading);
this.setContent(content);
this.showModal();
}
}

View File

@@ -20,13 +20,43 @@ export default class extends Controller {
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
// this.element.addEventListener('click', (event) => {
// let previewContentModal = document.querySelector('#previewContentModal');
// // previewContentModal.setHeading(event.target.dataset['title']);
// // previewContentModal.setContent('<p>Testing this here thingy-ma-bob!</p>');
// // previewContentModal.showModal();
// let content, heading = ""
// if (event.target.tagName !== "TR") {
// content = event.target.parentElement.previewContent();
// heading = event.target.parentElement.mediaTitle;
// } else {
// content = event.target.previewContent();
// heading = event.target.mediaTitle;
// }
//
// document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
// })
}
downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > 5) {
target.classList.add('hidden');
}
downloads.forEach(download => {
download.addEventListener('click', (event) => {
let content, heading = ""
if (event.target.tagName !== "TR") {
content = event.target.parentElement.previewContent;
heading = "Download # " + event.target.parentElement.downloadId + " - \"" + event.target.parentElement.mediaTitle + "\"";
} else {
content = event.target.previewContent;
heading = "Download # " + event.target.downloadId + " - \"" + event.target.mediaTitle + "\"";
}
if (null !== content && undefined !== content && "" !== content) {
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
}
})
})
}
pauseDownload(data) {

View File

@@ -6,6 +6,29 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['monitorList']
monitorListTargetConnected(target) {
let monitors = this.element.querySelectorAll('tbody tr');
monitors.forEach(monitor => {
monitor.addEventListener('click', (event) => {
let content, heading = ""
if (event.target.tagName !== "TR") {
content = event.target.parentElement.previewContent;
heading = "Monitor for \"" + event.target.parentElement.mediaTitle+ "\"";
} else {
content = event.target.previewContent;
heading = "Monitor for \"" + event.target.mediaTitle + "\"";
}
if (null !== content && undefined !== content && "" !== content) {
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
}
})
})
}
deleteMonitor(data) {
fetch(`/api/monitor/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())

View File

@@ -55,6 +55,10 @@ dialog {
}
}
dialog[open] {
animation: fade-in 100ms ease-in forwards;
}
/* Add animations */
dialog[data-dialog-target="dialog"][open] {
animation: fade-in 200ms forwards;

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

@@ -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.*"

1564
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,4 +67,7 @@ return [
'pulltorefreshjs' => [
'version' => '0.1.22',
],
'@ungap/custom-elements' => [
'version' => '1.3.0',
],
];

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

@@ -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

@@ -4,7 +4,7 @@ namespace App\Base\Service;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;

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

@@ -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

@@ -6,7 +6,7 @@ use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\UX\Turbo\Attribute\Broadcast;

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

@@ -6,7 +6,7 @@ use App\Base\Service\MediaFiles;
use App\Library\Action\Command\LibrarySearchCommand;
use App\Library\Action\Result\LibrarySearchResult;
use App\Library\Dto\MediaFileDto;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;

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,7 +13,7 @@ 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;
@@ -47,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

@@ -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

@@ -5,6 +5,7 @@ namespace App\User\Database;
class CodecList
{
public static $codecs = [
'-',
'h264',
'h265/HEVC',
];
@@ -16,9 +17,13 @@ class CodecList
public static function asSelectOptions(): array
{
return [
'h264' => 'h264',
'h265/HEVC' => 'h265',
];
$result = [];
foreach (static::$codecs as $codec) {
$result[$codec] = $codec;
}
$result['h265/HEVC'] = 'h265';
return $result;
}
}

View File

@@ -26,7 +26,7 @@ class ProviderList
public static function asSelectOptions(): array
{
$result = [];
$result = ['-' => '-'];
foreach (static::$providers as $provider) {
$result[$provider] = $provider;
}

View File

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

View File

@@ -18,7 +18,7 @@ class ResolutionList
public static function asSelectOptions(): array
{
$result = [];
$result = ['-' => '-'];
foreach (static::$resolutions as $resolution) {
$result[$resolution] = $resolution;
}

View File

@@ -36,6 +36,8 @@ class PreferencesController extends AbstractController
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
// dd($form);
return $this->render(
'user/preferences.html.twig',
[

View File

@@ -86,6 +86,21 @@
"phpstan.dist.neon"
]
},
"phpunit/phpunit": {
"version": "12.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "c6658a60fc9d594805370eacdf542c3d6b5c0869"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"spomky-labs/pwa-bundle": {
"version": "1.2.5"
},

View File

@@ -30,5 +30,6 @@
{% block body %}{% endblock %}
</div>
</div>
<twig:PreviewModal id="previewModal" />
</body>
</html>

View File

@@ -31,7 +31,7 @@
</th>
</tr>
</thead>
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50">
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50" data-download-list-target="download">
{% if this.downloads.items|length > 0 %}
{% for download in this.downloads.items %}
<twig:DownloadListRow download="{{ download }}" isWidget="{{ isWidget }}" />

View File

@@ -1,4 +1,17 @@
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}">
<tr{{ attributes }} is="download-list-row" class="hover:bg-gray-200" id="ad_download_{{ download.id }}" data-title="{{ download.title }}"
download-id="{{ download.id }}"
imdb-id="{{ download.imdbId }}"
media-title="{{ download.title }}"
url="{{ download.url }}"
filename="{{ download.filename }}"
status="{{ download.status }}"
progress="{{ download.progress }}"
media-type="{{ download.mediaType }}"
episode-id="{{ download.episodeId }}"
created-at="{{ download.createdAt|date('m/d/Y g:i a') }}"
updated-at="{{ download.updatedAt|date('m/d/Y g:i a') }}"
data-filename="{{ download.filename }}" data-media-type="{{ download.mediaType }}" data-status="{{ download.status }}" data-progress="{{ download.progress }}"
>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{% if download.mediaType == "movies" %}
{% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType} %}

View File

@@ -4,7 +4,9 @@
<twig:DownloadSearch search_path="app_search" placeholder="Find {{ type == "complete" ? "a" : "an" }} {{ type }} monitor..." />
</div>
{% endif %}
<table id="monitor_list" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Monitor\\Framework\\Entity\\Monitor') }}>
<table id="monitor_list" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Monitor\\Framework\\Entity\\Monitor') }}
{{ stimulus_target('monitor_list', 'monitorList') }}
>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
<th scope="col"

View File

@@ -1,4 +1,17 @@
<tr{{ attributes }} id="monitor_{{ monitor.id }}" class="hover:bg-gray-200">
<tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="hover:bg-gray-200"
monitor-id="{{ monitor.id }}"
imdb-id="{{ monitor.imdbId }}"
media-title="{{ monitor.title }}"
season="{{ monitor.season }}"
episode="{{ monitor.episode }}"
status="{{ monitor.status }}"
search-count="{{ monitor.searchCount }}"
media-type="{{ monitor.monitorType|monitor_type }}"
episode-id="{{ monitor|monitor_media_id }}"
created-at="{{ monitor.createdAt|date('m/d/Y g:i a') }}"
last-search="{{ monitor.lastSearch|date('m/d/Y g:i a') }}"
downloaded-at="{{ monitor.downloadedAt|date('m/d/Y g:i a') }}"
>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
class="mr-1 hover:underline rounded-md"

View File

@@ -0,0 +1,10 @@
<dialog{{ attributes }} is="preview-content-dialog" class="py-3 px-4 w-full md:w-[50rem] rounded-md dark:bg-gray-950/80 dark:border-2 dark:border-orange-500 dark:text-white backdrop-filter backdrop-blur-3xl">
<div class="flex flex-row justify-end">
<twig:ux:icon name="ic:twotone-cancel" width="16.75px" height="16.75px" class="modal-close rounded-full align-middle text-red-600 hover:text-red-700" />
</div>
<h2 class="modal-heading mb-4 text-2xl font-bold text-orange-500">{{ heading|default('') }}</h2>
<div class="modal-content mb-4">
{% block content %}{% endblock %}
</div>
</dialog>

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Tests\Download;
use PHPUnit\Framework\TestCase;
class DownloadOptionEvaluatorTest extends TestCase
{
public function testEpisodeExists(): void
{
$this->assertTrue(true);
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Tests\Monitor;
use App\Base\Service\MediaFiles;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvEpisodeHandler;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class MonitorTvShowHandlerTest extends TestCase
{
private MonitorTvShowHandler $handler;
private MonitorRepository $monitorRepository;
private EntityManagerInterface $entityManager;
private MonitorTvEpisodeHandler $episodeHandler;
private MediaFiles $mediaFiles;
private LoggerInterface $logger;
private Tmdb $tmdb;
protected function setUp(): void
{
$this->monitorRepository = $this->createMock(MonitorRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->episodeHandler = $this->createMock(MonitorTvEpisodeHandler::class);
$this->mediaFiles = $this->createMock(MediaFiles::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->tmdb = $this->createMock(Tmdb::class);
$this->handler = new MonitorTvShowHandler(
$this->monitorRepository,
$this->entityManager,
$this->episodeHandler,
$this->mediaFiles,
$this->logger,
$this->tmdb
);
}
public function testEpisodeExists(): void
{
// Arrange
$monitor = $this->createMock(Monitor::class);
$monitor->method('getId')->willReturn(1);
$monitor->method('getTmdbId')->willReturn('63770');
$monitor->method('getSeason')->willReturn(10);
$monitor->method('getTitle')->willReturn('The Late Show with Stephen Colbert');
$this->monitorRepository->expects($this->once())
->method('find')
->with(1)
->willReturn($monitor);
$this->tmdb->expects($this->once())
->method('seasonDetails')
->with(63770, 10)
->willReturn((object)['episodes' => [$this->getTmdbEpisode()]]);
$this->mediaFiles->expects($this->once())
->method('findEpisodes')
->willReturn($this->getDownloadedEpisodes());
// Act
$command = new MonitorTvShowCommand(1);
$result = $this->handler->handle($command);
// Assert
$this->assertInstanceOf(MonitorTvShowResult::class, $result);
$this->assertEquals('OK', $result->status);
$this->assertTrue(isset($result->result['monitor']));
$this->assertSame($monitor, $result->result['monitor']);
}
public function testEpisodeDoesNotExist(): void
{
// Arrange
$monitor = $this->createMock(Monitor::class);
$monitor->method('getId')->willReturn(1);
$monitor->method('getTmdbId')->willReturn(63770);
$monitor->method('getSeason')->willReturn(10);
$monitor->method('getTitle')->willReturn('The Late Show with Stephen Colbert');
$this->monitorRepository->expects($this->once())
->method('find')
->with(1)
->willReturn($monitor);
$this->tmdb->expects($this->once())
->method('seasonDetails')
->with(63770, 10)
->willReturn((object)['episodes' => []]);
$this->mediaFiles->expects($this->once())
->method('findEpisodes')
->willReturn([]);
// Act
$command = new MonitorTvShowCommand(1);
$result = $this->handler->handle($command);
// Assert
$this->assertInstanceOf(MonitorTvShowResult::class, $result);
$this->assertEquals('OK', $result->status);
$this->assertTrue(isset($result->result['monitor']));
$this->assertSame($monitor, $result->result['monitor']);
}
public function testHandleWithInvalidMonitorId(): void
{
// Arrange
$this->monitorRepository->expects($this->once())
->method('find')
->with(999)
->willReturn(null);
// Act & Assert
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Monitor not found');
$command = new MonitorTvShowCommand(999);
$this->handler->handle($command);
}
private function getTmdbEpisode(): array
{
return [
"air_date" => "2016-05-13",
"episode_number" => 142,
"episode_type" => "standard",
"id" => 1192242,
"name" => "Matt Bomer, Zach Woods, Nick Griffin",
"overview" => "",
"production_code" => "",
"runtime" => 45,
"season_number" => 1,
"show_id" => 63770,
"still_path" => null,
"vote_average" => 0.0,
"vote_count" => 0,
"crew" => [],
"guest_stars" => [],
"poster" => null,
];
}
private function getDownloadedEpisodes(): array
{
$episode1 = (object)[
"season" => 10,
"episode" => 142,
"resolution" => "1080p",
"codec" => "h264",
"group" => "jebaited.mkv",
"container" => "mkv",
"title" => "42 stephen colbert 2025 07 21 sandra oh",
"episodeName" => "E1 web",
];
$episode2 = (object)[
'season' => 10,
'episode' => 142,
'resolution' => '1080p',
'codec' => 'x265',
'group' => 'MeGusta[EZTVx.to].mkv',
'container' => 'mkv',
'title' => '42 Stephen Colbert 2025 07 21 Sandra Oh',
'episodeName' => 'E1 HEVC'
];
return [$episode1, $episode2];
}
}

13
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}