Compare commits

..

6 Commits

19 changed files with 169 additions and 95 deletions

View File

@@ -2,6 +2,10 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
H264_CODECS = ['h264', 'h.264', 'x264'] H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc'] H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
#downloadBtnEl;
#selectEpisodeInputEl;
url;
size; size;
quality; quality;
resolution; resolution;
@@ -12,34 +16,39 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
mediaType; mediaType;
season; season;
episode; episode;
filename;
imdbId;
episodeId;
mediaTitle;
constructor() { constructor() {
super(); super();
this.url = this.getAttribute('url');
this.size = this.getAttribute('size'); this.size = this.getAttribute('size');
this.quality = this.getAttribute('quality'); this.quality = this.getAttribute('quality');
this.resolution = this.getAttribute('resolution'); this.resolution = this.getAttribute('resolution');
this.codec = this.getAttribute('codec'); this.codec = this.getAttribute('codec');
this.seeders = this.getAttribute('seeders'); this.seeders = this.getAttribute('seeders');
this.provider = this.getAttribute('provider'); this.provider = this.getAttribute('provider');
this.filename = this.getAttribute('filename');
this.imdbId = this.getAttribute('imdb-id');
this.languages = JSON.parse(this.getAttribute('languages')); this.languages = JSON.parse(this.getAttribute('languages'));
this.mediaType = this.getAttribute('media-type'); this.mediaType = this.getAttribute('media-type');
this.mediaTitle = this.getAttribute('media-title');
this.season = this.getAttribute('season') ?? null; this.season = this.getAttribute('season') ?? null;
this.episode = this.getAttribute('episode') ?? null; this.episode = this.getAttribute('episode') ?? null;
this.episodeId = this.getAttribute('episode-id') ?? null;
this.#downloadBtnEl = this.querySelector('.download-btn');
this.#selectEpisodeInputEl = this.querySelector('input[type="checkbox"]');
// document.addEventListener('filterDownloadOptions', this.filter.bind(this)); this.#downloadBtnEl.addEventListener('click', () => this.download());
} }
connectedCallback() { get isSelected() {
return this.#selectEpisodeInputEl.checked;
} }
// attribute change set isSelected(value) {
attributeChangedCallback(property, oldValue, newValue) { this.#selectEpisodeInputEl.checked = value;
if (oldValue === newValue) return;
this[ property ] = newValue;
}
static get observedAttributes() {
return ['size', 'quality', 'resolution', 'codec', 'seeders', 'provider'];
} }
filter({ detail: { activeFilter } }) { filter({ detail: { activeFilter } }) {
@@ -90,4 +99,26 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
return include; return include;
} }
download() {
fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
url: this.url,
title: this.mediaTitle,
filename: this.filename,
mediaType: this.mediaType,
imdbId: this.imdbId,
episodeId: this.episodeId
})
})
.then(res => res.json())
.then(json => {
console.log(json)
})
}
} }

View File

@@ -1,8 +1,6 @@
export default class EpisodeContainer extends HTMLElement { export default class EpisodeContainer extends HTMLElement {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
options = []; options = [];
showTitle;
#episodeSelectorEl; #episodeSelectorEl;
#resultsToggleBtnEl; #resultsToggleBtnEl;
@@ -12,6 +10,7 @@ export default class EpisodeContainer extends HTMLElement {
constructor() { constructor() {
super(); super();
this.showTitle = this.getAttribute('show-title');
this.#resultsTableEl = this.querySelector('.results-container'); this.#resultsTableEl = this.querySelector('.results-container');
this.#resultsToggleBtnEl = this.querySelector('.dropdown-button'); this.#resultsToggleBtnEl = this.querySelector('.dropdown-button');
this.#resultsCountBadgeEl = this.querySelector('.results-count-badge'); this.#resultsCountBadgeEl = this.querySelector('.results-count-badge');
@@ -22,21 +21,9 @@ export default class EpisodeContainer extends HTMLElement {
this.#resultsCountBadgeEl.addEventListener('click', () => this.toggleResults()); this.#resultsCountBadgeEl.addEventListener('click', () => this.toggleResults());
document.addEventListener('filterDownloadOptions', this.filter.bind(this)); document.addEventListener('filterDownloadOptions', this.filter.bind(this));
document.addEventListener('downloadSelectedEpisodes', this.downloadSelectedResults.bind(this));
document.addEventListener('selectEpisodeForDownload', (e) => this.selectEpisodeForDownload(e.detail.select)); document.addEventListener('selectEpisodeForDownload', (e) => this.selectEpisodeForDownload(e.detail.select));
} }
connectedCallback() {
}
// attribute change
attributeChangedCallback(property, oldValue, newValue) {
if (oldValue === newValue) return;
this[ property ] = newValue;
}
static get observedAttributes() {
return ['name'];
}
toggleResults() { toggleResults() {
this.#resultsToggleBtnEl.classList.toggle('rotate-90'); this.#resultsToggleBtnEl.classList.toggle('rotate-90');
@@ -50,6 +37,20 @@ export default class EpisodeContainer extends HTMLElement {
} }
} }
downloadSelectedResults() {
if (this.#episodeSelectorEl.disabled === false &&
this.#episodeSelectorEl.checked === true
) {
console.log('episode is selected')
this.options.forEach(option => {
if (option.isSelected === true) {
option.download();
}
option.isSelected = false;
})
}
}
filter({ detail: { activeFilter } }) { filter({ detail: { activeFilter } }) {
let firstIncluded = true; let firstIncluded = true;
let count = 0; let count = 0;

View File

@@ -1,7 +1,4 @@
export default class MovieContainer extends HTMLElement { export default class MovieContainer extends HTMLElement {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
#resultsTableEl; #resultsTableEl;
#resultsCountNumberEl; #resultsCountNumberEl;
@@ -13,12 +10,6 @@ export default class MovieContainer extends HTMLElement {
document.addEventListener('filterDownloadOptions', this.filter.bind(this)); document.addEventListener('filterDownloadOptions', this.filter.bind(this));
} }
// attribute change
attributeChangedCallback(property, oldValue, newValue) {
if (oldValue === newValue) return;
this[ property ] = newValue;
}
filter({ detail: { activeFilter } }) { filter({ detail: { activeFilter } }) {
const options = this.querySelectorAll('tr.download-option'); const options = this.querySelectorAll('tr.download-option');
let firstIncluded = true; let firstIncluded = true;

View File

@@ -6,9 +6,6 @@ import { Controller } from '@hotwired/stimulus';
*/ */
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = { static values = {
title: String, title: String,
tmdbId: String, tmdbId: String,

View File

@@ -19,7 +19,7 @@ export default class extends Controller {
"quality": "", "quality": "",
} }
static outlets = ['movie-results', 'tv-results', 'tv-episode-list'] static outlets = ['tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
static values = { static values = {
'imdbId': String, 'imdbId': String,
@@ -55,6 +55,10 @@ export default class extends Controller {
})); }));
} }
downloadSelectedEpisodes() {
document.dispatchEvent(new CustomEvent('downloadSelectedEpisodes', {}));
}
addLanguages(option) { addLanguages(option) {
const languages = Object.assign([], option.languages); const languages = Object.assign([], option.languages);
languages.forEach((language) => { languages.forEach((language) => {
@@ -164,10 +168,6 @@ export default class extends Controller {
this.tvEpisodeListOutlet.setSeason(event.target.value); this.tvEpisodeListOutlet.setSeason(event.target.value);
} }
uncheckSelectAllBtn() {
this.selectAllTarget.checked = false;
}
downloadSeason() { downloadSeason() {
fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, { fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, {
headers: { headers: {
@@ -175,13 +175,4 @@ export default class extends Controller {
} }
}) })
} }
downloadSelectedEpisodes() {
this.tvResultsOutlets.forEach(episode => {
if (episode.isActive() && episode.isSelected()) {
episode.download();
}
});
this.selectAllTarget.checked = false;
}
} }

View File

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

View File

@@ -18,7 +18,7 @@ export default class extends Controller {
active: Boolean, active: Boolean,
}; };
static targets = ['list', 'count', 'episodeSelector', 'toggleButton', 'listContainer'] static targets = ['list', 'count', 'episodeSelector',]
static outlets = ['loading-icon'] static outlets = ['loading-icon']
options = [] options = []
@@ -37,21 +37,4 @@ export default class extends Controller {
this.episodeSelectorTarget.disabled = true; this.episodeSelectorTarget.disabled = true;
} }
} }
isSelected() {
return this.episodeSelectorTarget.checked;
}
download() {
this.element.options.forEach(option => {
const optionSelector = option.querySelector('input[type="checkbox"]');
if (true === optionSelector.checked) {
const downloadBtn = option.querySelector('button.download-btn');
const downloadBtnController = this.application.getControllerForElementAndIdentifier(downloadBtn, 'download-button');
downloadBtnController.download();
optionSelector.checked = false;
this.episodeSelectorTarget.checked = false;
}
})
}
} }

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler; use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput; use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput; use App\Search\Action\Input\SearchInput;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand; use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
@@ -28,6 +29,13 @@ final class WebController extends AbstractController
): Response { ): Response {
$results = $this->searchHandler->handle($searchInput->toCommand()); $results = $this->searchHandler->handle($searchInput->toCommand());
if ($results instanceof RedirectToMediaResult) {
return $this->redirectToRoute('app_search_result', [
'mediaType' => $results->mediaType,
'imdbId' => $results->imdbId,
]);
}
return $this->render('search/results.html.twig', [ return $this->render('search/results.html.twig', [
'results' => $results, 'results' => $results,
]); ]);

View File

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

View File

@@ -185,6 +185,28 @@ class Tmdb
throw new \Exception("No results found for $id"); throw new \Exception("No results found for $id");
} }
public function findByImdbId(string $imdbId)
{
$finder = new Find($this->client);
$result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);
if (count($result['movie_results']) > 0) {
$result = $result['movie_results'][0];
$mediaType = MediaType::Movie->value;
} elseif (count($result['tv_results']) > 0) {
$result = $result['tv_results'][0];
$mediaType = MediaType::TvShow->value;
} elseif (count($result['tv_episode_results']) > 0) {
$result = $result['tv_episode_results'][0];
$mediaType = MediaType::TvShow->value;
}
$result['media_type'] = $mediaType;
$result = $this->mediaDetails($imdbId, $result['media_type']);
return $result;
}
public function movieDetails(string $id) public function movieDetails(string $id)
{ {
$client = new MovieRepository($this->client); $client = new MovieRepository($this->client);

View File

@@ -28,6 +28,7 @@ class GetTvShowOptionsHandler implements HandlerInterface
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode); $file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
return new GetTvShowOptionsResult( return new GetTvShowOptionsResult(
parentShow: $parentShow,
media: $media, media: $media,
file: MediaFileDto::fromSplFileInfo($file), file: MediaFileDto::fromSplFileInfo($file),
season: $command->season, season: $command->season,

View File

@@ -9,6 +9,7 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsResult implements ResultInterface class GetTvShowOptionsResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $parentShow,
public TmdbResult $media, public TmdbResult $media,
public MediaFileDto|false $file, public MediaFileDto|false $file,
public string $season, public string $season,

View File

@@ -16,10 +16,9 @@ class CodecList
public static function asSelectOptions(): array public static function asSelectOptions(): array
{ {
$result = []; return [
foreach (static::$codecs as $codec) { 'h264' => 'h264',
$result[$codec] = $codec; 'h265/HEVC' => 'h265',
} ];
return $result;
} }
} }

View File

@@ -20,6 +20,7 @@
> >
</select> </select>
<button <button
id="search-button"
class="absolute top-1 right-1 flex items-center rounded class="absolute top-1 right-1 flex items-center rounded
bg-green-600 py-1 px-2.5 border border-transparent text-center bg-green-600 py-1 px-2.5 border border-transparent text-center
text-sm text-white transition-all text-sm text-white transition-all

View File

@@ -4,6 +4,7 @@
<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.getEpisodes().items %} {% for episode in this.getEpisodes().items %}
<episode-container id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results" <episode-container id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results"
show-title="{{ this.title }}"
data-tv-results-loading-icon-outlet=".loading-icon" data-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', {

View File

@@ -43,6 +43,7 @@
{% for result in results.results %} {% for result in results.results %}
<tr is="dl-tr" <tr is="dl-tr"
class="download-option bg-white dark:bg-slate-700 flex flex-col flex-no wrap r-tablerow border-b border-gray-500" class="download-option bg-white dark:bg-slate-700 flex flex-col flex-no wrap r-tablerow border-b border-gray-500"
url="{{ result.url }}"
size="{{ result.size }}" size="{{ result.size }}"
quality="{{ result.quality }}" quality="{{ result.quality }}"
resolution="{{ result.resolution }}" resolution="{{ result.resolution }}"
@@ -51,10 +52,16 @@
provider="{{ result.provider }}" provider="{{ result.provider }}"
languages="{{ result.languages|json_encode }}" languages="{{ result.languages|json_encode }}"
media-type="{{ results.media.mediaType }}" media-type="{{ results.media.mediaType }}"
imdb-id="{{ results.media.imdbId }}"
filename="{{ result.filename }}"
data-local-id="{{ result.localId }}" data-local-id="{{ result.localId }}"
{% if "tvshows" == results.media.mediaType %} {% if "tvshows" == results.media.mediaType %}
season="{{ result.season }}" season="{{ result.season }}"
episode="{{ result.episodeNumber }}" episode="{{ result.episodeNumber }}"
episode-id="{{ episode_id(result.season, result.episodeNumber) }}"
media-title="{{ results.parentShow.title }}"
{% else %}
media-title="{{ results.media.title }}"
{% endif %} {% endif %}
> >
<td id="size" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50"> <td id="size" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
@@ -79,17 +86,7 @@
{{ result.languageFlags|raw }} {{ result.languageFlags|raw }}
</td> </td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-start mb:justify-end"> <td class="px-4 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-start mb:justify-end">
<button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50" <button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50">
{{ stimulus_controller('download_button', {
url: result.url,
title: results.media.title,
filename: result.filename,
mediaType: results.media.mediaType,
imdbId: results.media.imdbId ?? app.current_route_parameters.imdbId,
episodeId: results|episode_id_from_results
}) }}
{{ stimulus_action('download_button', 'download', 'click') }}
>
Download Download
</button> </button>
<label for="select"> <label for="select">