Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ff9cddbb0 | |||
| b0d7bfefd7 | |||
| df4bb3b736 | |||
| 265d782f99 | |||
| dc9242d96e | |||
| 24355a4b30 | |||
| 3b0ba81ce3 | |||
| dc2845d74d | |||
| 5e722dcbc7 | |||
| a126871af8 | |||
| 70f551cea9 | |||
| 4824c2d344 | |||
| c09c7ad030 | |||
| f610297294 | |||
| f2971eee9c | |||
| 47108af1f8 | |||
| f7163b5e00 | |||
| 31e364d691 | |||
| b42981b2a1 | |||
| accfa9c9bf | |||
| 8b50b50466 | |||
| e38498f69b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,3 +18,4 @@ bolt.db
|
|||||||
###> phpstan/phpstan ###
|
###> phpstan/phpstan ###
|
||||||
phpstan.neon
|
phpstan.neon
|
||||||
###< phpstan/phpstan ###
|
###< phpstan/phpstan ###
|
||||||
|
.php-cs-fixer.cache
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import './bootstrap.js';
|
|||||||
* which should already be in your base.html.twig.
|
* which should already be in your base.html.twig.
|
||||||
*/
|
*/
|
||||||
import './styles/app.css';
|
import './styles/app.css';
|
||||||
|
import PullToRefresh from 'pulltorefreshjs';
|
||||||
|
|
||||||
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
|
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});
|
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});
|
||||||
|
|
||||||
|
const ptr = PullToRefresh.init({
|
||||||
|
mainElement: 'body',
|
||||||
|
onRefresh() {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
1
assets/bootstrap.js
vendored
1
assets/bootstrap.js
vendored
@@ -2,6 +2,7 @@ import { startStimulusApp } from '@symfony/stimulus-bundle';
|
|||||||
import Popover from '@stimulus-components/popover'
|
import Popover from '@stimulus-components/popover'
|
||||||
import Dialog from '@stimulus-components/dialog'
|
import Dialog from '@stimulus-components/dialog'
|
||||||
import Dropdown from '@stimulus-components/dropdown'
|
import Dropdown from '@stimulus-components/dropdown'
|
||||||
|
import 'animate.css'
|
||||||
|
|
||||||
const app = startStimulusApp();
|
const app = startStimulusApp();
|
||||||
// register any custom, 3rd party controllers here
|
// register any custom, 3rd party controllers here
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getComponent } from '@symfony/ux-live-component';
|
|||||||
|
|
||||||
/* stimulusFetch: 'lazy' */
|
/* stimulusFetch: 'lazy' */
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ['download']
|
static targets = ['download', 'deleteFileInput']
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
this.component = await getComponent(this.element);
|
this.component = await getComponent(this.element);
|
||||||
@@ -42,7 +42,8 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteDownload(data) {
|
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(res => res.json())
|
||||||
.then(json => console.debug(json));
|
.then(json => console.debug(json));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,24 +20,19 @@ export default class extends Controller {
|
|||||||
|
|
||||||
options = []
|
options = []
|
||||||
optionsLoaded = false
|
optionsLoaded = false
|
||||||
|
resultCountEl = null
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
await this.setOptions();
|
this.resultCountEl = document.querySelector('#movie_results_count');
|
||||||
}
|
}
|
||||||
|
|
||||||
async setOptions() {
|
async listTargetConnected() {
|
||||||
if (false === this.optionsLoaded) {
|
this.optionsLoaded = true;
|
||||||
this.optionsLoaded = true;
|
this.options = this.element.querySelectorAll('tbody tr');
|
||||||
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
|
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
|
||||||
.then(res => res.text())
|
this.dispatch('optionsLoaded', {detail: {options: this.options}})
|
||||||
.then(response => {
|
this.loadingIconOutlet.toggleIcon();
|
||||||
this.element.innerHTML = response;
|
this.resultCountEl.innerText = this.options.length;
|
||||||
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
|
// Keeps compatible with Filter & TV Shows
|
||||||
@@ -102,5 +97,6 @@ export default class extends Controller {
|
|||||||
count = count + 1;
|
count = count + 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.resultCountEl.innerText = count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export default class extends Controller {
|
|||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.element.parentElement.classList.toggle('hidden');
|
this.element.parentElement.classList.toggle('hidden');
|
||||||
|
this.element.classList.toggle('animate__slideInLeft');
|
||||||
this.element.classList.toggle('fixed');
|
this.element.classList.toggle('fixed');
|
||||||
this.element.classList.toggle('z-20');
|
this.element.classList.toggle('z-20');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ export default class extends Controller {
|
|||||||
this.component.on('render:finished', (component) => {
|
this.component.on('render:finished', (component) => {
|
||||||
console.log(component);
|
console.log(component);
|
||||||
});
|
});
|
||||||
|
if (window.location.hash) {
|
||||||
|
let targetElement = document.querySelector(window.location.hash);
|
||||||
|
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSeason(season) {
|
setSeason(season) {
|
||||||
@@ -25,6 +30,7 @@ export default class extends Controller {
|
|||||||
|
|
||||||
paginate(event) {
|
paginate(event) {
|
||||||
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
|
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
|
||||||
|
this.component.set('episodeNumber', null);
|
||||||
this.component.action('paginate', {page: event.params.page});
|
this.component.action('paginate', {page: event.params.page});
|
||||||
this.component.render();
|
this.component.render();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,37 +25,18 @@ export default class extends Controller {
|
|||||||
optionsLoaded = false
|
optionsLoaded = false
|
||||||
isOpen = false
|
isOpen = false
|
||||||
|
|
||||||
async connect() {
|
async listTargetConnected() {
|
||||||
await this.setOptions();
|
this.options = this.element.querySelectorAll('tbody tr');
|
||||||
}
|
if (this.options.length > 0) {
|
||||||
|
this.options.forEach((option) =>
|
||||||
async setOptions() {
|
option.querySelector('.download-btn').dataset['title'] = this.titleValue
|
||||||
if (this.optionsLoaded === false) {
|
);
|
||||||
this.optionsLoaded = true;
|
this.options[0].querySelector('input[type="checkbox"]').checked = true;
|
||||||
let response;
|
this.dispatch('optionsLoaded', {detail: {options: this.options}})
|
||||||
|
this.loadingIconOutlet.increaseCount();
|
||||||
try {
|
} else {
|
||||||
response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
|
this.countTarget.innerText = 0;
|
||||||
} catch (error) {
|
this.episodeSelectorTarget.disabled = true;
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +115,7 @@ export default class extends Controller {
|
|||||||
"codec": option.querySelector('#codec').textContent.trim(),
|
"codec": option.querySelector('#codec').textContent.trim(),
|
||||||
"provider": option.querySelector('#provider').textContent.trim(),
|
"provider": option.querySelector('#provider').textContent.trim(),
|
||||||
"languages": JSON.parse(option.dataset['languages']),
|
"languages": JSON.parse(option.dataset['languages']),
|
||||||
|
"quality": option.dataset['quality'],
|
||||||
}
|
}
|
||||||
|
|
||||||
let include = true;
|
let include = true;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
dev.caldwell.digital:443
|
{
|
||||||
|
log {
|
||||||
|
level DEBUG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
|
dev.caldwell.digital:443 {
|
||||||
|
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
|
||||||
reverse_proxy app:80
|
|
||||||
|
|
||||||
|
reverse_proxy app:80
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ services:
|
|||||||
caddy:
|
caddy:
|
||||||
image: caddy:2.9.1
|
image: caddy:2.9.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
tty: true
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ pwa:
|
|||||||
theme_color: "#083344"
|
theme_color: "#083344"
|
||||||
description: Torsearch provides a simple and intuitive way to manage your personal media library.
|
description: Torsearch provides a simple and intuitive way to manage your personal media library.
|
||||||
icons:
|
icons:
|
||||||
- src: "icon.png"
|
- src: "/icon.png"
|
||||||
sizes: [ 192 ]
|
sizes: [ 192 ]
|
||||||
- src: "icon.png"
|
- src: "/icon.png"
|
||||||
sizes: [ 192 ]
|
sizes: [ 192 ]
|
||||||
purpose: maskable
|
purpose: maskable
|
||||||
categories:
|
categories:
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ security:
|
|||||||
login_path: '/login/oidc'
|
login_path: '/login/oidc'
|
||||||
check_path: '/login/oidc/auth'
|
check_path: '/login/oidc/auth'
|
||||||
enable_end_session_listener: true
|
enable_end_session_listener: true
|
||||||
|
http_basic:
|
||||||
|
realm: Secured Area
|
||||||
entry_point: form_login
|
entry_point: form_login
|
||||||
|
|
||||||
# activate different ways to authenticate
|
# activate different ways to authenticate
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ controllersBase:
|
|||||||
defaults:
|
defaults:
|
||||||
schemes: [ 'https' ]
|
schemes: [ 'https' ]
|
||||||
|
|
||||||
|
controllersLibrary:
|
||||||
|
resource:
|
||||||
|
path: ../src/Library/Framework/Controller/
|
||||||
|
namespace: App\Library\Framework\Controller
|
||||||
|
type: attribute
|
||||||
|
defaults:
|
||||||
|
schemes: [ 'https' ]
|
||||||
|
|
||||||
controllersSearch:
|
controllersSearch:
|
||||||
resource:
|
resource:
|
||||||
path: ../src/Search/Framework/Controller/
|
path: ../src/Search/Framework/Controller/
|
||||||
|
|||||||
@@ -64,4 +64,7 @@ return [
|
|||||||
'version' => '2.4.3',
|
'version' => '2.4.3',
|
||||||
'type' => 'css',
|
'type' => 'css',
|
||||||
],
|
],
|
||||||
|
'pulltorefreshjs' => [
|
||||||
|
'version' => '0.1.22',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Base\Service;
|
|||||||
use Aimeos\Map;
|
use Aimeos\Map;
|
||||||
use App\Download\Framework\Entity\Download;
|
use App\Download\Framework\Entity\Download;
|
||||||
use Nihilarr\PTN;
|
use Nihilarr\PTN;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\Filesystem\Filesystem;
|
use Symfony\Component\Filesystem\Filesystem;
|
||||||
use Symfony\Component\Finder\Finder;
|
use Symfony\Component\Finder\Finder;
|
||||||
@@ -21,6 +22,7 @@ class MediaFiles
|
|||||||
private string $tvShowsPath;
|
private string $tvShowsPath;
|
||||||
|
|
||||||
private Filesystem $filesystem;
|
private Filesystem $filesystem;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[Autowire(param: 'media.base_path')]
|
#[Autowire(param: 'media.base_path')]
|
||||||
@@ -33,12 +35,14 @@ class MediaFiles
|
|||||||
string $tvShowsPath,
|
string $tvShowsPath,
|
||||||
|
|
||||||
Filesystem $filesystem,
|
Filesystem $filesystem,
|
||||||
|
LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
$this->finder = new Finder();
|
$this->finder = new Finder();
|
||||||
$this->basePath = $basePath;
|
$this->basePath = $basePath;
|
||||||
$this->moviesPath = $moviesPath;
|
$this->moviesPath = $moviesPath;
|
||||||
$this->tvShowsPath = $tvShowsPath;
|
$this->tvShowsPath = $tvShowsPath;
|
||||||
$this->filesystem = $filesystem;
|
$this->filesystem = $filesystem;
|
||||||
|
$this->logger = $logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPathByType(string $mediaType): string
|
public function getPathByType(string $mediaType): string
|
||||||
@@ -140,7 +144,7 @@ class MediaFiles
|
|||||||
return $path;
|
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);
|
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);
|
||||||
|
|
||||||
@@ -220,4 +224,26 @@ class MediaFiles
|
|||||||
{
|
{
|
||||||
$this->filesystem->chmod($filepath, $permissions);
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ class DeleteDownloadCommand implements CommandInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $downloadId,
|
public int $downloadId,
|
||||||
|
public bool $deleteFile = false,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ namespace App\Download\Action\Handler;
|
|||||||
use App\Download\Action\Command\DeleteDownloadCommand;
|
use App\Download\Action\Command\DeleteDownloadCommand;
|
||||||
use App\Download\Action\Result\DeleteDownloadResult;
|
use App\Download\Action\Result\DeleteDownloadResult;
|
||||||
use App\Download\Framework\Repository\DownloadRepository;
|
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\CommandInterface;
|
||||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||||
@@ -14,13 +16,26 @@ readonly class DeleteDownloadHandler implements HandlerInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private DownloadRepository $downloadRepository,
|
private DownloadRepository $downloadRepository,
|
||||||
|
private DeleteMediaFileHandler $deleteMediaFileHandler,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(CommandInterface $command): ResultInterface
|
public function handle(CommandInterface $command): ResultInterface
|
||||||
{
|
{
|
||||||
$download = $this->downloadRepository->find($command->downloadId);
|
$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);
|
$this->downloadRepository->delete($command->downloadId);
|
||||||
|
|
||||||
return new DeleteDownloadResult(200, 'Success', $download);
|
return new DeleteDownloadResult(
|
||||||
|
status: 200,
|
||||||
|
message: 'Success',
|
||||||
|
download: $download,
|
||||||
|
deleteMediaFileResult: $deletedFileResult ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Download\Action\Input;
|
namespace App\Download\Action\Input;
|
||||||
|
|
||||||
use App\Download\Action\Command\DeleteDownloadCommand;
|
use App\Download\Action\Command\DeleteDownloadCommand;
|
||||||
|
use OneToMany\RichBundle\Attribute\SourceQuery;
|
||||||
use OneToMany\RichBundle\Attribute\SourceRoute;
|
use OneToMany\RichBundle\Attribute\SourceRoute;
|
||||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||||
use OneToMany\RichBundle\Contract\InputInterface;
|
use OneToMany\RichBundle\Contract\InputInterface;
|
||||||
@@ -13,12 +14,15 @@ class DeleteDownloadInput implements InputInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
#[SourceRoute('downloadId')]
|
#[SourceRoute('downloadId')]
|
||||||
public int $downloadId,
|
public int $downloadId,
|
||||||
|
#[SourceQuery('deleteFile')]
|
||||||
|
public bool $deleteFile = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function toCommand(): CommandInterface
|
public function toCommand(): CommandInterface
|
||||||
{
|
{
|
||||||
return new DeleteDownloadCommand(
|
return new DeleteDownloadCommand(
|
||||||
$this->downloadId,
|
$this->downloadId,
|
||||||
|
$this->deleteFile,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,5 +12,6 @@ class DeleteDownloadResult implements ResultInterface
|
|||||||
public int $status,
|
public int $status,
|
||||||
public string $message,
|
public string $message,
|
||||||
public Download $download,
|
public Download $download,
|
||||||
|
public ?DeleteMediaFileResult $deleteMediaFileResult = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/Download/Action/Result/DeleteMediaFileResult.php
Normal file
14
src/Download/Action/Result/DeleteMediaFileResult.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ class ApiController extends AbstractController
|
|||||||
message: "{$result->download->getTitle()} has been deleted.",
|
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'])]
|
#[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\User\Framework\Entity\User;
|
|||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Gedmo\Timestampable\Traits\TimestampableEntity;
|
use Gedmo\Timestampable\Traits\TimestampableEntity;
|
||||||
use Nihilarr\PTN;
|
use Nihilarr\PTN;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Ignore;
|
||||||
use Symfony\UX\Turbo\Attribute\Broadcast;
|
use Symfony\UX\Turbo\Attribute\Broadcast;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
|
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
|
||||||
@@ -44,6 +45,7 @@ class Download
|
|||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
private ?string $episodeId = null;
|
private ?string $episodeId = null;
|
||||||
|
|
||||||
|
#[Ignore]
|
||||||
#[ORM\ManyToOne(inversedBy: 'downloads')]
|
#[ORM\ManyToOne(inversedBy: 'downloads')]
|
||||||
private ?User $user = null;
|
private ?User $user = null;
|
||||||
|
|
||||||
|
|||||||
16
src/Library/Action/Command/DeleteMediaFileCommand.php
Normal file
16
src/Library/Action/Command/DeleteMediaFileCommand.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
16
src/Library/Action/Command/LibrarySearchCommand.php
Normal file
16
src/Library/Action/Command/LibrarySearchCommand.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
42
src/Library/Action/Handler/DeleteMediaFileHandler.php
Normal file
42
src/Library/Action/Handler/DeleteMediaFileHandler.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/Library/Action/Handler/LibrarySearchHandler.php
Normal file
83
src/Library/Action/Handler/LibrarySearchHandler.php
Normal 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 Nihilarr\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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/Library/Action/Input/DeleteMediaFileInput.php
Normal file
29
src/Library/Action/Input/DeleteMediaFileInput.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Library/Action/Input/LibrarySearchInput.php
Normal file
38
src/Library/Action/Input/LibrarySearchInput.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/Library/Action/Result/DeleteMediaFileResult.php
Normal file
15
src/Library/Action/Result/DeleteMediaFileResult.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
16
src/Library/Action/Result/LibrarySearchResult.php
Normal file
16
src/Library/Action/Result/LibrarySearchResult.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
23
src/Library/Dto/MediaFileDto.php
Normal file
23
src/Library/Dto/MediaFileDto.php
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Library/Framework/Controller/Api.php
Normal file
40
src/Library/Framework/Controller/Api.php
Normal 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')
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,5 +11,6 @@ class GetMediaInfoCommand implements CommandInterface
|
|||||||
public string $imdbId,
|
public string $imdbId,
|
||||||
public string $mediaType,
|
public string $mediaType,
|
||||||
public ?int $season = null,
|
public ?int $season = null,
|
||||||
|
public ?int $episode = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Search\Action\Handler;
|
namespace App\Search\Action\Handler;
|
||||||
|
|
||||||
use App\Base\Service\MediaFiles;
|
|
||||||
use App\Search\Action\Command\GetMediaInfoCommand;
|
use App\Search\Action\Command\GetMediaInfoCommand;
|
||||||
use App\Search\Action\Result\GetMediaInfoResult;
|
use App\Search\Action\Result\GetMediaInfoResult;
|
||||||
use App\Tmdb\Tmdb;
|
use App\Tmdb\Tmdb;
|
||||||
@@ -15,19 +14,12 @@ class GetMediaInfoHandler implements HandlerInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Tmdb $tmdb,
|
private readonly Tmdb $tmdb,
|
||||||
private readonly MediaFiles $mediaFiles
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(CommandInterface $command): ResultInterface
|
public function handle(CommandInterface $command): ResultInterface
|
||||||
{
|
{
|
||||||
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
|
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
|
||||||
|
|
||||||
if ("tvshows" === $command->mediaType) {
|
return new GetMediaInfoResult($media, $command->season, $command->episode);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class GetMediaInfoInput implements InputInterface
|
|||||||
|
|
||||||
#[SourceRoute('season', nullify: true)]
|
#[SourceRoute('season', nullify: true)]
|
||||||
public ?int $season,
|
public ?int $season,
|
||||||
|
|
||||||
|
#[SourceRoute('episode', nullify: true)]
|
||||||
|
public ?int $episode,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function toCommand(): CommandInterface
|
public function toCommand(): CommandInterface
|
||||||
@@ -26,6 +29,10 @@ class GetMediaInfoInput implements InputInterface
|
|||||||
if ("tvshows" === $this->mediaType && null === $this->season) {
|
if ("tvshows" === $this->mediaType && null === $this->season) {
|
||||||
$this->season = 1;
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,6 @@ class GetMediaInfoResult implements ResultInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public TmdbResult $media,
|
public TmdbResult $media,
|
||||||
public ?int $season,
|
public ?int $season,
|
||||||
|
public ?int $episode,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
213
src/Search/Framework/Command/SearchCommand.php
Normal file
213
src/Search/Framework/Command/SearchCommand.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,11 +33,12 @@ final class WebController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')]
|
#[Route('/result/{mediaType}/{imdbId}/{season}/{episode?}', name: 'app_search_result')]
|
||||||
public function result(
|
public function result(
|
||||||
GetMediaInfoInput $input,
|
GetMediaInfoInput $input,
|
||||||
?int $season = null,
|
?int $season = null,
|
||||||
): Response {
|
): Response
|
||||||
|
{
|
||||||
$result = $this->getMediaInfoHandler->handle($input->toCommand());
|
$result = $this->getMediaInfoHandler->handle($input->toCommand());
|
||||||
|
|
||||||
return $this->render('search/result.html.twig', [
|
return $this->render('search/result.html.twig', [
|
||||||
@@ -52,32 +53,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,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ class Tmdb
|
|||||||
description: $data['overview'],
|
description: $data['overview'],
|
||||||
year: (new \DateTime($data['release_date']))->format('Y'),
|
year: (new \DateTime($data['release_date']))->format('Y'),
|
||||||
mediaType: "movies",
|
mediaType: "movies",
|
||||||
|
episodeAirDate: (new \DateTime($data['release_date']))->format('m/d/Y'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Torrentio\Action\Handler;
|
namespace App\Torrentio\Action\Handler;
|
||||||
|
|
||||||
use App\Base\Service\MediaFiles;
|
use App\Base\Service\MediaFiles;
|
||||||
|
use App\Library\Dto\MediaFileDto;
|
||||||
use App\Tmdb\Tmdb;
|
use App\Tmdb\Tmdb;
|
||||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||||
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
|
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
|
||||||
@@ -28,7 +29,7 @@ class GetTvShowOptionsHandler implements HandlerInterface
|
|||||||
|
|
||||||
return new GetTvShowOptionsResult(
|
return new GetTvShowOptionsResult(
|
||||||
media: $media,
|
media: $media,
|
||||||
file: $file,
|
file: MediaFileDto::fromSplFileInfo($file),
|
||||||
season: $command->season,
|
season: $command->season,
|
||||||
episode: $command->episode,
|
episode: $command->episode,
|
||||||
results: $this->torrentio->fetchEpisodeResults(
|
results: $this->torrentio->fetchEpisodeResults(
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Torrentio\Action\Result;
|
namespace App\Torrentio\Action\Result;
|
||||||
|
|
||||||
|
use App\Library\Dto\MediaFileDto;
|
||||||
use App\Tmdb\TmdbResult;
|
use App\Tmdb\TmdbResult;
|
||||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||||
use Symfony\Component\Finder\SplFileInfo;
|
|
||||||
|
|
||||||
class GetTvShowOptionsResult implements ResultInterface
|
class GetTvShowOptionsResult implements ResultInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public TmdbResult $media,
|
public TmdbResult $media,
|
||||||
public bool|SplFileInfo $file,
|
public MediaFileDto|false $file,
|
||||||
public string $season,
|
public string $season,
|
||||||
public string $episode,
|
public string $episode,
|
||||||
public array $results
|
public array $results
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ use App\Torrentio\Action\Input\GetMovieOptionsInput;
|
|||||||
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
|
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
|
||||||
use App\Torrentio\Exception\TorrentioRateLimitException;
|
use App\Torrentio\Exception\TorrentioRateLimitException;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Attribute\Cache;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Contracts\Cache\CacheInterface;
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
use Symfony\Contracts\Cache\ItemInterface;
|
use Symfony\Contracts\Cache\ItemInterface;
|
||||||
|
use Symfony\UX\Turbo\TurboBundle;
|
||||||
|
|
||||||
final class WebController extends AbstractController
|
final class WebController extends AbstractController
|
||||||
{
|
{
|
||||||
@@ -24,8 +27,9 @@ final class WebController extends AbstractController
|
|||||||
private readonly Broadcaster $broadcaster,
|
private readonly Broadcaster $broadcaster,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
#[Cache(expires: 3600, public: false, mustRevalidate: true)]
|
||||||
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
|
#[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(
|
$cacheId = sprintf(
|
||||||
"page.torrentio.movies.%s.%s",
|
"page.torrentio.movies.%s.%s",
|
||||||
@@ -33,17 +37,22 @@ final class WebController extends AbstractController
|
|||||||
$input->imdbId
|
$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));
|
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
|
||||||
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
|
return $this->getMovieOptionsHandler->handle($input->toCommand());
|
||||||
return $this->render('torrentio/movies.html.twig', [
|
|
||||||
'results' => $results,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ($request->headers->get('Turbo-Frame')) {
|
||||||
|
return $this->sendFragmentResponse($results, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('torrentio/movies.html.twig', [
|
||||||
|
'results' => $results,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
|
#[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(
|
$cacheId = sprintf(
|
||||||
"page.torrentio.tvshows.%s.%s.%s.%s",
|
"page.torrentio.tvshows.%s.%s.%s.%s",
|
||||||
@@ -54,13 +63,18 @@ final class WebController extends AbstractController
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
|
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
|
||||||
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
|
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
|
||||||
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
|
return $this->getTvShowOptionsHandler->handle($input->toCommand());
|
||||||
return $this->render('torrentio/tvshows.html.twig', [
|
});
|
||||||
'results' => $results,
|
|
||||||
]);
|
if ($request->headers->get('Turbo-Frame')) {
|
||||||
// });
|
return $this->sendFragmentResponse($results, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('torrentio/tvshows.html.twig', [
|
||||||
|
'results' => $results,
|
||||||
|
]);
|
||||||
} catch (TorrentioRateLimitException $exception) {
|
} catch (TorrentioRateLimitException $exception) {
|
||||||
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
|
$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',
|
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')]
|
private function sendFragmentResponse(ResultInterface $result, Request $request): Response
|
||||||
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
|
|
||||||
{
|
{
|
||||||
$cacheId = sprintf(
|
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
|
||||||
"page.torrentio.tvshows.%s.%s.%s.%s",
|
return $this->renderBlock(
|
||||||
$input->tmdbId,
|
'torrentio/fragments.html.twig',
|
||||||
$input->imdbId,
|
$request->query->get('block'),
|
||||||
$input->season,
|
[
|
||||||
$input->episode,
|
'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,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ use App\Search\Action\Command\GetMediaInfoCommand;
|
|||||||
use App\Search\Action\Handler\GetMediaInfoHandler;
|
use App\Search\Action\Handler\GetMediaInfoHandler;
|
||||||
use App\Search\TvEpisodePaginator;
|
use App\Search\TvEpisodePaginator;
|
||||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
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\Attribute\LiveProp;
|
||||||
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
use Symfony\UX\LiveComponent\DefaultActionTrait;
|
||||||
|
|
||||||
#[AsLiveComponent]
|
#[AsLiveComponent]
|
||||||
final class TvEpisodeList
|
final class TvEpisodeList
|
||||||
{
|
{
|
||||||
use DefaultActionTrait;
|
use DefaultActionTrait;
|
||||||
use PaginateTrait;
|
use PaginateTrait;
|
||||||
@@ -27,6 +29,12 @@ final class TvEpisodeList
|
|||||||
#[LiveProp(writable: true)]
|
#[LiveProp(writable: true)]
|
||||||
public int $season = 1;
|
public int $season = 1;
|
||||||
|
|
||||||
|
#[LiveProp(writable: true)]
|
||||||
|
public int $reloadCount = 0;
|
||||||
|
|
||||||
|
#[LiveProp(writable: true)]
|
||||||
|
public ?int $episodeNumber = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private GetMediaInfoHandler $getMediaInfoHandler,
|
private GetMediaInfoHandler $getMediaInfoHandler,
|
||||||
) {}
|
) {}
|
||||||
@@ -34,6 +42,14 @@ final class TvEpisodeList
|
|||||||
public function getEpisodes()
|
public function getEpisodes()
|
||||||
{
|
{
|
||||||
$results = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand($this->imdbId, "tvshows", $this->season));
|
$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);
|
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
src/Twig/Dto/EpisodeIdDto.php
Normal file
25
src/Twig/Dto/EpisodeIdDto.php
Normal 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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Twig\Extensions;
|
|||||||
|
|
||||||
use App\Base\Service\MediaFiles;
|
use App\Base\Service\MediaFiles;
|
||||||
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
|
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
|
||||||
|
use App\Twig\Dto\EpisodeIdDto;
|
||||||
use ChrisUllyott\FileSize;
|
use ChrisUllyott\FileSize;
|
||||||
use Twig\Attribute\AsTwigFilter;
|
use Twig\Attribute\AsTwigFilter;
|
||||||
use Twig\Attribute\AsTwigFunction;
|
use Twig\Attribute\AsTwigFunction;
|
||||||
@@ -47,7 +48,7 @@ class UtilExtension
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[AsTwigFilter('episode_id_from_results')]
|
#[AsTwigFilter('episode_id_from_results')]
|
||||||
public function episodeId($result): ?string
|
public function episodeIdFromResults($result): ?string
|
||||||
{
|
{
|
||||||
if (!$result instanceof GetTvShowOptionsResult) {
|
if (!$result instanceof GetTvShowOptionsResult) {
|
||||||
return null;
|
return null;
|
||||||
@@ -56,4 +57,49 @@ class UtilExtension
|
|||||||
return "S". str_pad($result->season, 2, "0", STR_PAD_LEFT) .
|
return "S". str_pad($result->season, 2, "0", STR_PAD_LEFT) .
|
||||||
"E". str_pad($result->episode, 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_" . $season . "_" . $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/', $episodeId, $seasonMatch);
|
||||||
|
if (empty($seasonMatch)) {
|
||||||
|
$season = "";
|
||||||
|
} else {
|
||||||
|
$season = str_replace(['S', 's'], '', $seasonMatch[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture episode
|
||||||
|
$episodeMatch = [];
|
||||||
|
preg_match('/[eE]\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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ module.exports = {
|
|||||||
"rounded-sm",
|
"rounded-sm",
|
||||||
"rounded-md",
|
"rounded-md",
|
||||||
"r-tablecell",
|
"r-tablecell",
|
||||||
|
"animate__animated",
|
||||||
|
"animate__slideInLeft",
|
||||||
|
"animate__animateFaster"
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}">
|
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}">
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
|
||||||
<a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}"
|
{% if download.mediaType == "movies" %}
|
||||||
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate"
|
{% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType} %}
|
||||||
>
|
{% set route = path('app_search_result', routeParams) %}
|
||||||
|
{% else %}
|
||||||
|
{% set episodeIdDto = extract_from_episode_id(download.episodeId) %}
|
||||||
|
{% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType, season: episodeIdDto.season, episode: episodeIdDto.episode} %}
|
||||||
|
{% set route = path('app_search_result', routeParams) ~ "#" ~ episode_anchor(episodeIdDto.season, episodeIdDto.episode) %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ route }}"
|
||||||
|
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate">
|
||||||
{{ download.title }}
|
{{ download.title }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if download.mediaType == "tvshows" and download.episodeId != null %}
|
{% if download.mediaType == "tvshows" and download.episodeId != null %}
|
||||||
— <span class="ml-1">(S{{ download.episodeId }})</span>
|
— <span class="ml-1">({{ download.episodeId }})</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -19,19 +26,22 @@
|
|||||||
{{ download.mediaType }}
|
{{ download.mediaType }}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50">
|
<td class="whitespace-nowrap gap-2 text-sm align-middle text-gray-800 dark:text-gray-50">
|
||||||
{% if download.progress < 100 %}
|
{% if download.progress < 100 %}
|
||||||
<div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-full h-6 align-middle overflow-hidden">
|
<div class="flex flex-row items-center justify-center">
|
||||||
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
<div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-16 h-6 align-middle overflow-hidden">
|
||||||
style="width:{{ download.progress }}%">
|
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||||
|
style="width:{{ download.progress }}%">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-black text-center" style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">{{ download.progress }}%</div>
|
<div class="text-black font-bold text-center z-40 ml-[-42px]">{{ download.progress }}%</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<twig:StatusBadge color="green" status="Complete" />
|
<twig:StatusBadge color="green" status="Complete" />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td id="hidden md:table-cell action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center">
|
<td id="hidden md:table-cell action_buttons_{{ download.id }}" class="pl-2 pr-4 py-4 flex flex-row items-center justify-end">
|
||||||
{% if download.status == 'In Progress' and download.progress < 100 %}
|
{% if download.status == 'In Progress' and download.progress < 100 %}
|
||||||
<button id="pause_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}>
|
<button id="pause_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}>
|
||||||
<twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" />
|
<twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" />
|
||||||
@@ -44,7 +54,12 @@
|
|||||||
|
|
||||||
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', height: '17.75px', width: '17.75px', class: 'rounded-full align-middle text-red-600 hover:text-red-700' }) %}
|
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', height: '17.75px', width: '17.75px', class: 'rounded-full align-middle text-red-600 hover:text-red-700' }) %}
|
||||||
<twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}" show_cancel show_submit>
|
<twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}" show_cancel show_submit>
|
||||||
Are you sure you want to delete <span class="font-bold">{{ download.filename }}</span>?
|
<p class="mb-1">Are you sure you want to delete the following record?</p>
|
||||||
|
<p class="mb-1 ml-4 italic">{{ download.filename }}</p>
|
||||||
|
<div class="">
|
||||||
|
<input id="delete_file_{{ download.id }}" class="accent-orange-500" type="checkbox" value="false" name="delete_file" />
|
||||||
|
<label for="delete_file_{{ download.id }}">Delete the file as well?</label>
|
||||||
|
</div>
|
||||||
</twig:Modal>
|
</twig:Modal>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -73,18 +73,15 @@
|
|||||||
{{ stimulus_action('result_filter', 'setSeason', 'change') }}
|
{{ stimulus_action('result_filter', 'setSeason', 'change') }}
|
||||||
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}
|
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}
|
||||||
>
|
>
|
||||||
<option selected value="1">1</option>
|
{% for season in range(1, results.media.episodes|length) %}
|
||||||
{% for season in range(2, results.media.episodes|length) %}
|
<option value="{{ season }}"
|
||||||
<option value="{{ season }}">{{ season }}</option>
|
{% if results.season == season %}
|
||||||
|
selected="selected"
|
||||||
|
{% endif %}
|
||||||
|
>{{ season }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{# <label for="episodeNumber">#}
|
|
||||||
{# Episode#}
|
|
||||||
{# <select id="episodeNumber" name="episodeNumber" data-result-filter-target="episode" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-sm">#}
|
|
||||||
{# <option selected value="">n/a</option>#}
|
|
||||||
{# </select>#}
|
|
||||||
{# </label>#}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span {{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
|
<span {{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
|
||||||
class="loading-icon">
|
class="loading-icon">
|
||||||
|
|||||||
@@ -3,7 +3,19 @@
|
|||||||
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
|
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
|
||||||
class="mr-1 hover:underline rounded-md"
|
class="mr-1 hover:underline rounded-md"
|
||||||
>
|
>
|
||||||
{{ monitor.title }}
|
|
||||||
|
{% if monitor.monitorType == "movies" %}
|
||||||
|
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType} %}
|
||||||
|
{% set route = path('app_search_result', routeParams) %}
|
||||||
|
{% else %}
|
||||||
|
{% set episodeIdDto = extract_from_episode_id(monitor|monitor_media_id) %}
|
||||||
|
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType, season: episodeIdDto.season, episode: episodeIdDto.episode} %}
|
||||||
|
{% set route = path('app_search_result', routeParams) ~ "#" ~ episode_anchor(episodeIdDto.season, episodeIdDto.episode) %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ route }}"
|
||||||
|
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate">
|
||||||
|
{{ monitor.title }}
|
||||||
|
</a>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<nav id="navbar" {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950 animate__animated animate__slideInLeft animate__slow">
|
<nav id="navbar" {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950 animate__animated animate__animateFaster">
|
||||||
<div class="px-4 py-4 flex flex-col gap-12">
|
<div class="px-4 py-4 flex flex-col gap-12">
|
||||||
<h1 class="text-3xl mt-12 md:mt-0 font-extrabold text-orange-500 mb-3"><a href="{{ path('app_index') }}">Torsearch</a></h1>
|
<h1 class="text-3xl mt-12 md:mt-0 font-extrabold text-orange-500 mb-3"><a href="{{ path('app_index') }}">Torsearch</a></h1>
|
||||||
<ul class="nav-list space-y-1">
|
<ul class="nav-list space-y-1">
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
imdbId: imdbId
|
imdbId: imdbId
|
||||||
}) }}">
|
}) }}">
|
||||||
<img src="{{ image }}" class="w-full md:w-40 rounded-md" />
|
<img src="{{ preload(image) }}" class="w-full md:w-40 rounded-md" />
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ path('app_search_result', {
|
<a href="{{ path('app_search_result', {
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
imdbId: imdbId
|
imdbId: imdbId
|
||||||
}) }}">
|
}) }}">
|
||||||
<h3 class="text-center text-white md:text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3>
|
<h3 class="text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
class="episode-list flex flex-col gap-4"
|
class="episode-list flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4">
|
<div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4">
|
||||||
{% for episode in this.episodes.items %}
|
{% for episode in this.getEpisodes().items %}
|
||||||
<div id="episode_{{ episode['season_number'] }}_{{ episode['episode_number'] }}" class="results"
|
<div id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results"
|
||||||
data-tv-results-loading-icon-outlet=".loading-icon"
|
data-tv-results-loading-icon-outlet=".loading-icon"
|
||||||
data-download-button-outlet=".download-btn"
|
data-download-button-outlet=".download-btn"
|
||||||
{{ stimulus_controller('tv_results', {
|
{{ stimulus_controller('tv_results', {
|
||||||
@@ -40,30 +40,17 @@
|
|||||||
{{ episode['air_date']|date(null, 'UTC') }}
|
{{ episode['air_date']|date(null, 'UTC') }}
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
{% if episode['file'] != false %}
|
<twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('api.library.search', {
|
||||||
<span data-controller="popover">
|
title: this.title,
|
||||||
<template data-popover-target="content">
|
season: episode['season_number'],
|
||||||
<div data-popover-target="card" class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
|
episode: episode['episode_number'],
|
||||||
<p class="font-bold text-sm text-left">Existing file(s) for this episode:</p>
|
block: 'media_exists_badge',
|
||||||
<ul class="list-disc ml-3">
|
target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode['season_number'], episode['episode_number'])
|
||||||
<li class="font-normal">{{ episode['file'].realPath|strip_media_path }} — <strong>{{ episode['file'].size|filesize }}</strong></li>
|
}) }}">
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<small
|
|
||||||
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
|
|
||||||
data-action="mouseenter->popover#show mouseleave->popover#hide"
|
|
||||||
>
|
|
||||||
exists
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if episode['file'] == false %}
|
|
||||||
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
|
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
|
||||||
missing
|
missing
|
||||||
</small>
|
</small>
|
||||||
{% endif %}
|
</twig:Turbo:Frame>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4 justify-between">
|
<div class="flex flex-col gap-4 justify-between">
|
||||||
@@ -87,8 +74,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div {{ stimulus_target('tv-results', 'listContainer') }} class="inline-block overflow-hidden rounded-lg">
|
<div class="inline-block overflow-hidden rounded-lg">
|
||||||
|
<twig:Turbo:Frame id="results_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('app_torrentio_tvshows', {
|
||||||
|
tmdbId: this.tmdbId,
|
||||||
|
imdbId: this.imdbId,
|
||||||
|
season: episode['season_number'],
|
||||||
|
episode: episode['episode_number'],
|
||||||
|
target: 'results_' ~ episode_id(episode['season_number'], episode['episode_number']),
|
||||||
|
block: 'tvshow_results'
|
||||||
|
}) }}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
templates/search/fragments.html.twig
Normal file
32
templates/search/fragments.html.twig
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% block media_exists_badge %}
|
||||||
|
<turbo-stream action="replace" targets="#{{ target }}">
|
||||||
|
<template>
|
||||||
|
{% if result.exists == true %}
|
||||||
|
<span data-controller="popover">
|
||||||
|
<template data-popover-target="content">
|
||||||
|
<div data-popover-target="card"
|
||||||
|
class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
|
||||||
|
<p class="font-bold text-sm text-left">Existing file(s) for this media:</p>
|
||||||
|
<ul class="list-disc ml-3">
|
||||||
|
<li class="font-normal">{{ result.file.filename|strip_media_path }} — <strong>{{ result.file.size|filesize }}</strong></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<small
|
||||||
|
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
|
||||||
|
data-action="mouseenter->popover#show mouseleave->popover#hide"
|
||||||
|
>
|
||||||
|
exists
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.exists == false %}
|
||||||
|
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white"
|
||||||
|
title="Media has not been downloaded yet.">
|
||||||
|
missing
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</template>
|
||||||
|
</turbo-stream>
|
||||||
|
{% endblock %}
|
||||||
@@ -49,9 +49,32 @@
|
|||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-gray-50">
|
<p class="text-gray-50">
|
||||||
{{ results.media.description }}
|
{{ results.media.description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if "movies" == results.media.mediaType %}
|
||||||
|
<div class="flex flex-row justify-start items-end grow">
|
||||||
|
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
|
||||||
|
<span id="movie_results_count">-</span> results
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Release date {{ results.media.episodeAirDate }}">
|
||||||
|
{{ results.media.episodeAirDate|date(null, 'UTC') }}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
|
||||||
|
title: results.media.title,
|
||||||
|
block: 'media_exists_badge',
|
||||||
|
target: "meb_" ~ results.media.imdbId
|
||||||
|
}) }}">
|
||||||
|
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
|
||||||
|
missing
|
||||||
|
</small>
|
||||||
|
</twig:Turbo:Frame>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,12 +85,18 @@
|
|||||||
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
|
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
|
||||||
data-movie-results-loading-icon-outlet=".loading-icon"
|
data-movie-results-loading-icon-outlet=".loading-icon"
|
||||||
>
|
>
|
||||||
|
<twig:Turbo:Frame id="movie_results_frame" src="{{ path('app_torrentio_movies', {
|
||||||
|
tmdbId: results.media.tmdbId,
|
||||||
|
imdbId: results.media.imdbId,
|
||||||
|
target: 'movie_results_frame',
|
||||||
|
block: 'movie_results'
|
||||||
|
}) }}" />
|
||||||
</div>
|
</div>
|
||||||
{% elseif "tvshows" == results.media.mediaType %}
|
{% elseif "tvshows" == results.media.mediaType %}
|
||||||
<twig:TvEpisodeList
|
<twig:TvEpisodeList
|
||||||
results="results"
|
results="results"
|
||||||
:imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1"
|
:imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1"
|
||||||
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer"
|
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer" :episodeNumber="results.episode"
|
||||||
/>
|
/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</twig:Card>
|
</twig:Card>
|
||||||
|
|||||||
21
templates/torrentio/fragments.html.twig
Normal file
21
templates/torrentio/fragments.html.twig
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% block movie_results %}
|
||||||
|
<turbo-stream action="replace" targets="#{{ target }}">
|
||||||
|
<template>
|
||||||
|
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
|
||||||
|
<div class="overflow-hidden rounded-md">
|
||||||
|
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</turbo-stream>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block tvshow_results %}
|
||||||
|
<turbo-stream action="replace" targets="#{{ target }}">
|
||||||
|
<template>
|
||||||
|
<div id="{{ target }}">
|
||||||
|
{{ include('torrentio/partial/option-table.html.twig', {controller: 'tv-results'}) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</turbo-stream>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends 'base.html.twig' %}
|
|
||||||
|
|
||||||
{% block title %}Hello TorrentioController!{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<style>
|
|
||||||
.example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
|
|
||||||
.example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="example-wrapper">
|
|
||||||
<h1>Hello {{ controller_name }}! ✅</h1>
|
|
||||||
|
|
||||||
This friendly message is coming from:
|
|
||||||
<ul>
|
|
||||||
<li>Your controller at <code>/var/www/src/Controller/TorrentioController.php</code></li>
|
|
||||||
<li>Your template at <code>/var/www/templates/torrentio/index.html.twig</code></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
|
<div class="p-4 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
|
||||||
{% if results.file != false %}
|
|
||||||
<div class="p-3 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
|
|
||||||
<p class="font-bold text-sm text-left">Existing file(s) for this movie:</p>
|
|
||||||
<ul class="list-disc ml-3 overflow-scroll">
|
|
||||||
<li class="font-normal">{{ results.file.realPath|strip_media_path }} — <strong>{{ results.file.size|filesize }}</strong></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="overflow-hidden rounded-md">
|
<div class="overflow-hidden rounded-md">
|
||||||
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
|
{{ include('torrentio/partial/option-table.html.twig', {controller: 'movie-results'}) }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user