Compare commits

..

1 Commits

Author SHA1 Message Date
46deba2982 wip: active downloads in header 2025-05-14 16:18:33 -05:00
187 changed files with 1231 additions and 6155 deletions

9
.env
View File

@@ -15,7 +15,7 @@
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=prod
APP_ENV=dev
APP_SECRET=
###< symfony/framework-bundle ###
@@ -26,15 +26,12 @@ APP_SECRET=
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
REDIS_HOST=redis://redis

8
.env.dev Normal file
View File

@@ -0,0 +1,8 @@
DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
APP_SECRET=70169beadfbc8101c393cbfbba27a313
DOWNLOAD_DIR=./movies
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://dev.caldwell.digital/hub/.well-known/mercure
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
MONITOR_FREQUENCY="* * * * *"

31
.env.dist Normal file
View File

@@ -0,0 +1,31 @@
APP_ENV=%%app_env%%
APP_SECRET="%%app_secret%%"
DATABASE_URL="%%db_url%%"
DOWNLOAD_DIR=%%download_dir%%
REAL_DEBRID_KEY="%%rd_key%%"
TMDB_API=%%tmdb_api%%
MERCURE_URL=%%mercure_url%%
MERCURE_PUBLIC_URL=%%mercure_public_url%%
MERCURE_JWT_SECRET="%%mercure_jwt_secret%%"
JELLYFIN_URL=%%jellyfin_url%%
JELLYFIN_TOKEN=%%jellyfin_token%%
REDIS_HOST="%%redis_host%%"
LDAP_HOST=
LDAP_PORT=
LDAP_ENCRYPTION=
LDAP_BASE_DN=
LDAP_BIND_USER=
LDAP_BIND_PASS=
LDAP_DN_STRING=
LDAP_UID_KEY="uid"
# LDAP group that identifies an Admin
# Users with this LDAP group will automatically
# get the admin role in this system.
LDAP_ADMIN_ROLE_DN="cn=admins,cn=groups,cn=accounts,dc=caldwell,dc=local"
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_USERNAME_ATTRIBUTE=uid
LDAP_NAME_ATTRIBUTE=displayname

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
.idea
bolt.db
###> symfony/framework-bundle ###
/.env.local
/.env.local.php

View File

@@ -1,19 +1,10 @@
FROM dunglas/frankenphp:php8.4
FROM registry.caldwell.digital/library/php:8.4-apache
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV APP_VERSION="0.0.1"
RUN apt-get update && \
apt-get install libldap2-dev -y && \
rm -rf /var/lib/apt/lists/* && \
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \
docker-php-ext-install ldap
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
RUN apt update && apt install -y wget
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY --chmod=0755 docker/app/Caddyfile /etc/frankenphp/Caddyfile
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf

11
Dockerfile.prod Normal file
View File

@@ -0,0 +1,11 @@
FROM registry.caldwell.digital/library/php:8.4-apache
RUN apt-get update && \
apt-get install libldap2-dev -y && \
rm -rf /var/lib/apt/lists/* && \
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \
docker-php-ext-install ldap
COPY --chown=www-data:www-data . /var/www
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -1,50 +1,13 @@
# Torsearch
Torsearch is a "media acquisition tool" that works strictly with Real Debrid. Torsearch makes it easy to search for
and download your favorite movies and tv shows. You can think of it like Stremio, but without the streaming. Why the
comparison to Stremio? That's because Torsearch uses the same source for media files that Stremio uses: Torrentio
(hence the name: Torsearch).
After two failed attempts at running a media server, I decided to hang up my hat and give up my dream of a self-hosted
media server. I figured the days of torrenting were mostly over and everybody ranting & raving about their media collections
must be going to Walmart and buying up the bucket of old movies they have. That's until I stumbled across Stremio.
At first, it seemed too good to be true, but I was yearning for something just sketchy enough to try out. What could
go wrong with handing over my card information to an unknown organization across the pond? At the end of the day,
the cost benefit analysis landed in my favor, and about 30 minutes after purchasing my Real Debrid subscription and
setting up Stremio, I was in business.
My mind was blown. I might not have the most "cultured" taste in media, but it had everything I searched for and more! After
watching a few movies, I noticed the "Copy Download Link" button. "What's this lil guy do?" I asked myself. Duh, it
downloads the f*****g movie. And there's the 💡flashing over my head. There's gotta be a way to automate this, I told myself.
After a month of studying Stremio's code and lots of tinkering, I finally figured it out. Torrentio is the magic behind
the scenes. You feed it a Real Debrid API key and an IMDB ID, and it gives you a list of results to download that media.
Easy peasy.
In about an hour I had a proof of concept working. It wasn't pretty, but it wasn't supposed to be. That proof-of-concept
has blossomed into the beautiful Torsearch that I've been using nearly every day since then. The code in this repo
is a complete re-write of the proof-of-concept that started out ugly and ended up even uglier. Knowing the core functionality
required to make it work, I was able to re-write the app with some design patterns in place.
## Pics or didn't happen
![Torsearch Homepage](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_homepage.png)
![TV Show Result](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_severance.png)
![TV Show Episodes](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_severance_episodes.png)
![TV Show Episode Results](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_severance_results.png)
![TV Show Movie Results](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_inception_results.png)
## Features
- Search for Movies & TV Shows by their name
- Download directly to your NAS
- Monitor TV Shows for new episodes and automatically download them
- Browse popular media and view its download options
- LDAP or local auth (OIDC coming soon)
## Features on the roadmap
- Requests - allow users to request new media
- OIDC auth
- Prometheus logging
# Caldwell Digital - Symfony Template
Get up and running quickly with this Symfony framework template!
## Getting Started
1. Clone the repo
1. Run `source bash/get_certs.sh` to grab the wildcard certs
2. Set the docker image tag in the `bash/build.sh` file
3. Set the docker image tag in the `deploy.compose.yml` file
4. run `composer install`
## Optional steps
1. Set phing vars
2. Update the project name in `build.xml`
3. Set a custom development hostname in `bash/vhost.conf`

7
assets/bootstrap.js vendored
View File

@@ -1,10 +1,5 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover'
import Dialog from '@stimulus-components/dialog'
import Dropdown from '@stimulus-components/dropdown'
const app = startStimulusApp();
// register any custom, 3rd party controllers here
app.register('popover', Popover);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);
// app.register('some_controller_name', SomeImportedController);

View File

@@ -12,7 +12,6 @@ export default class extends Controller {
filename: String,
mediaType: String,
imdbId: String,
episodeId: String
}
download() {
@@ -28,7 +27,6 @@ export default class extends Controller {
filename: this.filenameValue,
mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue,
episodeId: this.episodeIdValue
})
})
.then(res => res.json())

View File

@@ -29,24 +29,6 @@ export default class extends Controller {
}
}
pauseDownload(data) {
fetch(`/api/download/${data.params.id}/pause`, {method: 'PATCH'})
.then(res => res.json())
.then(json => console.debug(json));
}
resumeDownload(data) {
fetch(`/api/download/${data.params.id}/resume`, {method: 'PATCH'})
.then(res => res.json())
.then(json => console.debug(json));
}
deleteDownload(data) {
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }

View File

@@ -1,48 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static outlets = ['navbar']
initialize() {
// Called once when the controller is first instantiated (per element)
// Here you can initialize variables, create scoped callables for event
// listeners, instantiate external libraries, etc.
// this._fooBar = this.fooBar.bind(this)
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
}
navbarOutletConnected(outlet) {
console.log(outlet)
}
toggleMenu() {
console.log(this.navbarOutlet);
this.navbarOutlet.toggle();
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -0,0 +1,24 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static values = {
title: String,
tmdbId: String,
imdbId: String,
mediaType: String,
}
addMovieMonitor() {
console.log(`/monitor/movies/${this.tmdbIdValue}/${this.imdbIdValue}/${encodeURI(this.titleValue)}`)
fetch(`/monitor/movies/${this.tmdbIdValue}/${this.imdbIdValue}/${encodeURI(this.titleValue)}`)
.then(res => res.json())
.then(json => {
console.log(json)
})
}
}

View File

@@ -1,14 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
deleteMonitor(data) {
fetch(`/api/monitor/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}
}

View File

@@ -17,15 +17,9 @@ export default class extends Controller {
link.className = this.activeStyles;
}
});
window.addEventListener("resize", (event) => {
});
}
toggle() {
this.element.parentElement.classList.toggle('hidden');
this.element.classList.toggle('fixed');
this.element.classList.toggle('z-20');
setActive() {
}
}

View File

@@ -20,7 +20,7 @@ export default class extends Controller {
"provider": "",
}
static outlets = ['movie-results', 'tv-results', 'tv-episode-list']
static outlets = ['movie-results', 'tv-results']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected']
static values = {
'media-type': String,
@@ -127,10 +127,6 @@ export default class extends Controller {
}
}
setSeason(event) {
this.tvEpisodeListOutlet.setSeason(event.target.value);
}
uncheckSelectAllBtn() {
this.selectAllTarget.checked = false;
}

View File

@@ -1,51 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
async initialize() {
this.component = await getComponent(this.element);
this.component.on('render:finished', (component) => {
console.log(component);
});
}
setSeason(season) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.set('pageNumber', 1);
this.component.set('season', parseInt(season));
this.component.render();
}
paginate(event) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.action('paginate', {page: event.params.page});
this.component.render();
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -18,43 +18,32 @@ export default class extends Controller {
active: Boolean,
};
static targets = ['list', 'count', 'episodeSelector', 'toggleButton', 'listContainer']
static targets = ['list', 'count', 'episodeSelector']
static outlets = ['loading-icon']
options = []
optionsLoaded = false
isOpen = false
async connect() {
await this.setOptions();
}
async setOptions() {
if (this.optionsLoaded === false) {
if (true === this.activeValue && this.optionsLoaded === false) {
this.optionsLoaded = true;
let response;
try {
response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
} catch (error) {
console.log('There was an error', error);
}
if (response?.ok) {
response = await response.text()
this.listContainerTarget.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
}
this.loadingIconOutlet.increaseCount();
} else {
console.log(`HTTP Response Code: ${response?.status}`)
}
await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
.then(response => {
this.element.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.episodeSelectorTarget.disabled = true;
}
this.loadingIconOutlet.increaseCount();
});
}
}
@@ -66,13 +55,19 @@ export default class extends Controller {
// }
async setActive() {
this.activeValue = true;
this.element.classList.remove('hidden');
if (false === this.optionsLoaded) {
await this.setOptions();
}
}
async setInActive() {
this.activeValue = false;
// if (true === this.hasEpisodeSelectorTarget()) {
this.episodeSelectorTarget.checked = false;
// }
this.element.classList.add('hidden');
}
isActive() {
@@ -90,10 +85,7 @@ export default class extends Controller {
}
toggleList() {
this.listTarget.classList.toggle('options-table');
this.listTarget.classList.toggle('hidden');
this.toggleButtonTarget.classList.toggle('rotate-90');
this.toggleButtonTarget.classList.toggle('-rotate-90');
}
download() {

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V4M7 14H5a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1h-2m-1-5l-4 5l-4-5m9 8h.01"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 4c-4.41 0-8 3.59-8 8s3.59 8 8 8s8-3.59 8-8s-3.59-8-8-8m5 11.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41L8.41 7L12 10.59L15.59 7L17 8.41L13.41 12z" opacity=".3"/><path fill="currentColor" d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m3.59-13L12 10.59L8.41 7L7 8.41L10.59 12L7 15.59L8.41 17L12 13.41L15.59 17L17 15.59L13.41 12L17 8.41z"/></svg>

Before

Width:  |  Height:  |  Size: 526 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><mask id="ipTPauseOne0"><g fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="4"><path fill="#555" d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4S4 12.954 4 24s8.954 20 20 20Z"/><path stroke-linecap="round" d="M19 18v12m10-12v12"/></g></mask></defs><path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTPauseOne0)"/></svg>

Before

Width:  |  Height:  |  Size: 407 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><mask id="ipTPlay0"><g fill="#555" stroke="#fff" stroke-linejoin="round" stroke-width="4"><path d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4S4 12.954 4 24s8.954 20 20 20Z"/><path d="M20 24v-6.928l6 3.464L32 24l-6 3.464l-6 3.464z"/></g></mask></defs><path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTPlay0)"/></svg>

Before

Width:  |  Height:  |  Size: 392 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0" stroke="currentColor" stroke-dasharray="64" stroke-dashoffset="64" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12c0 -4.97 4.03 -9 9 -9c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9c-4.97 0 -9 -4.03 -9 -9Z"><animate fill="freeze" attributeName="fill-opacity" begin="0.6s" dur="0.15s" values="0;0.3"/><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="64;0"/></path></svg>

Before

Width:  |  Height:  |  Size: 517 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 0 0"><path fill="currentColor" fill-rule="evenodd" d="M0 3.75A.75.75 0 0 1 .75 3h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 3.75M0 8a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8m.75 3.5a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 333 B

View File

@@ -10,70 +10,4 @@
h2 {
font-size: var(--text-xl);
}
.rounded-ms {
border-radius: 0.275rem;
}
}
@layer components {
.alert {
@apply text-white text-sm min-w-[250px] border px-4 py-3 rounded-md
}
.alert-success {
@apply bg-green-950 hover:bg-green-900 border-green-500
}
.alert-warning {
@apply bg-yellow-500/70 hover:bg-yellow-600 border-yellow-400 text-black
}
}
/* Prevent scrolling while dialog is open */
body:has(dialog[data-dialog-target="dialog"][open]) {
overflow: hidden;
}
/* Customize the dialog backdrop */
dialog {
box-shadow: 0 0 0 100vw rgb(0 0 0 / 0.5);
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* Add animations */
dialog[data-dialog-target="dialog"][open] {
animation: fade-in 200ms forwards;
}
dialog[data-dialog-target="dialog"][closing] {
animation: fade-out 200ms forwards;
}
.options-table {
display: flex;
:last-child {
border-bottom: none;
}
}
@media screen and (min-width: 768px) {
.options-table {
display: inline-table;
}
}

2
bash/app/wget_download.sh Executable file
View File

@@ -0,0 +1,2 @@
# $1 = movies/tvshows/etc, $2 = title of media, $3 = URL of download
cd /var/download/${1} && if [ ! -d "${2}" ]; then mkdir "${2}"; fi && cd "${2}" && wget "${3}"

View File

@@ -1,3 +1,2 @@
echo "> Building ${IMG} for linux/amd64"
docker buildx build --platform linux/amd64 -f Dockerfile.app -t "${IMG}-app" .
docker buildx build --platform linux/amd64 -f Dockerfile.worker -t "${IMG}-worker" .
docker buildx build --platform linux/amd64 -f Dockerfile.prod -t ${IMG} .

View File

@@ -1,3 +1,2 @@
echo "> Building ${IMG} for linux/arm/v8"
docker buildx build --platform linux/arm/v8 -f Dockerfile.app -t "${IMG}-app" .
docker buildx build --platform linux/arm/v8 -f Dockerfile.app -t "${IMG}-worker" .
docker buildx build --platform linux/arm/v8 -f Dockerfile.prod -t ${IMG} .

View File

@@ -1,3 +1,2 @@
echo "> Pushing ${IMG}"
docker push "${IMG}-app"
docker push "${IMG}-worker"
docker push ${IMG}

View File

@@ -2,5 +2,5 @@ dev.caldwell.digital:443
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
reverse_proxy app:80
reverse_proxy php:80

23
bash/vhost.conf Executable file
View File

@@ -0,0 +1,23 @@
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/public
DirectoryIndex /index.php
<LocationMatch "/hub/">
ProxyPass http://mercure:80/
ProxyPassReverse http://mercure:80/
</LocationMatch>
<Directory /var/www/public>
AllowOverride None
Order Allow,Deny
Allow from All
FallbackResource /index.php
</Directory>
<Directory /var/www/public/bundles>
FallbackResource disabled
</Directory>
</VirtualHost>

View File

@@ -12,56 +12,48 @@ services:
- $PWD/bash/caddy:/etc/caddy
- $PWD/bash/certs:/etc/ssl
app:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
- $PWD/var/download:/var/download
- mercure_data:/data
- mercure_config:/config
tty: true
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on:
database:
condition: service_healthy
worker:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
- $PWD/var/download:/var/download
tty: true
environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume media_cache -vv --time-limit=3600
scheduler:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume scheduler_monitor -vv
tty: true
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
php:
build: .
volumes:
- ./:/var/www
worker:
build: .
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume async -vvv --time-limit=3600
scheduler:
build: .
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3000:80"
environment:
TZ: America/Chicago
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
database:
@@ -71,24 +63,16 @@ services:
volumes:
- mysql:/var/lib/mysql
environment:
TZ: America/Chicago
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s
timeout: 5s
retries: 10
adminer:
image: adminer
ports:
- "8081:8080"
volumes:
mysql:
mercure_data:

View File

@@ -4,51 +4,44 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"php": ">=8.2",
"ext-ctype": "*",
"ext-iconv": "*",
"1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12",
"chrisullyott/php-filesize": "^4.2",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-fixtures-bundle": "^4.1",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4",
"guzzlehttp/guzzle": "^7.9",
"league/pipeline": "^1.1",
"nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-http/cache-plugin": "^2.0",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/filesystem": "7.3.*",
"symfony/finder": "7.3.*",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/filesystem": "7.2.*",
"symfony/finder": "7.2.*",
"symfony/flex": "^2",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/ldap": "7.3.*",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/ldap": "7.2.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/scheduler": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.3.*",
"symfony/twig-bundle": "7.2.*",
"symfony/ux-icons": "^2.24",
"symfony/ux-live-component": "^2.24",
"symfony/ux-turbo": "^2.24",
"symfony/ux-twig-component": "^2.24",
"symfony/yaml": "7.3.*",
"symfony/yaml": "7.2.*",
"symfonycasts/tailwind-bundle": "^0.10.0",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
@@ -59,9 +52,6 @@
"symfony/flex": true,
"symfony/runtime": true
},
"platform": {
"php": "8.4"
},
"bump-after-update": true,
"sort-packages": true
},
@@ -97,7 +87,7 @@
"post-update-cmd": [
"@auto-scripts"
],
"sym": "docker compose exec app ./bin/console"
"sym": "docker compose exec php ./bin/console"
},
"conflict": {
"symfony/symfony": "*"
@@ -105,13 +95,13 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.3.*"
"require": "7.2.*"
}
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
}
}

2179
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,4 @@ return [
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
];

View File

@@ -38,7 +38,6 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,

View File

@@ -36,7 +36,6 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,

View File

@@ -9,7 +9,7 @@ framework:
# Redis
app: cache.adapter.redis
default_redis_provider: '%app.cache.redis.host%'
default_redis_provider: '%env(REDIS_HOST)%'
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu

View File

@@ -11,7 +11,7 @@ framework:
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
session:
handler_id: '%env(REDIS_HOST)%'
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
#esi: true
#fragments: true

View File

@@ -1,8 +1,8 @@
mercure:
hubs:
default:
url: '%app.mercure.url%'
public_url: '%app.mercure.public_url%'
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'

View File

@@ -10,22 +10,10 @@ framework:
options:
use_notify: true
check_delayed_interval: 60000
queue_name: default
retry_strategy:
max_retries: 1
multiplier: 1
media_cache:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
queue_name: media_cache
retry_strategy:
max_retries: 1
multiplier: 1
failed: 'doctrine://default?queue_name=failed'
default_bus: messenger.bus.default
@@ -41,7 +29,6 @@ framework:
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async
'App\Monitor\Action\Command\MonitorMovieCommand': async
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': media_cache
# when@test:
# framework:

View File

@@ -1,3 +0,0 @@
framework:
property_info:
with_constructor_extractor: true

View File

@@ -19,11 +19,13 @@ security:
security: false
main:
lazy: true
provider: app_local
form_login:
provider: app_ldap
form_login_ldap:
login_path: app_login
check_path: app_login
enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
logout:
path: app_logout
@@ -36,7 +38,6 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,

View File

@@ -1,7 +0,0 @@
# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
# See the official DoctrineExtensions documentation for more details: https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
timestampable: true

View File

@@ -1,10 +1,5 @@
twig:
globals:
version: '%app.version%'
file_name_pattern: '*.twig'
date:
format: 'm/d/Y'
timezone: '%env(default:app.default.timezone:TZ)%'
when@test:
twig:

View File

@@ -4,29 +4,11 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
# Media
media.base_path: '/var/download'
media.default_movies_dir: movies
media.default_tvshows_dir: tvshows
media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%'
# Mercure
app.mercure.url: 'http://app/.well-known/mercure'
app.mercure.public_url: '%env(APP_URL)%/.well-known/mercure'
# Cache
app.cache.adapter: '%env(default:app.cache.adapter.default:CACHE_ADAPTER)%'
app.cache.redis.host: '%env(default:app.cache.redis.host.default:REDIS_HOST)%'
app.cache.adapter.default: 'filesystem'
app.cache.redis.host.default: 'redis://redis'
# Various configs
app.default.version: '0.dev'
app.default.timezone: 'America/Chicago'
app.version: '%env(default:app.default.version:APP_VERSION)%'
services:
# default configuration for services in *this* file
_defaults:
@@ -54,7 +36,6 @@ services:
# LDAP
App\User\Framework\Security\LdapUserProvider:
arguments:
$registerLdapUserHandler: '@App\User\Action\Handler\RegisterLdapUserHandler'
$userRepository: '@App\User\Framework\Repository\UserRepository'
$ldap: '@Symfony\Component\Ldap\LdapInterface'
$baseDn: '%env(LDAP_BASE_DN)%'

View File

@@ -1,58 +1,42 @@
services:
app:
image: registry.caldwell.digital/home/torsearch-app:${TAG}
php:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
ports:
- '8001:80'
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
tty: true
- "8001:80"
deploy:
replicas: 2
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
- mercure_data:/data
- mercure_config:/config
depends_on:
- database
worker:
image: registry.caldwell.digital/home/torsearch-worker:${TAG}
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv --time-limit=3600 --limit=10
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
deploy:
replicas: 2
depends_on:
- app
scheduler:
image: registry.caldwell.digital/home/torsearch-scheduler:${TAG}
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
depends_on:
- app
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3000:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
volumes:
mysql:
mercure_config:
mercure_data:
redis_data:

View File

@@ -2,7 +2,6 @@ assets
bash
bin
config
docker
migrations
public
src

View File

@@ -1,25 +0,0 @@
FROM dunglas/frankenphp
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app
COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile
ENTRYPOINT [ "/usr/local/bin/docker-entrypoint" ]
CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]

View File

@@ -1,21 +0,0 @@
FROM dunglas/frankenphp:php8.4-alpine
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0

View File

@@ -1,23 +0,0 @@
FROM dunglas/frankenphp:php8.4-alpine
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
RUN apk add --no-cache wget
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0

View File

@@ -1,59 +0,0 @@
{
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
{$FRANKENPHP_CONFIG}
}
}
{$CADDY_EXTRA_CONFIG}
{$SERVER_NAME:localhost} {
log {
{$CADDY_SERVER_LOG_OPTIONS}
# Redact the authorization query parameter that can be set by Mercure
format filter {
request>uri query {
replace authorization REDACTED
}
}
}
root /app/public
encode zstd br gzip
mercure {
# Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
# Allow anonymous subscribers (double-check that it's what you want)
anonymous
# Enable the subscription API (double-check that it's what you want)
subscriptions
# Custmo cors
cors_origins *
# Extra directives
{$MERCURE_EXTRA_DIRECTIVES}
}
vulcain
{$CADDY_SERVER_EXTRA_DIRECTIVES}
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
header ?Permissions-Policy "browsing-topics=()"
@phpRoute {
not path /.well-known/mercure*
not file {path}
}
rewrite @phpRoute index.php
@frontController path index.php
php @frontController
file_server {
hide *.php
}
}

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# Sleep for a second to ensure DB is awake and ready
SLEEP_TIME=$(shuf -i 2-4 -n 1)
echo "> Sleeping for ${SLEEP_TIME} seconds to wait for the database"
sleep $SLEEP_TIME
# Provision database
php /app/bin/console doctrine:migrations:migrate --no-interaction
php /app/bin/console db:seed
exec docker-php-entrypoint "$@"

View File

@@ -1,12 +0,0 @@
[Unit]
Description=Torsearch media cache warming services
[Service]
ExecStart=php /app/bin/console messenger:consume media_cache --time-limit=3600
# for Redis, set a custom consumer name for each instance
Environment="MESSENGER_CONSUMER_NAME=symfony-%n-%i"
Restart=always
RestartSec=30
[Install]
WantedBy=default.target

View File

@@ -1,57 +0,0 @@
# App must be served over HTTPS (requirement of Mercure)
# Either serve behind an SSL terminating reverse proxy
# or pass your certificates into the 'app' container.
# Please omit any trailing slashes. The APP_URL is
# used to generate the Mercure URL behind the scenes.
APP_URL="https://torsearch.idocode.io"
APP_SECRET="70169beadfbc8101c393cbfbba27a313"
APP_ENV=prod
# Use the DATABASE_URL below to use the MariaDB container
# provided in the example.compose.yml file, or remove this
# line and fill in the details of your own MySQL/MariaDB server
DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
# Fill in your MySQL/MariaDB connection details
#DATABASE_URL="mysql://<mysql user>:<mysql pass>@<mysql host>:3306/<mysql db name>?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
# Enter your Real Debrid API key
# This key is never saved anywhere
# else and is passed to Torrentio
# to retrieve download options
#REAL_DEBRID_KEY=""
# Enter you TMDB API key
# This is used to provide rich search results
# when searching for media and rendering the
# Popular Movies and TV Shows section.
#TMDB_API=
REAL_DEBRID_KEY=""
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# Use your own Redis instance or use the
# below value to use the container included
# in the example compose.yml file.
REDIS_HOST="redis://redis"
# LDAP Config: To use LDAP, enter the below fields
# and run 'php bin/console config:set auth.method ldap'
LDAP_HOST=
LDAP_PORT=
LDAP_ENCRYPTION=
LDAP_BASE_DN=
LDAP_BIND_USER=
LDAP_BIND_PASS=
LDAP_DN_STRING=
LDAP_UID_KEY="uid"
# LDAP group that identifies an Admin
# Users with this LDAP group will automatically
# get the admin role in this system.
LDAP_ADMIN_ROLE_DN=""
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_USERNAME_ATTRIBUTE=uid
LDAP_NAME_ATTRIBUTE=displayname

View File

@@ -1,99 +0,0 @@
services:
# The "entrypoint" into the application. This reverse proxy
# proxies traffic back to their respective services. If not
# running behind a reverse proxy inject your SSL certificates
# into this container.
# This container runs the actual web app in a php:8.4-fpm
# base container.
app:
image: code.caldwell.digital/home/torsearch-app:latest
ports:
- '8006:80'
env_file:
- .env
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on:
database:
condition: service_healthy
# Downloads happen in this container. Replicate this
# container to run multiple downloads simultaneously.
# Map your "movies" folder to /var/download/movies
# Map your "TV shows" folder to /var/download/tvshows
# If your folders are on another machine, use an NFS volume.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
worker:
image: code.caldwell.digital/home/torsearch-worker:latest
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
command: -vvv
env_file:
- .env
restart: always
depends_on:
app:
condition: service_healthy
# This container handles the monitoring for new media. When new
# monitors are added, jobs are periodically dispatched to this
# container, and the desired media is searched for and downloaded.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
env_file:
- .env
environment:
TZ: America/Chicago
restart: always
depends_on:
app:
condition: service_healthy
database:
image: mariadb:10.11.2
volumes:
- mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
# **Optional**
# Provides a simple method of viewing the database
adminer:
image: adminer
ports:
- "8081:8080"
volumes:
mysql:
mercure_config:
mercure_data:
redis_data:

View File

@@ -1,115 +0,0 @@
services:
# The "entrypoint" into the application. This reverse proxy
# proxies traffic back to their respective services. If not
# running behind a reverse proxy inject your SSL certificates
# into this container.
# This container runs the actual web app in a php:8.4-fpm
# base container.
app:
image: code.caldwell.digital/home/torsearch-app:latest
ports:
- '8006:80'
configs:
- env_file
deploy:
replicas: 2
depends_on:
- database
# Downloads happen in this container. Replicate this
# container to run multiple downloads simultaneously.
# Map your "movies" folder to /var/download/movies
# Map your "TV shows" folder to /var/download/tvshows
# If your folders are on another machine, use an NFS volume.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
worker:
image: code.caldwell.digital/home/torsearch-worker:latest
configs:
- source: env_file
target: /app/bin/.env.local
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
deploy:
replicas: 4
depends_on:
- app
# This container handles the monitoring for new media. When new
# monitors are added, jobs are periodically dispatched to this
# container, and the desired media is searched for and downloaded.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest
configs:
- env_file
volumes:
- ./downloads:/var/download
restart: always
depends_on:
- app
# This container facilitates viewing the progress of downloads
# in realtime. It also handles sending alerts and notifications.
# The MERCURE_PUBLISHER_JWT key & MERCURE_SUBSCRIBER_JWT_KEY should
# match the MERCURE_JWT_SECRET environment variable.
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3001:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
database:
image: mariadb:10.11.2
volumes:
- mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
# **Optional**
# Provides a simple method of viewing the database
adminer:
image: adminer
ports:
- "8081:8080"
volumes:
mysql:
mercure_config:
mercure_data:
redis_data:
configs:
env_file:
file: $PWD/.env

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

View File

@@ -28,23 +28,4 @@ return [
'@hotwired/turbo' => [
'version' => '7.3.0',
],
'@stimulus-components/popover' => [
'version' => '7.0.0',
],
'@stimulus-components/dialog' => [
'version' => '1.0.1',
],
'@stimulus-components/dropdown' => [
'version' => '3.0.0',
],
'stimulus-use' => [
'version' => '0.52.2',
],
'animate.css' => [
'version' => '4.1.1',
],
'animate.css/animate.min.css' => [
'version' => '4.1.1',
'type' => 'css',
],
];

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250519193350 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE preference ADD type VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE preference DROP type
SQL);
}
}

View File

@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250610195448 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE download ADD created_at DATETIME NULL, ADD updated_at DATETIME NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE download DROP created_at, DROP updated_at
SQL);
}
}

View File

@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250610222503 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD parent_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD CONSTRAINT FK_E1159985727ACA70 FOREIGN KEY (parent_id) REFERENCES monitor (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_E1159985727ACA70 ON monitor (parent_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP FOREIGN KEY FK_E1159985727ACA70
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_E1159985727ACA70 ON monitor
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP parent_id
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL
SQL);
}
}

View File

@@ -1,129 +0,0 @@
variable "image_tag" {
type = string
description = "Docker image tag to deploy."
default = "latest"
}
job "torsearch" {
datacenters = [ "home" ]
type = "service"
group "app" {
count = 2
update {
max_parallel = 4
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
network {
port "app" {
to = 80
}
}
task "app" {
driver = "docker"
config {
image = "registry.caldwell.digital/home/torsearch-app:${var.image_tag}"
ports = ["app"]
}
env {
MERCURE_PUBLISHER_JWT_KEY = "!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY = "!ChangeThisMercureHubJWTSecretKey!"
}
service {
name = "torsearch-app"
provider = "nomad"
port = "app"
meta {
nomad_ingress_enabled = true
nomad_ingress_hostname = "torsearch-nomad.caldwell.digital"
}
}
}
}
group "worker" {
count = 2
update {
max_parallel = 4
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
volume "media" {
type = "host"
source = "media"
read_only = false
}
task "worker" {
driver = "docker"
volume_mount {
volume = "media"
destination = "/var/download"
read_only = false
}
config {
image = "registry.caldwell.digital/home/torsearch-worker:${var.image_tag}"
args = [
"-vv"
]
}
service {
name = "torsearch-worker"
provider = "nomad"
}
}
}
group "scheduler" {
count = 1
update {
max_parallel = 2
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
volume "media" {
type = "host"
source = "media"
read_only = false
}
task "scheduler" {
driver = "docker"
volume_mount {
volume = "media"
destination = "/var/download"
read_only = false
}
config {
image = "registry.caldwell.digital/home/torsearch-scheduler:${var.image_tag}"
args = [
"-vv"
]
}
service {
name = "torsearch-scheduler"
provider = "nomad"
}
}
}
}

View File

@@ -1,171 +0,0 @@
<?php
namespace App\Command;
use App\User\Framework\Repository\PreferenceOptionRepository;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'db:seed',
description: 'Seed the database with required data.',
)]
class SeedDatabaseCommand extends Command
{
private PreferencesRepository $preferenceRepository;
private PreferenceOptionRepository $preferenceOptionRepository;
public function __construct(
PreferencesRepository $preferenceRepository,
PreferenceOptionRepository $preferenceOptionRepository,
) {
parent::__construct();
$this->preferenceRepository = $preferenceRepository;
$this->preferenceOptionRepository = $preferenceOptionRepository;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->seedPreferences($io);
$this->seedPreferenceOptions($io);
return Command::SUCCESS;
}
private function seedPreferences(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Seeding preferences...');
$preferences = $this->getPreferences();
foreach ($preferences as $preference) {
if ($this->preferenceRepository->find($preference['id'])) {
continue;
}
$this->preferenceRepository->getEntityManager()->persist((new \App\User\Framework\Entity\Preference())
->setId($preference['id'])
->setName($preference['name'])
->setDescription($preference['description'])
->setEnabled($preference['enabled'])
->setType($preference['type'])
);
}
$this->preferenceRepository->getEntityManager()->flush();
}
private function getPreferences(): array
{
return [
[
'id' => 'codec',
'name' => 'Codec',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'resolution',
'name' => 'Resolution',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'language',
'name' => 'Language',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'provider',
'name' => 'Provider',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'movie_folder',
'name' => 'Create new folder for Movies',
'description' => 'When downloading a movie, store it in a new folder in your base \'movies\' folder. (e.g.: .../movies/Inception/Inception.2160p.h265.mkv)',
'enabled' => true,
'type' => 'download'
],
];
}
private function seedPreferenceOptions(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Seeding preference options...');
$options = $this->getPreferenceOptions();
foreach ($options as $option) {
if ($this->preferenceOptionRepository->findBy([
'preference' => $option['preference_id'],
'name' => $option['name'],
'value' => $option['value'],
'enabled' => $option['enabled'],
])) {
continue;
}
$this->preferenceOptionRepository->getEntityManager()->persist(
(new \App\User\Framework\Entity\PreferenceOption())
->setPreference($this->preferenceRepository->find($option['preference_id']))
->setName($option['name'])
->setValue($option['value'])
->setEnabled($option['enabled'])
);
}
$this->preferenceOptionRepository->getEntityManager()->flush();
}
private function getPreferenceOptions(): array
{
return [
[
'preference_id' => 'resolution',
'name' => '720p',
'value' => '720p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '1080p',
'value' => '1080p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '2160p',
'value' => '2160p',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => '-',
'value' => '-',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h264',
'value' => 'h264',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h265/HEVC',
'value' => 'h265',
'enabled' => true
]
];
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Command;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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\Style\SymfonyStyle;
#[AsCommand(
name: 'startup:status',
description: 'Add a short description for your command',
)]
class StartupStatusCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$ready = true;
$messengerTableExists = $this->doesMessengerTableExist();
if (false === $messengerTableExists) {
$ready = false;
}
return (true === $ready) ? Command::SUCCESS : Command::FAILURE;
}
private function doesMessengerTableExist()
{
return $this->entityManager->getConnection()
->createSchemaManager()
->tablesExist('messenger_messages');
}
}

View File

@@ -2,25 +2,34 @@
namespace App\Controller;
use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
final class AlertController extends AbstractController
{
public function __construct(
private readonly Broadcaster $broadcaster,
#[Autowire(service: 'twig')] private readonly Environment $renderer,
private readonly HubInterface $hub,
) {}
#[Route('/alert', name: 'app_alert')]
public function index(): Response
{
$this->broadcaster->alert(
'Added to queue',
'This is a testy test!'
$update = new Update(
'alerts',
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => 1,
'title' => 'Added to queue',
'message' => 'This is a testy test!',
])
);
$this->hub->publish($update);
return $this->json([
'Success' => 'Published'
]);

View File

@@ -3,8 +3,6 @@
namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\Tmdb;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -14,13 +12,15 @@ use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends AbstractController
{
public function __construct(
private readonly DownloadRepository $downloadRepository,
private readonly Tmdb $tmdb,
private readonly MonitorTvShowHandler $monitorTvShowHandler,
) {}
#[Route('/', name: 'app_index')]
public function index(Request $request): Response
{
$request->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(),
@@ -28,11 +28,4 @@ final class IndexController extends AbstractController
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]);
}
#[Route('/test', name: 'app_test')]
public function test()
{
$result = $this->monitorTvShowHandler->handle(new MonitorTvShowCommand(355));
return $this->json($result);
}
}

View File

@@ -6,20 +6,18 @@ use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class SearchController extends AbstractController
{
public function __construct(
private SearchHandler $searchHandler,
private GetMediaInfoHandler $getMediaInfoHandler,
private MessageBusInterface $bus,
) {}
#[Route('/search', name: 'app_search', methods: ['GET'])]
@@ -33,13 +31,11 @@ final class SearchController extends AbstractController
]);
}
#[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')]
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $input,
?int $season = null,
): Response {
$result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [
@@ -52,32 +48,4 @@ final class SearchController extends AbstractController
]
]);
}
private function warmDownloadOptionCache(TmdbResult $result)
{
if ($result->mediaType === 'tvshows') {
// dispatches a job to get the download options
// for each episode and load them in cache
foreach ($result->episodes as $season => $episodes) {
// Only do the first 2 seasons, so we reduce
// getting rate-limited by Torrentio
if ($season > 2) {
return;
}
foreach ($episodes as $episode) {
$this->bus->dispatch(new GetTvShowOptionsCommand(
tmdbId: $result->tmdbId,
imdbId: $result->imdbId,
season: $season,
episode: $episode['episode_number'],
));
}
}
} elseif ($result->mediaType === 'movies') {
$this->bus->dispatch(new GetMovieOptionsCommand(
$result->tmdbId,
$result->imdbId,
));
}
}
}

View File

@@ -6,12 +6,12 @@ use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Exception\TorrentioRateLimitException;
use App\Util\Broadcaster;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -21,7 +21,8 @@ final class TorrentioController extends AbstractController
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly Broadcaster $broadcaster,
private readonly HubInterface $hub,
private readonly \Twig\Environment $renderer,
) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
@@ -53,24 +54,13 @@ final class TorrentioController extends AbstractController
$input->episode,
);
try {
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
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,
]);
});
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
@@ -85,10 +75,14 @@ final class TorrentioController extends AbstractController
);
$cache->delete($cacheId);
$this->broadcaster->alert(
title: 'Success',
message: 'Torrentio cache Cleared.'
);
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Torrentio cache Cleared.',
])
));
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DeleteDownloadCommand>
*/
class DeleteDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<PauseDownloadCommand>
*/
class PauseDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<ResumeDownloadCommand>
*/
class ResumeDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\DeleteDownloadCommand;
use App\Download\Action\Result\DeleteDownloadResult;
use App\Download\Framework\Repository\DownloadRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<DeleteDownloadCommand, DeleteDownloadResult> */
readonly class DeleteDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$download = $this->downloadRepository->find($command->downloadId);
$this->downloadRepository->delete($command->downloadId);
return new DeleteDownloadResult(200, 'Success', $download);
}
}

View File

@@ -37,10 +37,10 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId);
}
dump($download);
try {
if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
}
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
$this->downloader->download(
$command->mediaType,
@@ -49,9 +49,7 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download->getId()
);
if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
}
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
} catch (\Throwable $exception) {
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\PauseDownloadCommand;
use App\Download\Action\Result\PauseDownloadResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Contracts\Cache\CacheInterface;
/** @implements HandlerInterface<PauseDownloadCommand, PauseDownloadResult> */
readonly class PauseDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private CacheInterface $cache,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $download */
$download = $this->downloadRepository->find($command->downloadId);
$this->cache->get('download.pause.' . $download->getId(), function () {
return true;
});
return new PauseDownloadResult(200, 'Success', $download);
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\ResumeDownloadCommand;
use App\Download\Action\Result\ResumeDownloadResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<ResumeDownloadCommand, ResumeDownloadResult> */
readonly class ResumeDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $messageBus,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $download */
$download = $this->downloadRepository->find($command->downloadId);
$this->messageBus->dispatch(new DownloadMediaCommand(
$download->getUrl(),
$download->getTitle(),
$download->getFilename(),
$download->getMediaType(),
$download->getImdbId(),
$download->getUser()->getId(),
$download->getId()
));
return new ResumeDownloadResult(200, 'Success', $download);
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\DeleteDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DeleteDownloadInput> */
class DeleteDownloadInput implements InputInterface
{
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
) {}
public function toCommand(): CommandInterface
{
return new DeleteDownloadCommand(
$this->downloadId,
);
}
}

View File

@@ -26,9 +26,6 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('episodeId', nullify: true)]
public ?string $episodeId = null,
public ?int $userId = null,
public ?int $downloadId = null,

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\PauseDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<PauseDownloadInput, PauseDownloadCommand> */
class PauseDownloadInput implements InputInterface
{
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
) {}
public function toCommand(): CommandInterface
{
return new PauseDownloadCommand(
$this->downloadId,
);
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\PauseDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<PauseDownloadInput, PauseDownloadCommand> */
class ResumeDownloadInput implements InputInterface
{
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
) {}
public function toCommand(): CommandInterface
{
return new PauseDownloadCommand(
$this->downloadId,
);
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Download\Action\Result;
use App\Download\Framework\Entity\Download;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<DownloadMediaResult> */
class DeleteDownloadResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
public Download $download,
) {}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Download\Action\Result;
use App\Download\Framework\Entity\Download;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<PauseDownloadResult> */
class PauseDownloadResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
public Download $download,
) {}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Download\Action\Result;
use App\Download\Framework\Entity\Download;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<ResumeDownloadResult> */
class ResumeDownloadResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
public Download $download,
) {}
}

View File

@@ -10,11 +10,11 @@ use App\Message\DownloadTvShowMessage;
interface DownloaderInterface
{
/**
* @param string $mediaType
* @param string $baseDir
* @param string $title
* @param string $url
* @return void
* Downloads the requested file.
*/
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void;
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void;
}

View File

@@ -3,49 +3,33 @@
namespace App\Download\Downloader;
use App\Download\Framework\Entity\Download;
use App\Monitor\Service\MediaFiles;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Symfony\Contracts\Cache\CacheInterface;
class ProcessDownloader implements DownloaderInterface
{
/**
* @var RedisAdapter $cache
*/
public function __construct(
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private CacheInterface $cache,
) {}
/**
* @inheritDoc
*/
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void
{
/** @var Download $downloadEntity */
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
$downloadEntity->setProgress(0);
$this->entityManager->flush();
$downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences();
$path = $this->getDownloadPath($mediaType, $title, $downloadPreferences);
$processArgs = ['wget', $url];
if ($downloadEntity->getStatus() === 'Paused') {
$downloadEntity->setStatus('In Progress');
$processArgs = ['wget', '-c', $url];
} else {
$downloadEntity->setProgress(0);
}
fwrite(STDOUT, implode(" ", $processArgs));
$process = (new Process($processArgs))->setWorkingDirectory($path);
$process = new Process([
'/bin/sh',
'/var/www/bash/app/wget_download.sh',
$baseDir,
$title,
$url
]);
$process->setTimeout(1800); // 30 min
$process->setIdleTimeout(600); // 10 min
@@ -55,18 +39,7 @@ class ProcessDownloader implements DownloaderInterface
try {
$progress = 0;
$this->entityManager->flush();
$process->wait(function ($type, $buffer) use ($progress, $downloadEntity, $process): void {
// The PauseDownloadHandler will set this to 'true'
$doPause = $this->cache->getItem('download.pause.' . $downloadEntity->getId());
if (true === $doPause->isHit() && true === $doPause->get()) {
$downloadEntity->setStatus('Paused');
$this->cache->deleteItem('download.pause.' . $downloadEntity->getId());
$this->entityManager->flush();
$process->stop();
}
$process->wait(function ($type, $buffer) use ($progress, $downloadEntity): void {
if (Process::ERR === $type) {
$pregMatchOutput = [];
preg_match('/[\d]+%/', $buffer, $pregMatchOutput);
@@ -81,29 +54,11 @@ class ProcessDownloader implements DownloaderInterface
}
fwrite(STDOUT, $buffer);
});
if ($downloadEntity->getStatus() !== 'Paused') {
$downloadEntity->setProgress(100);
}
$downloadEntity->setProgress(100);
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
}
$this->entityManager->flush();
}
public function getDownloadPath(string $mediaType, string $title, array $downloadPreferences): string
{
if ($mediaType === 'movies') {
if ((bool) $downloadPreferences['movie_folder']->getPreferenceValue() === true) {
return $this->mediaFiles->createMovieDirectory($title);
}
return $this->mediaFiles->getMoviesPath();
}
if ($mediaType === 'tvshows') {
return $this->mediaFiles->createTvShowDirectory($title);
}
throw new \Exception("There is no download path for media type: $mediaType");
}
}

View File

@@ -2,18 +2,13 @@
namespace App\Download\Framework\Controller;
use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Handler\PauseDownloadHandler;
use App\Download\Action\Handler\ResumeDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Broadcaster;
use Nihilarr\PTN;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -22,20 +17,14 @@ class ApiController extends AbstractController
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly Broadcaster $broadcaster,
private readonly HubInterface $hub,
) {}
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
public function download(
Request $request,
DownloadMediaInput $input,
): Response {
$ptn = (object) new Ptn()->parse($input->filename);
if ($input->mediaType === "tvshows" &&
!property_exists($ptn, 'episode') && !property_exists($ptn, 'season')
) {
$input->filename = $input->episodeId . '_' . $input->filename;
}
$download = $this->downloadRepository->insert(
$this->getUser(),
$input->url,
@@ -56,53 +45,15 @@ class ApiController extends AbstractController
return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
$this->broadcaster->alert(
title: 'Success',
message: "$input->title added to Queue."
);
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderView('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => '"' . $input->title . '" added to Queue',
])
));
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
}
#[Route('/api/download/{downloadId}', name: 'api_download_delete', methods: ['DELETE'])]
public function deleteDownload(
DeleteDownloadInput $input,
DeleteDownloadHandler $handler,
): Response {
$result = $handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: "{$result->download->getTitle()} has been deleted.",
);
return $this->json(['status' => 200, 'message' => 'Download Deleted']);
}
#[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])]
public function pauseDownload(
PauseDownloadInput $input,
PauseDownloadHandler $handler,
): Response {
$result = $handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: "{$result->download->getTitle()} has been Paused.",
);
return $this->json(['status' => 200, 'message' => 'Download Paused']);
}
#[Route('/api/download/{downloadId}/resume', name: 'api_download_resume', methods: ['PATCH'])]
public function resumeDownload(
ResumeDownloadInput $input,
ResumeDownloadHandler $handler,
): Response {
$result = $handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: "{$result->download->getTitle()} has been Resumed.",
);
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
}
}
}

View File

@@ -5,16 +5,12 @@ namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Nihilarr\PTN;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
#[Broadcast(template: 'broadcast/Download.stream.html.twig')]
class Download
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -166,16 +162,4 @@ class Download
return $this;
}
public function getPtn(): object
{
$ptn = (object) (new PTN())->parse($this->filename);
if ($this->mediaType === "tvshows") {
$ptn->season = str_pad($ptn->season, 2, "0", STR_PAD_LEFT);
$ptn->episode = str_pad($ptn->episode, 2, "0", STR_PAD_LEFT);
}
return $ptn;
}
}

View File

@@ -25,31 +25,27 @@ class DownloadRepository extends ServiceEntityRepository
$this->paginator = $paginator;
}
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10, string $term = ""): Paginator
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10)
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->andWhere('(d.title LIKE :term OR d.filename LIKE :term OR d.imdbId LIKE :term OR d.status LIKE :term OR d.mediaType LIKE :term)')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete'])
->setParameter('user', $user)
->setParameter('term', '%' . $term . '%')
->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5, string $term = ""): Paginator
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5)
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->andWhere('(d.title LIKE :term OR d.filename LIKE :term OR d.imdbId LIKE :term OR d.status LIKE :term OR d.mediaType LIKE :term)')
->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress', 'Paused'])
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('user', $user)
->setParameter('term', '%' . $term . '%')
->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage);

View File

@@ -1,13 +0,0 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<DeleteMonitorCommand> */
class DeleteMonitorCommand implements CommandInterface
{
public function __construct(
public string $monitorId,
) {}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\AddMonitorCommand;
use App\Monitor\Action\Command\DeleteMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult;
use App\Monitor\Action\Result\DeleteMonitorResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<DeleteMonitorCommand, DeleteMonitorResult> */
readonly class DeleteMonitorHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$monitor = $this->monitorRepository->find($command->monitorId);
$this->monitorRepository->getEntityManager()->remove($monitor);
$this->monitorRepository->getEntityManager()->flush();
return new DeleteMonitorResult(
status: 'OK',
result: [],
monitor: $monitor
);
}
}

View File

@@ -5,13 +5,10 @@ namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use Carbon\Carbon;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
@@ -30,26 +27,12 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
private MessageBusInterface $bus,
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
private Tmdb $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler');
$monitor = $this->monitorRepository->find($command->movieMonitorId);
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::now()) {
$this->logger->info('> [MonitorTvEpisodeHandler] Episode has not aired yet, skipping for now');
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'message' => 'No change',
'monitor' => $monitor,
]
);
}
$monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
@@ -88,7 +71,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->flush();
return new MonitorTvEpisodeResult(
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,

View File

@@ -3,9 +3,9 @@
namespace App\Monitor\Action\Handler;
use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Result\MonitorTvSeasonResult;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
@@ -17,14 +17,16 @@ use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorTvSeasonCommand> */
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvSeasonHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Tmdb $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
@@ -39,42 +41,33 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode))
->filter(fn ($episode) =>
property_exists($episode, 'episode')
&& property_exists($episode, 'season')
&& null !== $episode->episode
&& null !== $episode->season
)
->rekey(fn($episode) => $episode->episode);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB
$episodesInSeason = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId())->episodes[$monitor->getSeason()]
$this->tmdb->tvDetails($monitor->getTmdbId())
->episodes[$monitor->getSeason()]
)->rekey(fn($episode) => $episode['episode_number']);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($episodesInSeason) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
if ($downloadedEpisodes->count() !== $episodesInSeason->count()) {
// Dispatch Episode commands for each missing Episode
foreach ($episodesInSeason as $episode) {
// Check if the episode is already downloaded
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
$this->logger->info('> [MonitorTvSeasonHandler] Episode exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) {
$this->logger->info('> [MonitorTvSeasonHandler] Episode exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
// Dispatch Episode commands for each missing Episode
foreach ($episodesInSeason as $episode) {
$monitorCheck = $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $monitor->getSeason(),
'episode' => $episode['episode_number'],
'status' => ['New', 'Active', 'In Progress']
]);
// Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) {
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for season ' . $monitor->getSeason() . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (null !== $monitorCheck ? 'YES' : 'NO'));
if (!array_key_exists($episode['episode_number'], $downloadedEpisodes->toArray())
&& null === $monitorCheck
) {
$episodeMonitor = (new Monitor())
->setParent($monitor)
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
@@ -95,38 +88,16 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
}
}
// Set the status to Active, so it will be re-executed.
$monitor->setStatus('Active');
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush();
return new MonitorTvSeasonResult(
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
private function episodeExists(array $episodeInShow, Map $downloadedEpisodes): bool
{
return $downloadedEpisodes->filter(
fn (object $episode) => $episode->episode === $episodeInShow['episode_number']
&& $episode->season === $episodeInShow['season_number']
)->count() > 0;
}
private function monitorExists(Monitor $monitor, array $episode): bool
{
return $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $episode['season_number'],
'episode' => $episode['episode_number'],
'status' => ['New', 'Active', 'In Progress']
]) !== null;
}
}

View File

@@ -5,12 +5,11 @@ namespace App\Monitor\Action\Handler;
use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use Carbon\Carbon;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
@@ -18,6 +17,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvShowHandler implements HandlerInterface
@@ -25,8 +25,8 @@ readonly class MonitorTvShowHandler implements HandlerInterface
public function __construct(
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Tmdb $tmdb,
) {}
@@ -39,116 +39,55 @@ readonly class MonitorTvShowHandler implements HandlerInterface
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode))
->filter(fn ($episode) =>
property_exists($episode, 'episode')
&& property_exists($episode, 'season')
&& null !== $episode->episode
&& null !== $episode->season
)
;
->map(fn($episode) => (object) (new PTN())->parse($episode));
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB
$episodesInShow = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId())->episodes
$this->tmdb->tvDetails($monitor->getTmdbId())
->episodes
)->flat(1);
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle());
// Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
$episodeAlreadyDownloaded = $downloadedEpisodes->find(
fn($ep) => $ep->episode === $episode['episode_number'] && $ep->season === $episode['season_number']
);
$episodeAlreadyDownloaded = !is_null($episodeAlreadyDownloaded);
$episodeMonitors = [];
if ($downloadedEpisodes->count() !== $episodesInShow->count()) {
// Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
// Only monitor future episodes
$episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode);
$this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeInFuture ? 'YES' : 'NO'));
if (false === $episodeInFuture) {
$this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for title: ' . 'for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ', skipping');
continue;
}
// Check if the episode is already downloaded
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
$this->logger->info('> [MonitorTvShowHandler] Episode exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) {
$this->logger->info('> [MonitorTvShowHandler] Episode exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
// Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvShowHandler] Monitor exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) {
$this->logger->info('> [MonitorTvShowHandler] Monitor exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
// Create the monitor
$episodeMonitor = (new Monitor())
->setParent($monitor)
if (false === $episodeAlreadyDownloaded) {
$monitor = (new Monitor())
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvepisode')
->setMonitorType('tvshow')
->setSeason($episode['season_number'])
->setEpisode($episode['episode_number'])
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($episodeMonitor);
$this->monitorRepository->getEntityManager()->persist($monitor);
$this->monitorRepository->getEntityManager()->flush();
$episodeMonitors[] = $episodeMonitor;
// Immediately run the monitor
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $episodeMonitor->getSeason() . ' episode ' . $episodeMonitor->getEpisode() . ' for title: ' . $monitor->getTitle());
$command = new MonitorTvEpisodeCommand($monitor->getId());
$this->bus->dispatch($command);
$this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' for title: ' . $monitor->getTitle());
}
}
// Set the status to Active, so it will be re-executed.
$monitor->setStatus('Active');
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush();
return new MonitorTvShowResult(
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'monitor' => $monitor,
'new_monitors' => $episodeMonitors,
]
);
}
private function episodeReleasedAfterMonitorCreated(string|DateTimeImmutable $monitorStartDate, array $episodeInShow): bool
{
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
$episodeAirDate = Carbon::parse($episodeInShow['air_date']);
return $episodeAirDate >= $monitorStartDate;
}
private function episodeExists(array $episodeInShow, Map $downloadedEpisodes): bool
{
return $downloadedEpisodes->filter(
fn (object $episode) => $episode->episode === $episodeInShow['episode_number']
&& $episode->season === $episodeInShow['season_number']
)->count() > 0;
}
private function monitorExists(Monitor $monitor, array $episode): bool
{
return $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $episode['season_number'],
'episode' => $episode['episode_number'],
'status' => ['New', 'Active', 'In Progress']
]) !== null;
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Monitor\Action\Input;
use App\Monitor\Action\Command\DeleteMonitorCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DeleteMonitorInput, DeleteMonitorCommand> */
class DeleteMonitorInput implements InputInterface
{
public function __construct(
#[SourceRoute('monitorId')]
public int $monitorId,
) {}
public function toCommand(): CommandInterface
{
return new DeleteMonitorCommand(
$this->monitorId
);
}
}

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