Compare commits

..

61 Commits

Author SHA1 Message Date
5ca0cd651d wip: toggling visibility of newly added download 2025-06-04 22:12:06 -05:00
b6bc1645b4 fix: centers download progress in progress bar 2025-06-03 21:52:49 -05:00
16023f1a26 fix: uses frankingphp:8.4-alpine image locally 2025-06-03 11:15:01 -05:00
725034dd4e fix: shows download progress 2025-06-03 11:14:38 -05:00
234aeab120 fix: monitor row being styled incorrectly on update 2025-06-02 10:42:56 -05:00
044df00982 fix: broad implementation of broadcaster 2025-06-01 20:34:39 -05:00
92015feaac fix: missing monitor alert 2025-06-01 20:14:34 -05:00
5b704d9b36 fix: missing monitor alert 2025-06-01 20:14:25 -05:00
4f72481829 fix: orders monitor desc 2025-06-01 16:16:01 -05:00
4ee338e397 upgrade to Symfony 7.3, style updates 2025-06-01 15:58:04 -05:00
08d28d9a4f fix: splits monitor widget types to complete and active 2025-06-01 14:32:15 -05:00
b17313c8fb chore: adds 'asPagiator' utility function 2025-06-01 14:13:53 -05:00
f2b50b4f60 fix: uses paginate trait on download list 2025-06-01 14:08:29 -05:00
393e3ef41f fix: monitors page & pagination 2025-06-01 14:07:29 -05:00
b1ccf3bf00 feat: delete monitors 2025-06-01 09:25:12 -05:00
785794790c fix: adds delete button to broadcasted element 2025-05-31 22:46:31 -05:00
c0f67a32ff feat: delete downloads from DB 2025-05-31 22:21:11 -05:00
325e4e14a7 fix: removes default movies & tvshows paths from .env 2025-05-31 11:46:36 -05:00
f9017297f3 feat: adds nomad deployment file 2025-05-28 18:59:32 -05:00
0c4def42a2 fix: proper flag passed to worker 2025-05-26 09:51:31 -05:00
3100912927 fix: disabled caddy worker mode 2025-05-26 09:39:17 -05:00
07553a172f fix: sets limits on worker 2025-05-25 23:30:31 -05:00
a88da4f637 fix(local deploy): uncomments line 2025-05-25 22:52:51 -05:00
a88f128ccb fix(local deploy): uses correct Caddyfile 2025-05-25 21:22:07 -05:00
b1ec344f42 fix(local deploy): correct bind mounts on scheduler 2025-05-25 20:50:26 -05:00
1ee6db4668 fix(local deploy): monitor/scheduler container 2025-05-25 20:31:15 -05:00
76bad88190 fix(local deploy): constrains node 2025-05-25 16:02:21 -05:00
a4029725f9 fix: bad path in dockerfile copy, chore: cleanup 2025-05-25 15:18:50 -05:00
aed3d92462 fix: combines mercure with frankenphp app 2025-05-25 15:00:32 -05:00
76531f397f fix(local deploy): removes port from mercure, uses tag instead of latest 2025-05-25 10:22:10 -05:00
c763d67fcb fix: updates local deploy compose 2025-05-24 23:13:21 -05:00
e85ae5ac95 wip: frankenphp 2025-05-23 21:55:41 -05:00
794189d70a wip: franenphp in worker mode 2025-05-23 20:38:22 -05:00
34d9029b7c chore: quicker healthchecks on prod containers 2025-05-23 15:44:53 -05:00
168bf04550 chore: speeds up startup healthchecks 2025-05-23 15:40:56 -05:00
81eb2d1f62 chore: small code cleanup 2025-05-23 14:56:24 -05:00
f5102b859f fix: adds php opcache 2025-05-23 14:56:06 -05:00
a9c6abe06b feat: download option cache on search 2025-05-22 20:48:08 -05:00
764fef37e5 fix: passes nginx config into app container 2025-05-22 12:33:20 -05:00
565481aa5a fix: uses new base image for app 2025-05-21 23:52:04 -05:00
64b7d4c963 fix: example compose 2025-05-21 16:40:51 -05:00
9eb7079ea6 fix: local compose file 2025-05-21 16:39:40 -05:00
bd92480958 fix: nginx conf, missing app_env 2025-05-21 16:38:12 -05:00
079a7e2cb5 fix: creates custom images for each container 2025-05-20 23:36:55 -05:00
295f68cb92 feat: uses php-fpm & nginx instead of php-apache 2025-05-20 20:38:36 -05:00
a2a1154a22 fix: updates docker version in example 2025-05-20 15:19:11 -05:00
3074b2d5f1 feat: download movie to dedicated directory 2025-05-19 22:28:00 -05:00
4638f3765a chore: remove unused imports 2025-05-19 20:38:07 -05:00
70189b95e1 fix: movie_folder download preference not saving when unchecked 2025-05-19 20:37:10 -05:00
c47b1fc23f fix: uses actual app container for dev 2025-05-19 16:36:37 -05:00
f39e307bc4 fix: adds download preference to form 2025-05-19 16:36:01 -05:00
3c965aa1ec fix: refactors getting user's media preferences 2025-05-19 16:16:54 -05:00
bd4ce76177 fix: renames preferences routes 2025-05-19 15:34:19 -05:00
fd0853d6f0 fix: adds 'type' to Preference 2025-05-19 15:33:55 -05:00
53ad80c90b fix: sets all media preferences to 'n/a' by default 2025-05-19 13:49:28 -05:00
fb47cf9d6b fix: adds redis to example compose.yml 2025-05-19 13:05:50 -05:00
eac586d946 chore: cleanup 2025-05-18 23:15:29 -05:00
ae0e416cdd fix: updates example compose file 2025-05-18 22:57:11 -05:00
a64ac6b2cb fix: jenkins 2025-05-18 22:44:04 -05:00
1881247bf2 fix: building in pipeline requires default env vars 2025-05-18 22:37:52 -05:00
0337d7530a fix: adds missing default env var 2025-05-18 22:19:18 -05:00
80 changed files with 2093 additions and 878 deletions

4
.env
View File

@@ -29,10 +29,12 @@ APP_SECRET=
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
MERCURE_JWT_SECRET=
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

1
.gitignore vendored
View File

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

View File

@@ -1,10 +1,18 @@
FROM registry.caldwell.digital/library/php:8.4-apache
FROM dunglas/frankenphp:php8.4-alpine
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
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
RUN apk add --no-cache wget
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

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

@@ -7,7 +7,13 @@ import { getComponent } from '@symfony/ux-live-component';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['download']
static targets = ['download', 'downloadRow', 'viewAllBtn']
static values = {
isWidget: Boolean,
perPage: Number,
}
component = null;
async initialize() {
this.component = await getComponent(this.element);
@@ -22,13 +28,48 @@ export default class extends Controller {
// this.fooTarget.addEventListener('click', this._fooBar)
}
downloadRowTargetConnected(target) {
if (Boolean(target.getAttribute('isBroadcasted')) === true) {
this.viewAllBtnTarget.parentElement.append(this.viewAllBtnTarget);
if (this.downloadRowTargets.length > this.perPageValue) {
target.classList.add('hidden');
this.viewAllBtnTarget.classList.remove('hidden');
} else {
this.viewAllBtnTarget.classList.add('hidden');
}
}
}
downloadRowTargetDisconnected(target) {
this.viewAllBtnTarget.parentElement.append(this.viewAllBtnTarget);
let i = 1;
this.downloadRowTargets.forEach((downloadRow) => {
console.log(downloadRow)
if (i <= this.perPageValue) {
downloadRow.classList.remove('hidden');
} else {
downloadRow.classList.add('hidden');
}
})
if (this.downloadRowTargets.length > this.perPage) {
this.viewAllBtnTarget.classList.remove('hidden');
}
}
downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > 5) {
if (downloads.length > this.perPageValue) {
target.classList.add('hidden');
}
}
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,24 +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 {
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

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

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

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -1,2 +0,0 @@
# $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

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

View File

@@ -1,18 +0,0 @@
#!/bin/bash
# Sleep for a second to ensure DB is awake and ready
SLEEP_TIME=$(shuf -i 2-5 -n 1)
echo "> Sleeping for ${SLEEP_TIME} seconds to wait for the database"
echo "> If there are errors after the migration runs, it's possible another container (scheduler, worker, etc.) already ran the migrations"
sleep $SLEEP_TIME
# Provision database
php /var/www/bin/console doctrine:migrations:migrate --no-interaction
php /var/www/bin/console db:seed
# Start Apache in the foreground
echo "Starting Apache..."
exec apachectl -D FOREGROUND
exec "$@"

View File

@@ -1,23 +0,0 @@
<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,6 +12,41 @@ services:
- $PWD/bash/caddy:/etc/caddy
- $PWD/bash/certs:/etc/ssl
app:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
- mercure_data:/data
- mercure_config:/config
tty: true
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on:
database:
condition: service_healthy
worker:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
tty: true
command: php /app/bin/console messenger:consume async -vv --time-limit=3600 --limit=10
scheduler:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
command: php /app/bin/console messenger:consume scheduler_monitor -vv
tty: true
redis:
image: redis:latest
volumes:
@@ -19,42 +54,6 @@ services:
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:
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
@@ -67,12 +66,19 @@ services:
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

@@ -22,28 +22,29 @@
"p3k/emoji-detector": "^1.2",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"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.*",
"runtime/frankenphp-symfony": "^0.2.0",
"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/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/ldap": "7.2.*",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/ldap": "7.3.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/scheduler": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/messenger": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.2.*",
"symfony/twig-bundle": "7.3.*",
"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.2.*",
"symfony/yaml": "7.3.*",
"symfonycasts/tailwind-bundle": "^0.10.0",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
@@ -89,7 +90,7 @@
"post-update-cmd": [
"@auto-scripts"
],
"sym": "docker compose exec php ./bin/console"
"sym": "docker compose exec app ./bin/console"
},
"conflict": {
"symfony/symfony": "*"
@@ -97,13 +98,13 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*"
"require": "7.3.*"
}
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
}
}

825
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ framework:
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async
'App\Monitor\Action\Command\MonitorMovieCommand': async
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': async
# when@test:
# framework:

View File

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

View File

@@ -7,12 +7,12 @@ parameters:
# Media
media.default_movies_dir: movies
media.default_tvshows_dir: tvshows
media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.movies_path: '%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://mercure/.well-known/mercure'
app.mercure.public_url: '%env(APP_URL)%/hub/.well-known/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)%'

View File

@@ -1,42 +1,57 @@
services:
php:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
app:
image: registry.caldwell.digital/home/torsearch-app:${TAG}
ports:
- "8001:80"
deploy:
replicas: 2
worker:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
deploy:
replicas: 2
scheduler:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3000:80"
- '8001: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
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}
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv --time-limit=3600 --limit=10
deploy:
replicas: 2
depends_on:
- app
scheduler:
image: registry.caldwell.digital/home/torsearch-scheduler:${TAG}
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
depends_on:
- app
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
volumes:
mysql:
mercure_config:
mercure_data:
redis_data:

View File

@@ -1,16 +1,22 @@
FROM registry.caldwell.digital/library/php:8.4-apache
FROM dunglas/frankenphp
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
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
COPY --chown=www-data:www-data . /var/www
COPY --chmod=0775 ./bash/entrypoint.sh /usr/local/bin/
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
HEALTHCHECK --interval=5s --timeout=5s --retries=5 CMD [ "php", "/var/www/bin/console", "startup:status" ]
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/entrypoint.sh" ]
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

@@ -0,0 +1,18 @@
FROM dunglas/frankenphp:php8.4-alpine
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
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,12 +1,20 @@
FROM registry.caldwell.digital/library/php:8.4-apache
FROM dunglas/frankenphp:php8.4-alpine
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
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
COPY --chown=www-data:www-data . /var/www
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf
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

61
docker/app/Caddyfile Normal file
View File

@@ -0,0 +1,61 @@
{
{$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
}
}

12
docker/app/entrypoint.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/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

@@ -3,8 +3,9 @@
# 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-test.caldwell.digital"
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
@@ -18,13 +19,17 @@ DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-Ma
# This key is never saved anywhere
# else and is passed to Torrentio
# to retrieve download options
REAL_DEBRID_KEY=""
#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=""
#TMDB_API=
REAL_DEBRID_KEY="QYYBR7OSQ4VEFKWASDEZ2B4VO67KHUJY6IWOT7HHA7ATXO7QCYDQ"
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"

View File

@@ -1,32 +1,36 @@
services:
# This container runs the actual web app in a php:8.4-apache
# base container. If not running behind a reverse proxy,
# inject your SSL certificates into this container
# 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: registry.caldwell.digital/home/torsearch:test-app
image: code.caldwell.digital/home/torsearch-app:latest
ports:
- "8006:80"
- '8006:80'
env_file:
- .env
depends_on:
database:
condition: service_healthy
# Downloads happen asynchronously in this container. Replicate
# this container to run multiple downloads simultaneously.
# 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
# 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: registry.caldwell.digital/home/torsearch:test-worker
image: code.caldwell.digital/home/torsearch-worker:latest
volumes:
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
command: -vvv
env_file:
- .env
restart: always
depends_on:
app:
condition: service_healthy
@@ -37,12 +41,12 @@ services:
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
scheduler:
image: registry.caldwell.digital/home/torsearch:test-worker
image: code.caldwell.digital/home/torsearch-scheduler:latest
volumes:
- ./downloads:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
env_file:
- .env
restart: always
depends_on:
app:
condition: service_healthy
@@ -79,9 +83,19 @@ services:
MYSQL_ROOT_PASSWORD: password
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
timeout: 10s
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:
@@ -91,3 +105,4 @@ volumes:
mysql:
mercure_config:
mercure_data:
redis_data:

View File

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

View File

@@ -0,0 +1,35 @@
<?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);
}
}

129
nomad.deploy.hcl Normal file
View File

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

@@ -53,6 +53,7 @@ class SeedDatabaseCommand extends Command
->setName($preference['name'])
->setDescription($preference['description'])
->setEnabled($preference['enabled'])
->setType($preference['type'])
);
}
@@ -66,26 +67,37 @@ class SeedDatabaseCommand extends Command
'id' => 'codec',
'name' => 'Codec',
'description' => null,
'enabled' => true
'enabled' => true,
'type' => 'media',
],
[
'id' => 'resolution',
'name' => 'Resolution',
'description' => null,
'enabled' => true
'enabled' => true,
'type' => 'media',
],
[
'id' => 'language',
'name' => 'Language',
'description' => null,
'enabled' => true
'enabled' => true,
'type' => 'media',
],
[
'id' => 'provider',
'name' => 'Provider',
'description' => null,
'enabled' => true
]
'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'
],
];
}

View File

@@ -2,34 +2,25 @@
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(
#[Autowire(service: 'twig')] private readonly Environment $renderer,
private readonly HubInterface $hub,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/alert', name: 'app_alert')]
public function index(): Response
{
$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->broadcaster->alert(
'Added to queue',
'This is a testy test!'
);
$this->hub->publish($update);
return $this->json([
'Success' => 'Published'
]);

View File

@@ -6,18 +6,20 @@ 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 Carbon\Carbon;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
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'])]
@@ -36,6 +38,8 @@ final class SearchController extends AbstractController
GetMediaInfoInput $input,
): Response {
$result = $this->getMediaInfoHandler->handle($input->toCommand());
$this->warmDownloadOptionCache($result->media);
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [
@@ -48,4 +52,32 @@ 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,11 @@ 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\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,8 +20,7 @@ final class TorrentioController extends AbstractController
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly HubInterface $hub,
private readonly \Twig\Environment $renderer,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
@@ -75,14 +73,10 @@ final class TorrentioController extends AbstractController
);
$cache->delete($cacheId);
$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.',
])
));
$this->broadcaster->alert(
title: 'Success',
message: 'Torrentio cache Cleared.'
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));

View File

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

@@ -0,0 +1,26 @@
<?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,8 +37,6 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId);
}
dump($download);
try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');

View File

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

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

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

View File

@@ -3,6 +3,7 @@
namespace App\Download\Downloader;
use App\Download\Framework\Entity\Download;
use App\Monitor\Service\MediaFiles;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
@@ -11,25 +12,26 @@ class ProcessDownloader implements DownloaderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
) {}
/**
* @inheritDoc
*/
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void
{
/** @var Download $downloadEntity */
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
$downloadEntity->setProgress(0);
$this->entityManager->flush();
$process = new Process([
'/bin/sh',
'/var/www/bash/app/wget_download.sh',
$baseDir,
$title,
$downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences();
$path = $this->getDownloadPath($mediaType, $title, $downloadPreferences);
$process = (new Process([
'wget',
$url
]);
]))->setWorkingDirectory($path);
$process->setTimeout(1800); // 30 min
$process->setIdleTimeout(600); // 10 min
@@ -61,4 +63,20 @@ class ProcessDownloader implements DownloaderInterface
$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,13 +2,13 @@
namespace App\Download\Framework\Controller;
use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Broadcaster;
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;
@@ -17,12 +17,11 @@ class ApiController extends AbstractController
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly HubInterface $hub,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
public function download(
Request $request,
DownloadMediaInput $input,
): Response {
$download = $this->downloadRepository->insert(
@@ -45,15 +44,25 @@ class ApiController extends AbstractController
return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
$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',
])
));
$this->broadcaster->alert(
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']);
}
}

View File

@@ -25,7 +25,7 @@ class DownloadRepository extends ServiceEntityRepository
$this->paginator = $paginator;
}
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10)
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10): Paginator
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
@@ -38,7 +38,7 @@ class DownloadRepository extends ServiceEntityRepository
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5)
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5): Paginator
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')

View File

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

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

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

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Monitor\Action\Result;
use App\Monitor\Framework\Entity\Monitor;
use OneToMany\RichBundle\Contract\ResultInterface;
class DeleteMonitorResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
public Monitor $monitor,
) {}
}

View File

@@ -2,23 +2,23 @@
namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class ApiController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')]
private readonly Environment $renderer,
private readonly HubInterface $hub,
private readonly Security $security,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])]
@@ -28,15 +28,34 @@ class ApiController extends AbstractController
HubInterface $hub,
) {
$command = $input->toCommand();
$command->userId = $this->security->getUser()->getId();
$command->userId = $this->getUser()->getId();
$response = $handler->handle($command);
$this->broadcaster->alert(
title: 'Success',
message: "New monitor added for {$input->title}",
);
return $this->json([
'status' => 200,
'message' => $response
]);
}
#[Route('/api/monitor/{monitorId}', name: 'api_monitor_delete', methods: ['DELETE'])]
public function deleteMonitor(
DeleteMonitorInput $input,
DeleteMonitorHandler $handler,
HubInterface $hub,
) {
$response = $handler->handle($input->toCommand());
$hub->publish(new Update(
'alerts',
$this->renderer->render('Alert.stream.html.twig', [
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$input->title}",
'message' => "New monitor added for {$response->monitor->getTitle()}",
])
));

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Monitor\Framework\Repository\MonitorRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class WebController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')]
private readonly Environment $renderer,
) {}
#[Route('/monitors', name: 'app_monitors', methods: ['GET'])]
public function addMonitor()
{
return $this->render('monitor/index.html.twig');
}
}

View File

@@ -7,7 +7,9 @@ use App\User\Framework\Entity\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[Broadcast(template: 'broadcast/Monitor.stream.html.twig')]
#[ORM\Entity(repositoryClass: MonitorRepository::class)]
class Monitor
{

View File

@@ -21,7 +21,7 @@ class MonitorRepository extends ServiceEntityRepository
$this->paginator = $paginator;
}
public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage)
public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage): Paginator
{
$query = $this->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')

View File

@@ -32,6 +32,17 @@ class MediaFiles
$this->filesystem = $filesystem;
}
public function getPathByType(string $mediaType): string
{
if ('movies' === $mediaType) {
return $this->moviesPath;
} elseif ('tvshows' === $mediaType) {
return $this->tvShowsPath;
}
throw new \Exception(sprintf('A path for media type %s does not exist.', $mediaType));
}
public function getMoviesPath(): string
{
return $this->moviesPath;
@@ -83,4 +94,35 @@ class MediaFiles
return Map::from($results);
}
public function createMovieDirectory(string $path): string
{
$path = $this->moviesPath . DIRECTORY_SEPARATOR . $path;
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
return $path;
}
public function createTvShowDirectory(string $path): string
{
$path = $this->tvShowsPath . DIRECTORY_SEPARATOR . $path;
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
return $path;
}
public function createDirectory(string $path): string
{
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
return $path;
}
}

View File

@@ -4,20 +4,15 @@ namespace App\Torrentio\Client;
use App\Torrentio\Client\Rule\DownloadOptionFilter\Resolution;
use App\Torrentio\Client\Rule\RuleEngine;
use App\Torrentio\MediaResult;
use App\Torrentio\Result\ResultFactory;
use Carbon\Carbon;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* ToDo: Fix
*/
class Torrentio
{
private string $baseUrl = 'https://torrentio.strem.fun/providers%253Dyts%252Ceztv%252Crarbg%252C1337x%252Cthepiratebay%252Ckickasstorrents%252Ctorrentgalaxy%252Cmagnetdl%252Chorriblesubs%252Cnyaasi%7Csort%253Dqualitysize%7Cqualityfilter%253D480p%252Cscr%252Ccam%7Crealdebrid={realDebridKey}/stream/movie/{imdbCode}.json';
// private string $baseUrl = 'https://torrentio.strem.fun/providers=yts,eztv,rarbg,1337x,thepiratebay,kickasstorrents,torrentgalaxy,magnetdl,horriblesubs|sort=qualitysize|qualityfilter=480p,cam,unknown|debridoptions=nodownloadlinks|realdebrid=QYYBR7OSQ4VEFKWASDEZ2B4VO67KHUJY6IWOT7HHA7ATXO7QCYDQ/stream/{imdbCode}.json';
private string $searchUrl;
@@ -44,26 +39,6 @@ class Torrentio
return $this->parse($results, $filter);
}
public function searchBySeriesSeason(MediaResult $series): MediaResult
{
$imdbCode = $series->imdbId;
// foreach ($series->episodes as $season => $episodes) {
// foreach ($episodes as $key => $episode) {
// $cacheKey = "torrentio.$series->imdbId.$season.{$episode['episode_number']}";
// $downloadOptions = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $season, $episode) {
// $item->expiresAt(new \DateTimeImmutable("today 11:59 pm"));
// $response = file_get_contents(str_replace('{imdbCode}', "$imdbCode:$season:{$episode['episode_number']}", $this->searchUrl));
// return json_decode(
// $response,
// true
// );
// });
// $series->episodes[$season][$key]['download_options'] = $this->parse($downloadOptions, []);
// }
// }
return $series;
}
public function fetchEpisodeResults(string $imdbId, int $season, int $episode): array
{
$cacheKey = "torrentio.$imdbId.$season.$episode";

View File

@@ -16,15 +16,11 @@ final class DownloadList extends AbstractController
{
use DefaultActionTrait;
use PaginateTrait;
#[LiveProp(writable: true)]
public string $type;
#[LiveProp(writable: true)]
public int $pageNumber = 1;
#[LiveProp(writable: true)]
public int $perPage = 5;
#[LiveProp(writable: true)]
public bool $isWidget = true;
@@ -42,10 +38,4 @@ final class DownloadList extends AbstractController
return [];
}
#[LiveAction]
public function paginate(#[LiveArg] int $page)
{
$this->pageNumber = $page;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Twig\Components;
use App\Download\Framework\Entity\Download;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class DownloadListRow
{
public Download $download;
public bool $isWidget = true;
public bool $isBroadcasted = false;
}

View File

@@ -3,14 +3,24 @@
namespace App\Twig\Components;
use App\Monitor\Framework\Repository\MonitorRepository;
use Doctrine\ORM\Query\Expr\OrderBy;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsTwigComponent]
#[AsLiveComponent]
final class MonitorList extends AbstractController
{
use DefaultActionTrait;
use PaginateTrait;
#[LiveProp(writable: true)]
public string $type;
#[LiveProp(writable: true)]
public bool $isWidget = true;
public function __construct(
@@ -18,8 +28,36 @@ final class MonitorList extends AbstractController
) {}
#[LiveAction]
public function getUserMonitors(#[LiveArg] int $page = 1, #[LiveArg] int $perPage = 5)
public function getMonitors()
{
return $this->monitorRepository->getUserMonitorsPaginated($this->getUser(), $page, $perPage);
if ($this->type === "active") {
return $this->getActiveUserMonitors();
} elseif ($this->type === "complete") {
return $this->getCompleteUserMonitors();
}
return [];
}
#[LiveAction]
public function getActiveUserMonitors()
{
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')
->setParameter('statuses', ['New', 'In Progress'])
->orderBy('m.id', 'DESC')
->getQuery()
);
}
#[LiveAction]
public function getCompleteUserMonitors()
{
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status = :status')
->setParameter('status', 'Complete')
->orderBy('m.id', 'DESC')
->getQuery()
);
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Twig\Components;
use App\Monitor\Framework\Entity\Monitor;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class MonitorListRow
{
public Monitor $monitor;
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Twig\Components;
use App\Util\Paginator;
use Doctrine\ORM\Query;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
trait PaginateTrait
{
#[LiveProp(writable: true)]
public int $pageNumber = 1;
#[LiveProp(writable: true)]
public int $perPage = 5;
#[LiveAction]
public function paginate(#[LiveArg] int $page)
{
$this->pageNumber = $page;
}
private function asPaginator(Query $query): Paginator
{
return (new Paginator())
->paginate($query, $this->pageNumber, $this->perPage);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Twig\Extensions;
use App\Monitor\Framework\Entity\Monitor;
use Twig\Attribute\AsTwigFilter;
class MonitorExtension
{
#[AsTwigFilter('monitor_type')]
public function type(string $type)
{
$types = [
'tvshows' => 'Show',
'tvseason' => 'Season',
'tvepisode' => 'Episode',
];
return $types[$type] ?? '-';
}
#[AsTwigFilter('monitor_media_id')]
public function mediaId(Monitor $monitor)
{
if ($monitor->getMonitorType() === "tvseason") {
return "S". str_pad($monitor->getSeason(), 2, "0", STR_PAD_LEFT);
}
if ($monitor->getMonitorType() === "tvepisode") {
return "S". str_pad($monitor->getSeason(), 2, "0", STR_PAD_LEFT) .
"E". str_pad($monitor->getEpisode(), 2, "0", STR_PAD_LEFT);
}
return "-";
}
}

View File

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

View File

@@ -0,0 +1,52 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Result\SaveUserDownloadPreferencesResult;
use App\User\Action\Result\SaveUserMediaPreferencesResult;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Symfony\Bundle\SecurityBundle\Security;
/** @implements HandlerInterface<SaveUserMediaPreferencesCommand> */
class SaveUserDownloadPreferencesHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly Security $token,
) {}
public function handle(C $command): R
{
/** @var User $user */
$user = $this->token->getUser();
foreach ($command as $preference => $value) {
if ($user->hasUserPreference($preference)) {
$user->updateUserPreference($preference, $value);
$this->entityManager->flush();
continue;
}
$preference = $this->preferenceRepository->find($preference);
$user->addUserPreference(
(new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue($value)
);
}
$this->entityManager->flush();
return new SaveUserDownloadPreferencesResult($user->getDownloadPreferences());
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\User\Action\Input;
use App\User\Action\Command\SaveUserDownloadPreferencesCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<SaveUserDownloadPreferencesInput, SaveUserDownloadPreferencesCommand> */
class SaveUserDownloadPreferencesInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public mixed $userId,
#[SourceRequest('movie_folder', nullify: true)]
public bool $movieFolder,
) {}
public function toCommand(): C
{
return new SaveUserDownloadPreferencesCommand(
$this->movieFolder,
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Action\Result;
use Doctrine\Common\Collections\Collection;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface */
class SaveUserDownloadPreferencesResult implements ResultInterface
{
public function __construct(
public array $downloadPreferences,
) {}
}

View File

@@ -2,10 +2,8 @@
namespace App\User\Framework\Controller\Web;
use App\User\Action\Handler\RegisterUserHandler;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

View File

@@ -4,44 +4,30 @@ declare(strict_types=1);
namespace App\User\Framework\Controller\Web;
use Aimeos\Map;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use App\Util\CountryCodes;
use App\Util\Broadcaster;
use App\Util\CountryLanguages;
use App\Util\ProviderList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
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;
class PreferencesController extends AbstractController
{
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
private readonly Security $security,
private readonly HubInterface $hub,
private readonly Broadcaster $broadcaster,
) {}
#[Route('/media/preferences', 'app_media_preferences', methods: ['GET'])]
#[Route('/user/preferences', 'app_user_preferences', methods: ['GET'])]
public function mediaPreferences(): Response
{
$enabledPreferences = $this->preferencesRepository->findEnabled();
if ($this->security->getUser()->getUserPreferences()->count() !== count($enabledPreferences)) {
$this->setUserPreferences($this->security->getUser(), $enabledPreferences);
}
$userPreferences = $this->security->getUser()->getUserPreferences()->toArray();
$userPreferences = Map::from($userPreferences)
->rekey(fn($preference) => $preference->getPreference()->getId());
$mediaPreferences = $this->getUser()->getMediaPreferences();
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$languages = CountryLanguages::$languages;
sort($languages);
@@ -51,31 +37,29 @@ class PreferencesController extends AbstractController
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'userPreferences' => $userPreferences->toArray(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
]
);
}
#[Route('/media/preferences', 'app_save_media_preferences', methods: ['POST'])]
#[Route('/user/preferences/media', 'app_save_media_preferences', methods: ['POST'])]
public function saveMediaPreferences(
Request $request,
SaveUserMediaPreferencesInput $input,
SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
): Response
{
$userPreferences = $this->saveUserMediaPreferencesHandler->handle($input->toCommand())->userPreferences;
$userPreferences = Map::from($userPreferences)->rekey(fn($preference) => $preference->getPreference()->getId());
$saveUserMediaPreferencesHandler->handle($input->toCommand());
$mediaPreferences = $this->getUser()->getMediaPreferences();
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$languages = CountryLanguages::$languages;
sort($languages);
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderView('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Your media preferences have been saved.',
])
));
$this->broadcaster->alert(
title: 'Success',
message: 'Your media preferences have been saved.'
);
return $this->render(
'user/preferences.html.twig',
@@ -83,22 +67,38 @@ class PreferencesController extends AbstractController
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'userPreferences' => $userPreferences->toArray(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
]
);
}
private function setUserPreferences(User $user, array $preferences): void
#[Route('/user/preferences/download', 'app_save_download_preferences', methods: ['POST'])]
public function saveDownloadPreferences(
SaveUserDownloadPreferencesInput $input,
SaveUserDownloadPreferencesHandler $saveUserDownloadPreferencesHandler,
): Response
{
foreach ($preferences as $preference) {
if (false === $user->hasUserPreference($preference->getId())) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
$this->preferencesRepository->getEntityManager()->flush();
$downloadPreferences = $saveUserDownloadPreferencesHandler->handle($input->toCommand())->downloadPreferences;
$mediaPreferences = $this->getUser()->getMediaPreferences();
$languages = CountryLanguages::$languages;
sort($languages);
$this->broadcaster->alert(
title: 'Success',
message: 'Your download preferences have been saved.'
);
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
]
);
}
}

View File

@@ -14,6 +14,9 @@ class Preference
#[ORM\Column]
private ?string $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $type = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
@@ -57,6 +60,17 @@ class Preference
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getDescription(): ?string
{
return $this->description;

View File

@@ -204,7 +204,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getUserPreferenceValues()
public function getUserPreferenceValues(string $type = 'all'): array
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
@@ -213,7 +213,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $userPreference->getPreferenceValue();
}
foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) {
// dd((int) $userPreference->getPreferenceValue(), $preferenceOption->getId(), $preferenceOption->getValue());
if ($preferenceOption->getId() === (int) $userPreference->getPreferenceValue()) {
return $preferenceOption->getValue();
}
@@ -273,6 +272,24 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->downloads;
}
public function getMediaPreferences()
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->filter(fn(UserPreference $userPreference) => $userPreference->getPreference()->getType() === 'media')
->toArray()
;
}
public function getDownloadPreferences()
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->filter(fn(UserPreference $userPreference) => $userPreference->getPreference()->getType() === 'download')
->toArray()
;
}
/**
* @return Collection<int, Download>
*/

33
src/Util/Broadcaster.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Util;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Twig\Environment;
readonly class Broadcaster
{
public function __construct(
#[Autowire(service: 'twig')]
private Environment $renderer,
private HubInterface $hub,
private RequestStack $requestStack,
) {}
public function alert(string $title, string $message): void
{
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
])
);
$this->hub->publish($update);
}
}

View File

@@ -2,6 +2,15 @@
"1tomany/rich-bundle": {
"version": "v1.8.3"
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.14",
"recipe": {
@@ -169,6 +178,18 @@
"config/packages/messenger.yaml"
]
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.2",
"recipe": {

View File

@@ -4,6 +4,14 @@ module.exports = {
"./assets/**/*.js",
"./templates/**/*.html.twig",
],
safelist: [
"bg-blue-300",
"bg-orange-300",
"bg-fuchsia-300",
"bg-green-400",
"bg-purple-400",
"bg-orange-400",
],
theme: {
extend: {},
},

View File

@@ -5,36 +5,19 @@
<turbo-stream action="append" target="active_downloads">
<template>
<tr data-download-list-target="download" id="ad_download_{{ entity.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-50">
{% if entity.progress < 100 %}
<div class="w-[3.25ch] h-[3.25ch] bg-purple-600 rounded-full block text-center table-cell align-middle text-xs text-gray-50">
{{ entity.progress }}
</div>
{% else %}
<twig:StatusBadge color="green" status="Complete" />
{% endif %}
</td>
</tr>
<twig:DownloadListRow download="{{ entity }}" isBroadcasted="true" />
</template>
</turbo-stream>
{% endblock %}
{% block update %}
{% if entity.status != "Complete" %}
<turbo-stream action="update" target="ad_download_{{ id }}">
<turbo-stream action="update" target="download_progress_{{ id }}">
<template>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<div class="border-2 border-green-700 rounded-md w-full h-6 align-middle overflow-hidden">
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5" style="width:{{ entity.progress }}%">{{ entity.progress }}%</div>
</div>
</td>
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5"
style="width:{{ entity.progress }}%">
<span>{{ entity.progress }}%</span>
</div>
</template>
</turbo-stream>
{% else %}
@@ -52,14 +35,7 @@
<turbo-stream action="prepend" target="complete_downloads">
<template>
<tr id="ad_download_{{ entity.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 min-w-[45ch] max-w-[45ch] truncate">
{{ entity.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-50">
<twig:StatusBadge color="green" status="Complete" />
</td>
</tr>
<twig:DownloadListRow download="{{ entity }}" :isBroadcasted="true" />
</template>
</turbo-stream>
{% endif %}

View File

@@ -0,0 +1,29 @@
{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #}
{% block create %}
<turbo-stream action="remove" target="active_monitors_no_monitors">
</turbo-stream>
<turbo-stream action="prepend" target="monitors">
<template>
<twig:MonitorListRow monitor="{{ entity }}" />
</template>
</turbo-stream>
{% endblock %}
{% block update %}
<turbo-stream action="replace" target="monitor_{{ id }}">
<template>
<twig:MonitorListRow monitor="{{ entity }}" />
</template>
</turbo-stream>
{% endblock %}
{% block remove %}
<turbo-stream action="remove" target="monitor_{{ id }}"></turbo-stream>
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert title="Success" message="Your Monitor for '{{ entity.title }}' has been removed." alert_id="monitor_alert_{{ entity.id }}" data-controller="alert" />
</template>
</turbo-stream>
{% endblock %}

View File

@@ -1,5 +1,11 @@
<div{{ attributes.defaults(stimulus_controller('download_list')) }} class="min-w-48" >
<div{{ attributes.defaults(stimulus_controller('download_list', {isWidget: this.isWidget, perPage: this.perPage})) }} class="min-w-48" >
{% set table_body_id = (type == "complete") ? "complete_downloads" : "active_downloads" %}
{% if this.isWidget == true and this.downloads.items|length > this.perPage %}
{% set show_view_all = true %}
{% else %}
{% set show_view_all = false %}
{% endif %}
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
@@ -23,46 +29,21 @@
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
Progress
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
</th>
</tr>
</thead>
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50">
{% if this.downloads.items|length > 0 %}
{% for download in this.downloads.items %}
<tr id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 {% if this.isWidget == true %}min-w-[45ch] max-w-[45ch]{% endif %} truncate">
{{ download.title }}
</td>
{% if this.isWidget == false %}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.filename }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.mediaType }}
</td>
{% endif %}
<td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %}
<div class="border-2 border-green-700 rounded-md w-full h-6 align-middle overflow-hidden">
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5" style="width:{{ download.progress }}%">{{ download.progress }}%</div>
</div>
{# <div class="w-[3.25ch] h-[3.25ch] bg-purple-600 rounded-full block text-center table-cell align-middle text-xs text-gray-50">#}
{# {{ download.progress }}#}
{# </div>#}
{% else %}
<twig:StatusBadge color="green" status="Completed" />
{% endif %}
</td>
</tr>
<twig:DownloadListRow download="{{ download }}" isWidget="{{ this.isWidget }}" perPage="{{ this.perPage }}" />
{% endfor %}
{% if this.isWidget == true and this.downloads.items|length > this.perPage %}
<tr id="download_view_all">
<td class="py-2 whitespace-nowrap bg-orange-500 uppercase text-sm font-medium text-center text-white truncate" colspan="100%">
<a href="{{ path('app_downloads') }}">View All Downloads</a>
</td>
</tr>
{% endif %}
<tr id="download_view_all" class="{{ show_view_all == false ?? "hidden" }}" {{ stimulus_target('download_list', 'viewAllBtn')}} >
<td class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black truncate" colspan="100%">
<a href="{{ path('app_downloads') }}">View All Downloads</a>
</td>
</tr>
{% else %}
<tr id="{{ table_body_id }}_no_downloads">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium text-gray-800 dark:text-stone-800" colspan="100%">

View File

@@ -0,0 +1,36 @@
<tr{{ attributes }} id="ad_download_{{ download.id }}" {{ stimulus_target('download_list', 'downloadRow') }} isBroadcasted="{{ isBroadcasted ?? 'false' }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.title }}
</td>
{% if isWidget == false %}
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] truncate">
{{ download.filename }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.mediaType }}
</td>
{% endif %}
<td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %}
<div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-full h-6 align-middle overflow-hidden">
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
style="width:{{ download.progress }}%">
</div>
<div class="absolute text-black text-center" style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">{{ download.progress }}%</div>
</div>
{% else %}
<twig:StatusBadge color="green" status="Complete" />
{% endif %}
</td>
<td class="px-6 py-4 flex flex-row align-middle justify-center">
<button {{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}>
<twig:ux:icon
name="ic:twotone-cancel" width="18px"
class="rounded-full align-middle text-red-600"
title="Remove {{ download.title }} from your Download list. This will not delete the file."
/>
</button>
</td>
</tr>

View File

@@ -1,12 +1,15 @@
<div{{ attributes }} class="min-w-48">
<p class="text-white mb-1">The items you're currently monitoring to automatically download.</p>
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-fixed" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<div{{ attributes.defaults(stimulus_controller('monitor_list')) }}>
<table id="monitor_list" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Monitor\\Framework\\Entity\\Monitor') }}>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase min-w-[45ch] max-w-[45ch] truncate">
class="px-6 py-3 text-start text-xs font-medium uppercase truncate">
Title
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
ID
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Search Count
@@ -19,49 +22,32 @@
class="px-6 py-3 text-start text-xs font-medium uppercase">
Last Search Date
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Type
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Status
</th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{% if this.userMonitors.items|length > 0 %}
{% for monitor in this.userMonitors.items %}
<tr id="monitor_{{ monitor.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 min-w-[50ch] max-w-[50ch] truncate">
{{ monitor.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.searchCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.createdAt|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.lastSearch|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.status == "New" %}
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}
<twig:StatusBadge color="purple" status="{{ monitor.status }}" />
{% else %}
<twig:StatusBadge color="green" status="{{ monitor.status }}" />
{% endif %}
</td>
</tr>
<tbody id="monitors" class="divide-y divide-gray-50">
{% if this.monitors.items|length > 0 %}
{% for monitor in this.monitors.items %}
<twig:MonitorListRow :monitor="monitor" />
{% endfor %}
{% if this.userMonitors.items|length > 5 %}
{% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all">
<td colspan="5" class="py-2 whitespace-nowrap bg-orange-500 uppercase text-sm font-medium text-center text-white min-w-[50ch] max-w-[50ch] truncate">
<a href="#">View All Monitors</a>
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black min-w-[50ch] max-w-[50ch] truncate">
<a href="{{ path('app_monitors') }}">View All Monitors</a>
</td>
</tr>
{% endif %}
{% else %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-stone-800" colspan="2">
<tr id="active_monitors_no_monitors">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-stone-800" colspan="100%">
No monitors
</td>
</tr>
@@ -70,8 +56,8 @@
</table>
{% if this.isWidget == false %}
{% if this.userMonitors.items|length > 0 %}
{% set paginator = this.userMonitors %}
{% if this.monitors.items|length > 0 %}
{% set paginator = this.monitors %}
{% include 'partial/paginator.html.twig' %}
{% endif %}
{% endif %}

View File

@@ -0,0 +1,44 @@
<tr{{ attributes }} id="monitor_{{ monitor.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 truncate">
{{ monitor.title }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor|monitor_media_id }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.searchCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.createdAt|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.lastSearch|date('m/d/Y h:i a') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.monitorType == "tvshow" %}
<twig:StatusBadge color="blue" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% elseif monitor.monitorType == "tvseason" %}
<twig:StatusBadge color="orange" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% else %}
<twig:StatusBadge color="fuchsia" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.status == "New" %}
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}
<twig:StatusBadge color="purple" status="{{ monitor.status }}" />
{% else %}
<twig:StatusBadge color="green" status="{{ monitor.status }}" />
{% endif %}
</td>
<td class="px-6 py-4 flex flex-row align-middle justify-center">
<button {{ stimulus_action('monitor_list', 'deleteMonitor', 'click', {id: monitor.id}) }}>
<twig:ux:icon
name="ic:twotone-cancel" width="18px"
class="rounded-full align-middle text-red-600"
title="Remove {{ monitor.title }} from your Monitor list."
/>
</button>
</td>
</tr>

View File

@@ -21,7 +21,16 @@
</li>
<li>
<a href="{{ path('app_media_preferences') }}"
<a href="{{ path('app_monitors') }}"
class="block rounded-lg
bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60
px-4 py-2 text-sm font-medium text-gray-50">
Monitors
</a>
</li>
<li>
<a href="{{ path('app_user_preferences') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
Preferences
</a>

View File

@@ -1,3 +1,8 @@
<span {{ attributes }} class="py-[3px] px-[7px] bg-{{ color|default('green') }}-600 rounded-lg inline-block text-center text-xs text-white">
<span {{ attributes }}
class="py-[3px] px-[7px]
bg-{{ color|default('green') }}-{{ number|default(400) }}
text-{{ text|default('black') }}
rounded-lg inline-block text-center text-xs"
>
{{ status }}
</span>

View File

@@ -16,7 +16,7 @@
</div>
<div class="flex flex-row gap-4">
<twig:Card title="Monitors" class="w-full">
<twig:MonitorList />
<twig:MonitorList :type="'active'" :isWidget="true" />
</twig:Card>
</div>
<div class="flex flex-col gap-4">

View File

@@ -0,0 +1,18 @@
{% extends 'base.html.twig' %}
{% block title %}Monitors &mdash; Torsearch{% endblock %}
{% block h2 %}Monitors{% endblock %}
{% block body %}
<div class="p-4">
<twig:Card title="Active Monitors">
<twig:MonitorList :type="'active'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>
<div class="p-4">
<twig:Card title="Complete Monitors">
<twig:MonitorList :type="'complete'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>
{% endblock %}

View File

@@ -3,55 +3,78 @@
{% block h2 %}Preferences{% endblock %}
{% block body %}
<div class="p-4 flex flex-col">
<twig:Card title="Choose your preferences">
<p class="text-gray-50 mb-2">Define a set of filters to apply to your media download option results.</p>
<form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_media_preferences') }}">
<div class="p-4 flex flex-row gap-2">
<twig:Card title="Media Preferences" class="w-full">
<p class="text-gray-50 mb-2">Define a filter to be pre-applied to your download options.</p>
<form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_save_media_preferences') }}">
<label class="text-gray-50" for="resolution">Resolution</label>
<select class="p-1.5 rounded-md mb-2" name="resolution" id="resolution" value="{{ userPreferences['resolution'].getPreferenceValue() }}">
{% for pref in userPreferences['resolution'].getPreference().getPreferenceOptions() %}
<select class="p-1.5 rounded-md mb-2" name="resolution" id="resolution" value="{{ mediaPreferences['resolution'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ mediaPreferences['resolution'] is null ? "selected" }}
>n/a</option>
{% for pref in mediaPreferences['resolution'].getPreference().getPreferenceOptions() %}
<option class="text-gray-800"
value="{{ pref.id }}"
{{ pref.id == userPreferences['resolution'].getPreferenceValue() ? "selected" }}
{{ pref.id == mediaPreferences['resolution'].getPreferenceValue() ? "selected" }}
>{{ pref.name }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="codec">Codec</label>
<select class="p-1.5 rounded-md mb-2" name="codec" id="codec" value="{{ userPreferences['codec'].getPreferenceValue() }}">
{% for pref in userPreferences['codec'].getPreference().getPreferenceOptions() %}
<select class="p-1.5 rounded-md mb-2" name="codec" id="codec" value="{{ mediaPreferences['codec'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ mediaPreferences['codec'].getPreferenceValue() is null ? "selected" }}
>n/a</option>
{% for pref in mediaPreferences['codec'].getPreference().getPreferenceOptions() %}
<option class="text-gray-800"
value="{{ pref.id }}"
{{ pref.id == userPreferences['codec'].getPreferenceValue() ? "selected" }}
{{ pref.id == mediaPreferences['codec'].getPreferenceValue() ? "selected" }}
>{{ pref.name }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="provider">Provider</label>
<select class="p-1.5 rounded-md mb-2" name="provider" id="provider" value="{{ userPreferences['provider'].getPreferenceValue() }}">
<option class="text-gray-800" value=""
{{ "" == userPreferences['provider'].getPreferenceValue() ? "selected" }}
<select class="p-1.5 rounded-md mb-2" name="provider" id="provider" value="{{ mediaPreferences['provider'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ "" == mediaPreferences['provider'].getPreferenceValue() ? "selected" }}
>n/a</option>
{% for provider in providers %}
<option class="text-gray-800"
value="{{ provider }}"
{{ provider == userPreferences['provider'].getPreferenceValue() ? "selected" }}
{{ provider == mediaPreferences['provider'].getPreferenceValue() ? "selected" }}
>{{ provider }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="language">Language</label>
<select class="p-1.5 rounded-md mb-2" name="language" id="language" value="{{ userPreferences['language'].getPreferenceValue() }}">
<select class="p-1.5 rounded-md mb-2" name="language" id="language" value="{{ mediaPreferences['language'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ mediaPreferences['language'].getPreferenceValue() is null ? "selected" }}
>n/a</option>
{% for language in languages %}
<option class="text-gray-800"
value="{{ language }}"
{{ language == userPreferences['language'].getPreferenceValue() ? "selected" }}
{{ language == mediaPreferences['language'].getPreferenceValue() ? "selected" }}
>{{ language }}</option>
{% endfor %}
</select>
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
</form>
</twig:Card>
<twig:Card title="Download Preferences" class="w-full">
<p class="text-gray-50 mb-2">Change how your downloads are stored.</p>
<form id="download_preferences" class="flex flex-col" name="download_preferences" method="post" action="{{ path('app_save_download_preferences') }}">
<div class="flex flex-row gap-2 mb-2">
<input type="hidden" name="movie_folder" id="movie_folder_hidden" value="0" />
<input type="checkbox" name="movie_folder" id="movie_folder" value="1" {{ downloadPreferences['movie_folder'].getPreferenceValue() == true ? 'checked' }} />
<label class="text-gray-50" for="movie_folder">Store movies in a new directory?</label>
</div>
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
</form>
</twig:Card>