Compare commits

...

76 Commits

Author SHA1 Message Date
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
9b9240c35b fix: adds missing default env var 2025-05-18 22:15:56 -05:00
a568cb7c86 fix: adds docker dir to deployment.properties 2025-05-18 21:04:30 -05:00
daafeb79b7 fix: container DB race condition, feat: separate images for app & worker, chore: example compose & .env files 2025-05-18 19:48:10 -05:00
bc8c214c99 fix: customer seeder 2025-05-16 21:07:02 -05:00
d5f0098280 fixes apache broken processes 2025-05-16 19:18:04 -05:00
4cb9fd0810 chore: removes unused code 2025-05-16 13:16:20 -05:00
7bc8720377 fix: provisions DB on container startup 2025-05-16 13:15:28 -05:00
cc4942a537 fix: adds data fixtures for preferences table 2025-05-16 10:29:29 -05:00
c4f416af37 fix: logs user in after registration 2025-05-16 08:17:00 -05:00
2c31485964 fix: gradient bg 2025-05-15 23:39:51 -05:00
5d5d66bd79 wip-feat: reduces env vars, adds getting-started page 2025-05-15 23:25:12 -05:00
ce6fda257b chore: style cleanup 2025-05-14 20:37:12 -05:00
7afc845343 docs: cleanup 2025-05-14 20:36:53 -05:00
546039aa43 docs: adds images 2025-05-14 17:50:41 -05:00
e6196ce078 docs: starts readme 2025-05-14 17:25:00 -05:00
eecd5444a6 fix: uses progress bar to show download progress 2025-05-14 11:33:20 -05:00
74506f6928 fix: adds alert when preferences are saved 2025-05-13 22:03:34 -05:00
845a67bdd8 fix: search buton 2025-05-13 20:49:10 -05:00
651697640c fix: nav bar cleanup 2025-05-13 20:24:23 -05:00
dd48cc542f fix: better paginator functionality 2025-05-13 16:28:42 -05:00
6c0e42d291 fix: mostly working paginator 2025-05-13 16:11:30 -05:00
e230913c89 wip: adds downloads page, makes DownloadList a widget or a full page list 2025-05-13 11:18:08 -05:00
8967d407cb fix: adds 'view all ...' button to dashboard widgets 2025-05-13 09:07:20 -05:00
217a667df2 fix: scopes alerts to user session 2025-05-12 22:04:10 -05:00
4653feb123 wip: pagination 2025-05-12 20:27:39 -05:00
6ad10a585d fix: limits active download list to 5 items 2025-05-12 16:33:47 -05:00
eed2e70d21 fix: download progress indicator 2025-05-12 15:10:25 -05:00
eded5a2fc8 fix: pixel perfect status badge 2025-05-12 14:33:06 -05:00
8428fc6cf6 fix: adds status badges 2025-05-12 14:17:22 -05:00
888a030680 fix: broken download, added to queue alert, download list component; feat: monitor list 2025-05-12 11:23:03 -05:00
a628d85ef2 fix: broken LDAP 2025-05-11 18:33:55 -05:00
afb62645f6 fix: ignores platform reqs on composer install 2025-05-11 16:43:28 -05:00
8aba35fee1 fix: scopes downloads and monitors to users 2025-05-11 16:27:53 -05:00
6817bd8c80 wip: scopes downloads to usrs 2025-05-11 00:12:55 -05:00
854177a121 feat: command to set auth method 2025-05-10 23:53:46 -05:00
ddb71b3bb0 chore: cleaning 2025-05-10 20:05:57 -05:00
35a3e48ac9 fix: mostly working ldap 2025-05-10 20:03:17 -05:00
6e55195e6f wip-feat: authenticates with LDAP 2025-05-10 08:48:12 -05:00
e325687af5 chore: style updates 2025-05-09 21:30:43 -05:00
120 changed files with 3299 additions and 575 deletions

12
.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=dev
APP_ENV=prod
APP_SECRET=
###< symfony/framework-bundle ###
@@ -26,12 +26,18 @@ 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"
###< doctrine/doctrine-bundle ###
# 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
MOVIES_PATH=/var/download/movies
TVSHOWS_PATH=/var/download/tvshows

View File

@@ -1,8 +0,0 @@
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="* * * * *"

View File

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

1
.gitignore vendored
View File

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

View File

@@ -1,4 +1,16 @@
FROM registry.caldwell.digital/library/php:8.4-apache
FROM dunglas/frankenphp
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf
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
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -1,5 +0,0 @@
FROM registry.caldwell.digital/library/php:8.4-apache
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,13 +1,50 @@
# Caldwell Digital - Symfony Template
Get up and running quickly with this Symfony framework template!
# 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
## Getting Started
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`
1. Clone the repo
## Optional steps
1. Set phing vars
2. Update the project name in `build.xml`
3. Set a custom development hostname in `bash/vhost.conf`

View File

@@ -15,7 +15,7 @@ export default class extends Controller {
}
download() {
fetch('/download', {
fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -26,7 +26,7 @@ export default class extends Controller {
title: this.element.dataset['title'],
filename: this.filenameValue,
mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue
imdbId: this.imdbIdValue,
})
})
.then(res => res.json())

View File

@@ -0,0 +1,42 @@
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 {
static targets = ['download']
async initialize() {
this.component = await getComponent(this.element);
}
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)
}
downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > 5) {
target.classList.add('hidden');
}
}
// 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

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

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

View File

@@ -1,2 +1,3 @@
echo "> Building ${IMG} for linux/arm/v8"
docker buildx build --platform linux/arm/v8 -f Dockerfile.prod -t ${IMG} .
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" .

View File

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

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

@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project name="Caldwell Digital Symfony Template" default="build">
<project name="Torsearch" default="build">
<!-- build dev for dev envs -->
<target name="build" depends="setEnv,composer,compileAssets,migrateDb,clearCache" />
<target name="composer" description="Run composer">
<exec executable="composer">
<arg value="install" />
<arg value="--ignore-platform-reqs" />
</exec>
</target>

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

@@ -11,15 +11,18 @@
"aimeos/map": "^3.12",
"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",
"league/pipeline": "^1.1",
"nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*",
@@ -29,6 +32,7 @@
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/ldap": "7.2.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*",
@@ -86,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": "*"

354
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7e29123297e1ac72cd417967d2a761b4",
"content-hash": "c4ad1f54b8dd44fb55097f631c945460",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -446,6 +446,89 @@
],
"time": "2025-03-22T10:17:19+00:00"
},
{
"name": "doctrine/data-fixtures",
"version": "2.0.2",
"source": {
"type": "git",
"url": "https://github.com/doctrine/data-fixtures.git",
"reference": "f7f1e12d6bceb58c204b3e77210a103c1c57601e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/f7f1e12d6bceb58c204b3e77210a103c1c57601e",
"reference": "f7f1e12d6bceb58c204b3e77210a103c1c57601e",
"shasum": ""
},
"require": {
"doctrine/persistence": "^3.1 || ^4.0",
"php": "^8.1",
"psr/log": "^1.1 || ^2 || ^3"
},
"conflict": {
"doctrine/dbal": "<3.5 || >=5",
"doctrine/orm": "<2.14 || >=4",
"doctrine/phpcr-odm": "<1.3.0"
},
"require-dev": {
"doctrine/coding-standard": "^12",
"doctrine/dbal": "^3.5 || ^4",
"doctrine/mongodb-odm": "^1.3.0 || ^2.0.0",
"doctrine/orm": "^2.14 || ^3",
"ext-sqlite3": "*",
"fig/log-test": "^1",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5.3",
"symfony/cache": "^6.4 || ^7",
"symfony/var-exporter": "^6.4 || ^7"
},
"suggest": {
"alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)",
"doctrine/mongodb-odm": "For loading MongoDB ODM fixtures",
"doctrine/orm": "For loading ORM fixtures",
"doctrine/phpcr-odm": "For loading PHPCR ODM fixtures"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Common\\DataFixtures\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
}
],
"description": "Data Fixtures for all Doctrine Object Managers",
"homepage": "https://www.doctrine-project.org",
"keywords": [
"database"
],
"support": {
"issues": "https://github.com/doctrine/data-fixtures/issues",
"source": "https://github.com/doctrine/data-fixtures/tree/2.0.2"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures",
"type": "tidelift"
}
],
"time": "2025-01-21T13:21:31+00:00"
},
{
"name": "doctrine/dbal",
"version": "3.9.4",
@@ -727,6 +810,92 @@
],
"time": "2025-03-22T17:28:21+00:00"
},
{
"name": "doctrine/doctrine-fixtures-bundle",
"version": "4.1.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
"reference": "a06db6b81ff20a2980bf92063d80c013bb8b4b7c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/a06db6b81ff20a2980bf92063d80c013bb8b4b7c",
"reference": "a06db6b81ff20a2980bf92063d80c013bb8b4b7c",
"shasum": ""
},
"require": {
"doctrine/data-fixtures": "^2.0",
"doctrine/doctrine-bundle": "^2.2",
"doctrine/orm": "^2.14.0 || ^3.0",
"doctrine/persistence": "^2.4 || ^3.0 || ^4.0",
"php": "^8.1",
"psr/log": "^2 || ^3",
"symfony/config": "^6.4 || ^7.0",
"symfony/console": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/deprecation-contracts": "^2.1 || ^3",
"symfony/doctrine-bridge": "^6.4.16 || ^7.1.9",
"symfony/http-kernel": "^6.4 || ^7.0"
},
"conflict": {
"doctrine/dbal": "< 3"
},
"require-dev": {
"doctrine/coding-standard": "13.0.0",
"phpstan/phpstan": "2.1.11",
"phpunit/phpunit": "^10.5.38 || 11.4.14"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Doctrine\\Bundle\\FixturesBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Doctrine Project",
"homepage": "https://www.doctrine-project.org"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony DoctrineFixturesBundle",
"homepage": "https://www.doctrine-project.org",
"keywords": [
"Fixture",
"persistence"
],
"support": {
"issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
"source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.1.0"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle",
"type": "tidelift"
}
],
"time": "2025-03-26T10:56:26+00:00"
},
{
"name": "doctrine/doctrine-migrations-bundle",
"version": "3.4.1",
@@ -1623,6 +1792,62 @@
],
"time": "2025-01-26T21:29:45+00:00"
},
{
"name": "league/pipeline",
"version": "1.1.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/pipeline.git",
"reference": "9069ddfdbd5582f8a563e00cffdbeffb9a0acd01"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/pipeline/zipball/9069ddfdbd5582f8a563e00cffdbeffb9a0acd01",
"reference": "9069ddfdbd5582f8a563e00cffdbeffb9a0acd01",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0 || ^10.0 || ^11.5"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Pipeline\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frenky.net",
"role": "Author"
},
{
"name": "Woody Gilk",
"email": "woody.gilk@gmail.com",
"role": "Maintainer"
}
],
"description": "A plug and play pipeline implementation.",
"keywords": [
"composition",
"design pattern",
"pattern",
"pipeline",
"sequential"
],
"support": {
"issues": "https://github.com/thephpleague/pipeline/issues",
"source": "https://github.com/thephpleague/pipeline/tree/1.1.0"
},
"time": "2025-02-06T08:48:15+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.9.1",
@@ -3075,6 +3300,58 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "runtime/frankenphp-symfony",
"version": "0.2.0",
"source": {
"type": "git",
"url": "https://github.com/php-runtime/frankenphp-symfony.git",
"reference": "56822c3631d9522a3136a4c33082d006bdfe4bad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-runtime/frankenphp-symfony/zipball/56822c3631d9522a3136a4c33082d006bdfe4bad",
"reference": "56822c3631d9522a3136a4c33082d006bdfe4bad",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0",
"symfony/runtime": "^5.4 || ^6.0 || ^7.0"
},
"require-dev": {
"phpunit/phpunit": "^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Runtime\\FrankenPhpSymfony\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "kevin@dunglas.dev"
}
],
"description": "FrankenPHP runtime for Symfony",
"support": {
"issues": "https://github.com/php-runtime/frankenphp-symfony/issues",
"source": "https://github.com/php-runtime/frankenphp-symfony/tree/0.2.0"
},
"funding": [
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2023-12-12T12:06:11+00:00"
},
{
"name": "symfony/asset",
"version": "v7.2.0",
@@ -5082,6 +5359,81 @@
],
"time": "2025-05-02T09:04:03+00:00"
},
{
"name": "symfony/ldap",
"version": "v7.2.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ldap.git",
"reference": "48013cfa9d394343162dae7da914112a6206b575"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ldap/zipball/48013cfa9d394343162dae7da914112a6206b575",
"reference": "48013cfa9d394343162dae7da914112a6206b575",
"shasum": ""
},
"require": {
"ext-ldap": "*",
"php": ">=8.2",
"symfony/options-resolver": "^6.4|^7.0"
},
"conflict": {
"symfony/options-resolver": "<6.4",
"symfony/security-core": "<6.4"
},
"require-dev": {
"symfony/security-core": "^6.4|^7.0",
"symfony/security-http": "^6.4|^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Ldap\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Charles Sarrazin",
"email": "charles@sarraz.in"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a LDAP client for PHP on top of PHP's ldap extension",
"homepage": "https://symfony.com",
"keywords": [
"active-directory",
"ldap"
],
"support": {
"source": "https://github.com/symfony/ldap/tree/v7.2.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-11-25T15:21:05+00:00"
},
{
"name": "symfony/mercure",
"version": "v0.6.5",

View File

@@ -17,4 +17,5 @@ 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],
];

57
config/dist/ldap.security.yaml vendored Normal file
View File

@@ -0,0 +1,57 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
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
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# 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,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

55
config/dist/local.security.yaml vendored Normal file
View File

@@ -0,0 +1,55 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_local
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# 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,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

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

View File

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

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

@@ -5,26 +5,27 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_user_provider
provider: app_local
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
@@ -35,6 +36,7 @@ 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

@@ -14,6 +14,14 @@ controllersUser:
defaults:
schemes: ['https']
controllersDownload:
resource:
path: ../src/Download/Framework/Controller
namespace: App\Download\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersMonitor:
resource:
path: ../src/Monitor/Framework/Controller

61
config/security.yaml Normal file
View File

@@ -0,0 +1,61 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_ldap
entry_point: form_login_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)%'
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# 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: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -4,11 +4,22 @@
# 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.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://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'
services:
# default configuration for services in *this* file
_defaults:
@@ -28,6 +39,38 @@ services:
# please note that last definitions always *replace* previous ones
App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader"
# Session
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'
# 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)%'
$searchDn: '%env(LDAP_BIND_USER)%'
$searchPassword: '%env(LDAP_BIND_PASS)%'
$defaultRoles: ['ROLE_USER']
$uidKey: '%env(LDAP_UID_KEY)%'
# $passwordAttribute: '%env(LDAP_PASSWORD_ATTRIBUTE)%'
Symfony\Component\Ldap\LdapInterface: '@Symfony\Component\Ldap\Ldap'
Symfony\Component\Ldap\Ldap:
arguments: [ '@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter' ]
tags:
- ldap
Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
arguments:
- host: '%env(LDAP_HOST)%'
port: '%env(LDAP_PORT)%'
encryption: '%env(LDAP_ENCRYPTION)%'
options:
protocol_version: 3
referrals: false

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

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

22
docker/Dockerfile.app Normal file
View File

@@ -0,0 +1,22 @@
FROM dunglas/frankenphp
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
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/caddy/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

18
docker/Dockerfile.worker Normal file
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", "async" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0

66
docker/app/Caddyfile Normal file
View File

@@ -0,0 +1,66 @@
{
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
{$FRANKENPHP_CONFIG}
worker {
file ./public/index.php
num 20
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
{$FRANKENPHP_WORKER_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 "$@"

57
docs/examples/.env Normal file
View File

@@ -0,0 +1,57 @@
# 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="QYYBR7OSQ4VEFKWASDEZ2B4VO67KHUJY6IWOT7HHA7ATXO7QCYDQ"
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

108
docs/examples/compose.yml Normal file
View File

@@ -0,0 +1,108 @@
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
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
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:
- ./downloads:/var/download
env_file:
- .env
restart: always
depends_on:
app:
condition: service_healthy
# 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:

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

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 Version20250510185814 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 user ADD username 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 user DROP username
SQL);
}
}

View File

@@ -0,0 +1,47 @@
<?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 Version20250511050008 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 user_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download ADD CONSTRAINT FK_781A8270A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_781A8270A76ED395 ON download (user_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 download DROP FOREIGN KEY FK_781A8270A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_781A8270A76ED395 ON download
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download DROP user_id
SQL);
}
}

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);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Command;
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: 'config:set',
description: 'Add a short description for your command',
)]
class ConfigSetCommand extends Command
{
public function __construct()
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('key', InputArgument::REQUIRED, 'Config key')
->addArgument('value', InputArgument::REQUIRED, 'Config value')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$key = $input->getArgument('key');
$handlers = [
'auth.method' => 'setAuthMethod',
];
$handler = $handlers[$key];
$this->$handler($input, $io);
$io->success('Success: "' . $input->getArgument('key') . '" set to "' . $input->getArgument('value') . '"');
return Command::SUCCESS;
}
private function setAuthMethod(InputInterface $input, SymfonyStyle $io)
{
$config = [
'local' => 'config/dist/local.security.yaml',
'ldap' => 'config/dist/ldap.security.yaml',
];
$authMethod = $input->getArgument('value');
$io->text('> Setting auth method to: ' . $authMethod);
copy($config[$authMethod], 'config/packages/security.yaml');
}
}

View File

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

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

@@ -22,7 +22,7 @@ final class AlertController extends AbstractController
{
$update = new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => 1,
'title' => 'Added to queue',
'message' => 'This is a testy test!',

View File

@@ -5,6 +5,7 @@ namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository;
use App\Tmdb\Tmdb;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -16,11 +17,13 @@ final class IndexController extends AbstractController
) {}
#[Route('/', name: 'app_index')]
public function index(): Response
public function index(Request $request): Response
{
$request->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->render('index/index.html.twig', [
'active_downloads' => $this->downloadRepository->getActivePaginated(),
'recent_downloads' => $this->downloadRepository->latest(5),
'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(),
'popular_movies' => $this->tmdb->popularMovies(1, 6),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]);

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'])]
@@ -34,24 +36,48 @@ final class SearchController extends AbstractController
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $input,
CacheInterface $cache
): Response {
$cacheId = sprintf("page.%s.%s", $input->mediaType, $input->tmdbId);
$result = $this->getMediaInfoHandler->handle($input->toCommand());
$this->warmDownloadOptionCache($result->media);
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [
'resolution' => '',
'codec' => '',
'provider' => '',
'language' => '',
'season' => 1,
'episode' => ''
]
]);
// });
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [
'resolution' => '',
'codec' => '',
'provider' => '',
'language' => '',
'season' => 1,
'episode' => ''
]
]);
}
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

@@ -8,6 +8,7 @@ use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
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;
@@ -63,7 +64,7 @@ final class TorrentioController extends AbstractController
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
@@ -75,8 +76,8 @@ final class TorrentioController extends AbstractController
$cache->delete($cacheId);
$this->hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
$request->getSession()->get('mercure_alert_topic'),
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Torrentio cache Cleared.',

View File

@@ -15,6 +15,7 @@ class DownloadMediaCommand implements CommandInterface
public string $filename,
public string $mediaType,
public string $imdbId,
public int $userId,
public ?int $downloadId = null,
) {}
}

View File

@@ -6,6 +6,7 @@ use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface;
use App\User\Framework\Repository\UserRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -17,12 +18,14 @@ readonly class DownloadMediaHandler implements HandlerInterface
public function __construct(
private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
if (null === $command->downloadId) {
$download = $this->downloadRepository->insert(
$this->userRepository->find($command->userId),
$command->url,
$command->title,
$command->filename,
@@ -34,7 +37,6 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId);
}
try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');

View File

@@ -26,6 +26,8 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')]
public string $imdbId,
public ?int $userId = null,
public ?int $downloadId = null,
) {}
@@ -37,6 +39,7 @@ class DownloadMediaInput implements InputInterface
$this->filename,
$this->mediaType,
$this->imdbId,
$this->userId,
$this->downloadId,
);
}

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

@@ -1,26 +1,32 @@
<?php
namespace App\Controller;
namespace App\Download\Framework\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
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;
class DownloadController extends AbstractController
class ApiController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly HubInterface $hub,
) {}
#[Route('/download', name: 'app_download', methods: ['POST'])]
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
public function download(
Request $request,
DownloadMediaInput $input,
): Response {
$download = $this->downloadRepository->insert(
$this->getUser(),
$input->url,
$input->title,
$input->filename,
@@ -28,13 +34,26 @@ class DownloadController extends AbstractController
$input->mediaType,
"",
);
$this->downloadRepository->getEntityManager()->persist($download);
$this->downloadRepository->getEntityManager()->flush();
$input->downloadId = $download->getId();
$input->userId = $this->getUser()->getId();
try {
$this->bus->dispatch($input->toCommand());
} catch (\Throwable $exception) {
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',
])
));
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Download\Framework\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
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;
class WebController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly HubInterface $hub,
) {}
#[Route('/downloads', name: 'app_downloads', methods: ['GET'])]
public function download(): Response {
return $this->render('downloads/index.html.twig');
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;
@@ -39,6 +40,9 @@ class Download
#[ORM\Column(length: 255, nullable: true)]
private ?string $batchId = null;
#[ORM\ManyToOne(inversedBy: 'downloads')]
private ?User $user = null;
public function getId(): ?int
{
return $this->id;
@@ -146,4 +150,16 @@ class Download
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
}

View File

@@ -3,11 +3,11 @@
namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\Download;
use App\ValueObject\DownloadRequest;
use App\User\Framework\Entity\User;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Knp\Component\Pager\Paginator;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Download>
@@ -16,41 +16,43 @@ class DownloadRepository extends ServiceEntityRepository
{
private ManagerRegistry $managerRegistry;
public function __construct(ManagerRegistry $registry, ManagerRegistry $managerRegistry)
private Paginator $paginator;
public function __construct(ManagerRegistry $registry, ManagerRegistry $managerRegistry, Paginator $paginator)
{
parent::__construct($registry, Download::class);
$this->managerRegistry = $managerRegistry;
$this->paginator = $paginator;
}
public function getCompletePaginated(int $pageNumber = 1, int $perPage = 10)
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->setParameter('user', $user)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function getActivePaginated(int $pageNumber = 1, int $perPage = 5)
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->setParameter('user', $user)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function insert(
UserInterface $user,
string $url,
string $title,
string $filename,
@@ -59,7 +61,9 @@ class DownloadRepository extends ServiceEntityRepository
string $batchId,
string $status = 'New'
): Download {
/** @var User $user */
$download = (new Download())
->setUser($user)
->setUrl($url)
->setTitle($title)
->setFilename($filename)
@@ -75,22 +79,6 @@ class DownloadRepository extends ServiceEntityRepository
return $download;
}
public function insertFromDownloadRequest(DownloadRequest $request): Download
{
$download = (new Download())
->setUrl($request->downloadUrl)
->setTitle($request->seriesName)
->setFilename($request->filename)
->setImdbId($request->imdbCode)
->setMediaType($request->mediaType)
->setStatus('New');
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function updateStatus(int $id, string $status): Download
{
$download = $this->find($id);
@@ -106,18 +94,6 @@ class DownloadRepository extends ServiceEntityRepository
$this->getEntityManager()->flush();
}
public function getPendingByBatchId(string $batchId): ?array
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.batchId = :batchId')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('batchId', $batchId)
->getQuery();
return $query->getResult();
}
public function latest(int $limit = 1)
{
return $this->createQueryBuilder('d')

View File

@@ -7,7 +7,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class AddMonitorCommand implements CommandInterface
{
public function __construct(
public string $userEmail,
public string $userId,
public string $title,
public string $imdbId,
public string $tmdbId,

View File

@@ -22,7 +22,7 @@ readonly class AddMonitorHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface
{
$user = $this->userRepository->findOneBy(['email' => $command->userEmail]);
$user = $this->userRepository->find($command->userId);
$monitor = (new Monitor())
->setUser($user)
->setTmdbId($command->tmdbId)

View File

@@ -2,9 +2,10 @@
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
@@ -15,6 +16,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
@@ -27,11 +29,13 @@ readonly class MonitorMovieHandler implements HandlerInterface
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Security $security,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler');
/** @var Monitor $monitor */
$monitor = $this->movieMonitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
@@ -51,6 +55,7 @@ readonly class MonitorMovieHandler implements HandlerInterface
$result->filename,
'movies',
$monitor->getImdbId(),
$monitor->getUser()->getId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeIMmutable());
@@ -59,7 +64,7 @@ readonly class MonitorMovieHandler implements HandlerInterface
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->incrementSearchCount();
$this->entityManager->flush();
return new MonitorMovieResult(

View File

@@ -58,6 +58,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$result->filename,
'tvshows',
$monitor->getImdbId(),
$monitor->getUser()->getId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable());

View File

@@ -12,7 +12,7 @@ class AddMonitorInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public string $userEmail,
public int|string $userId,
#[SourceRequest('tmdbId')]
public string $tmdbId,
@@ -36,7 +36,7 @@ class AddMonitorInput implements InputInterface
public function toCommand(): CommandInterface
{
return new AddMonitorCommand(
$this->userEmail,
$this->userId,
$this->title,
$this->imdbId,
$this->tmdbId,

View File

@@ -5,6 +5,7 @@ namespace App\Monitor\Framework\Controller;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
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;
@@ -16,7 +17,6 @@ class ApiController extends AbstractController
public function __construct(
#[Autowire(service: 'twig')]
private readonly Environment $renderer,
private readonly HubInterface $hub,
) {}
#[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])]
@@ -25,11 +25,13 @@ class ApiController extends AbstractController
AddMonitorHandler $handler,
HubInterface $hub,
) {
$response = $handler->handle($input->toCommand());
$command = $input->toCommand();
$command->userId = $this->getUser()->getId();
$response = $handler->handle($command);
$hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$input->title}",

View File

@@ -3,41 +3,34 @@
namespace App\Monitor\Framework\Repository;
use App\Monitor\Framework\Entity\Monitor;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Monitor>
*/
class MonitorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
private Paginator $paginator;
public function __construct(ManagerRegistry $registry, Paginator $paginator)
{
parent::__construct($registry, Monitor::class);
$this->paginator = $paginator;
}
// /**
// * @return MovieMonitor[] Returns an array of MovieMonitor objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage)
{
$query = $this->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')
->andWhere('m.user = :user')
->orderBy('m.id', 'ASC')
->setParameter('statuses', ['Active', 'New', 'In Progress', 'Complete'])
->setParameter('user', $user)
->getQuery();
// public function findOneBySomeField($value): ?MovieMonitor
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
return $this->paginator->paginate($query, $page, $perPage);
}
}

View File

@@ -11,7 +11,7 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('*/10 * * * *', schedule: 'monitor')]
#[AsCronTask('* * * * *', schedule: 'monitor')]
class MonitorDispatcher
{
public function __construct(
@@ -27,7 +27,7 @@ class MonitorDispatcher
'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class,
'tvseason' => MonitorTvSeasonCommand::class,
'tvshow' => MonitorTvShowCommand::class,
'tvshows' => MonitorTvShowCommand::class,
];
$monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]);

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

@@ -1,24 +0,0 @@
<?php
namespace App\Twig\Components;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class ActiveDownloadList
{
use DefaultActionTrait;
public function __construct(
private DownloadRepository $downloadRepository,
) {}
#[LiveAction]
public function getDownloads()
{
return $this->downloadRepository->getActivePaginated();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Twig\Components;
use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Paginator;
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\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class DownloadList extends AbstractController
{
use DefaultActionTrait;
#[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;
public function __construct(
private readonly DownloadRepository $downloadRepository,
) {}
public function getDownloads()
{
if ($this->type === "active") {
return $this->downloadRepository->getActivePaginated($this->getUser(), $this->pageNumber, $this->perPage);
} elseif ($this->type === "complete") {
return $this->downloadRepository->getCompletePaginated($this->getUser(), $this->pageNumber, $this->perPage);
}
return [];
}
#[LiveAction]
public function paginate(#[LiveArg] int $page)
{
$this->pageNumber = $page;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Twig\Components;
use App\Monitor\Framework\Repository\MonitorRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class MonitorList extends AbstractController
{
public bool $isWidget = true;
public function __construct(
private readonly MonitorRepository $monitorRepository,
) {}
#[LiveAction]
public function getUserMonitors(#[LiveArg] int $page = 1, #[LiveArg] int $perPage = 5)
{
return $this->monitorRepository->getUserMonitorsPaginated($this->getUser(), $page, $perPage);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class StatusBadge
{
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class TextInput
{
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\User\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<RegisterUserCommand> */
class RegisterLdapUserCommand implements CommandInterface
{
public function __construct(
public string $name,
public string $email,
public string $username,
public string $password,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\User\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<RegisterUserCommand> */
class RegisterUserCommand implements CommandInterface
{
public function __construct(
public ?string $name = null,
public ?string $email = null,
public ?string $username = null,
public ?string $password = null,
) {}
}

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,57 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\RegisterLdapUserCommand;
use App\User\Action\Result\RegisterLdapUserResult;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
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\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/** @implements HandlerInterface<RegisterLdapUserCommand, RegisterLdapUserResult> */
class RegisterLdapUserHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly UserPasswordHasherInterface $userPasswordHasher,
private readonly UserRepository $userRepository,
) {}
public function handle(C $command): R
{
$user = $this->userRepository->findOneBy(['username' => $command->username]);
if (null === $user) {
$user = new User();
$user->setPassword($this->userPasswordHasher->hashPassword($user, $command->password));
}
$user->setUsername($command->username);
$user->setEmail($command->email);
$user->setName($command->name);
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->setUserPreferences($user, $this->preferenceRepository->findEnabled());
$this->entityManager->flush();
return new RegisterLdapUserResult($user);
}
private function setUserPreferences(User $user, array $preferences): void
{
foreach ($preferences as $preference) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\RegisterUserCommand;
use App\User\Action\Result\RegisterUserResult;
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\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/** @implements HandlerInterface<RegisterUserCommand> */
class RegisterUserHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly UserPasswordHasherInterface $userPasswordHasher,
) {}
public function handle(C $command): R
{
$user = new User();
$user->setUsername($command->username);
$user->setEmail($command->email);
$user->setPassword($this->userPasswordHasher->hashPassword($user, $command->password));
$user->setName($command->name);
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->setUserPreferences($user, $this->preferenceRepository->findEnabled());
$this->entityManager->flush();
return new RegisterUserResult($user);
}
private function setUserPreferences(User $user, array $preferences): void
{
foreach ($preferences as $preference) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
}

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 App\User\Framework\Entity\User;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface */
class RegisterLdapUserResult implements ResultInterface
{
public function __construct(
public User $user,
) {}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Action\Result;
use App\User\Framework\Entity\User;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface */
class RegisterUserResult implements ResultInterface
{
public function __construct(
public User $user,
) {}
}

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,6 +2,8 @@
namespace App\User\Framework\Controller\Web;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -10,8 +12,12 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class LoginController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
public function login(AuthenticationUtils $authenticationUtils, UserRepository $userRepository): Response
{
if ((new ArrayCollection($userRepository->findAll()))->count() === 0) {
return $this->redirectToRoute('app_getting_started');
}
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();

View File

@@ -4,18 +4,18 @@ 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\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
@@ -23,21 +23,14 @@ 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 SaveUserDownloadPreferencesHandler $saveUserDownloadPreferencesHandler,
) {}
#[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);
@@ -47,44 +40,76 @@ 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,
): Response
{
$userPreferences = $this->saveUserMediaPreferencesHandler->handle($input->toCommand())->userPreferences;
$userPreferences = Map::from($userPreferences)->rekey(fn($preference) => $preference->getPreference()->getId());
$this->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.',
])
));
return $this->render(
'user/preferences.html.twig',
[
'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(
Request $request,
SaveUserDownloadPreferencesInput $input,
): 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 = $this->saveUserDownloadPreferencesHandler->handle($input->toCommand())->downloadPreferences;
$mediaPreferences = $this->getUser()->getMediaPreferences();
$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 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

@@ -2,43 +2,48 @@
namespace App\User\Framework\Controller\Web;
use App\User\Action\Command\RegisterUserCommand;
use App\User\Action\Handler\RegisterUserHandler;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Form\RegistrationFormType;
use App\User\Framework\Pipeline\GettingStarted\AddPreferencesToDatabase;
use App\User\Framework\Pipeline\GettingStarted\GettingStartedInput;
use App\User\Framework\Pipeline\GettingStarted\MigrateDatabase;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use League\Pipeline\Pipeline;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
public function __construct(private readonly RegisterUserHandler $registerUserHandler)
{
}
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $userPasswordHasher,
EntityManagerInterface $entityManager,
PreferencesRepository $preferencesRepository,
Security $security,
): Response {
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form = $this->createForm(RegistrationFormType::class, new User());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
$user = $this->registerUserHandler->handle(new RegisterUserCommand(
name: $form->get('name')->getData(),
email: $form->get('email')->getData(),
username: $form->get('username')->getData(),
password: $form->get('plainPassword')->getData(),
));
// encode the plain password
$user->setPassword($userPasswordHasher->hashPassword($user, $plainPassword));
$entityManager->persist($user);
$entityManager->flush();
$this->setUserPreferences($user, $preferencesRepository->findEnabled());
$preferencesRepository->getEntityManager()->flush();
$security->login($user->user);
return $this->redirectToRoute('app_index');
}
@@ -48,14 +53,30 @@ class RegistrationController extends AbstractController
]);
}
private function setUserPreferences(User $user, array $preferences): void
#[Route(path: '/getting-started', name: 'app_getting_started')]
public function gettingStarted(Request $request, Security $security, UserRepository $userRepository, PreferencesRepository $preferencesRepository, KernelInterface $kernel, LoggerInterface $logger): Response
{
foreach ($preferences as $preference) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
if ((new ArrayCollection($userRepository->findAll()))->count() !== 0) {
return $this->redirectToRoute('app_index');
}
$form = $this->createForm(RegistrationFormType::class, new User());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->registerUserHandler->handle(new RegisterUserCommand(
name: $form->get('name')->getData(),
email: $form->get('email')->getData(),
password: $form->get('plainPassword')->getData(),
));
$security->login($user->user);
return $this->redirectToRoute('app_index');
}
return $this->render('user/getting-started.html.twig', [
'registrationForm' => $form,
]);
}
}

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;
@@ -44,6 +47,12 @@ class Preference
return $this->name;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function setName(?string $name): static
{
$this->name = $name;
@@ -51,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

@@ -3,6 +3,7 @@
namespace App\User\Framework\Entity;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use App\Monitor\Framework\Entity\Monitor;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
@@ -22,6 +23,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $username;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email;
@@ -44,12 +48,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* @var Collection<int, Monitor>
*/
#[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $yes;
private Collection $monitors;
/**
* @var Collection<int, Download>
*/
#[ORM\OneToMany(targetEntity: Download::class, mappedBy: 'user')]
private Collection $downloads;
public function __construct()
{
$this->userPreferences = new ArrayCollection();
$this->yes = new ArrayCollection();
$this->monitors = new ArrayCollection();
$this->downloads = new ArrayCollection();
}
public function getId(): ?int
@@ -88,7 +99,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
return (string) $this->username ?? $this->email;
}
/**
@@ -193,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())
@@ -202,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();
}
@@ -215,30 +225,108 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/**
* @return Collection<int, Monitor>
*/
public function getYes(): Collection
public function getMonitors(): Collection
{
return $this->yes;
return $this->monitors;
}
public function addYe(Monitor $ye): static
public function addMonitor(Monitor $monitor): static
{
if (!$this->yes->contains($ye)) {
$this->yes->add($ye);
$ye->setUser($this);
if (!$this->monitors->contains($monitor)) {
$this->monitors->add($monitor);
$monitor->setUser($this);
}
return $this;
}
public function removeYe(Monitor $ye): static
public function removeMonitor(Monitor $monitor): static
{
if ($this->yes->removeElement($ye)) {
if ($this->monitors->removeElement($monitor)) {
// set the owning side to null (unless already changed)
if ($ye->getUser() === $this) {
$ye->setUser(null);
if ($monitor->getUser() === $this) {
$monitor->setUser(null);
}
}
return $this;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(?string $username): static
{
$this->username = $username;
return $this;
}
/**
* @return Collection<int, Download>
*/
public function getDownloads(): Collection
{
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>
*/
public function getActiveDownloads(): Collection
{
return $this->downloads->filter(fn(Download $download) => in_array($download->getStatus(), ['New', 'In Progress']));
}
public function addDownload(Download $download): static
{
if (!$this->downloads->contains($download)) {
$this->downloads->add($download);
$download->setUser($this);
}
return $this;
}
public function removeDownload(Download $download): static
{
if ($this->downloads->removeElement($download)) {
// set the owning side to null (unless already changed)
if ($download->getUser() === $this) {
$download->setUser(null);
}
}
return $this;
}
public function queryDownloads(string $type = 'complete', int $limit = 5)
{
if ($type === 'complete') {
return $this->downloads->filter(fn($item) => in_array($item->getStatus(), ['Complete']))->slice(0, $limit);
} elseif ($type === 'in-progress') {
return $this->downloads->filter(fn($item) => in_array($item->getStatus(), ['New', 'In Progress']))->slice(0, $limit);
}
return [];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\User\Framework\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
/**
* @see https://symfony.com/doc/current/security/custom_authenticator.html
*/
class LdapAuthenticator extends AbstractAuthenticator
{
/**
* Called on every request to decide if this authenticator should be
* used for the request. Returning `false` will cause this authenticator
* to be skipped.
*/
public function supports(Request $request): ?bool
{
// return $request->headers->has('X-AUTH-TOKEN');
}
public function authenticate(Request $request): Passport
{
// $apiToken = $request->headers->get('X-AUTH-TOKEN');
// if (null === $apiToken) {
// The token header was empty, authentication fails with HTTP Status
// Code 401 "Unauthorized"
// throw new CustomUserMessageAuthenticationException('No API token provided');
// }
// implement your own logic to get the user identifier from `$apiToken`
// e.g. by looking up a user in the database using its API key
// $userIdentifier = /** ... */;
// return new SelfValidatingPassport(new UserBadge($userIdentifier));
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// on success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$data = [
// you may want to customize or obfuscate the message first
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
// or to translate this message
// $this->translator->trans($exception->getMessageKey(), $exception->getMessageData())
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
// public function start(Request $request, ?AuthenticationException $authException = null): Response
// {
// /*
// * If you would like this class to control what happens when an anonymous user accesses a
// * protected page (e.g. redirect to /login), uncomment this method and make this class
// * implement Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface.
// *
// * For more details, see https://symfony.com/doc/current/security/experimental_authenticators.html#configuring-the-authentication-entry-point
// */
// }
}

View File

@@ -0,0 +1,218 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\User\Framework\Security;
use App\User\Action\Command\RegisterLdapUserCommand;
use App\User\Action\Handler\RegisterLdapUserHandler;
use App\User\Framework\Entity\User;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Ldap\Entry;
use Symfony\Component\Ldap\Exception\ExceptionInterface;
use Symfony\Component\Ldap\Exception\InvalidCredentialsException;
use Symfony\Component\Ldap\Exception\InvalidSearchCredentialsException;
use Symfony\Component\Ldap\LdapInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Ldap\Security\LdapUser;
/**
* LdapUserProvider is a simple user provider on top of LDAP.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Charles Sarrazin <charles@sarraz.in>
* @author Robin Chalas <robin.chalas@gmail.com>
*
* @template-implements UserProviderInterface<LdapUser>
*/
class LdapUserProvider implements UserProviderInterface, PasswordUpgraderInterface
{
private string $uidKey;
private string $defaultSearch;
private string $usernameAttribute;
private string $emailAttribute;
private string $displayNameAttribute;
public function __construct(
private RegisterLdapUserHandler $registerLdapUserHandler,
private UserRepository $userRepository,
private LdapInterface $ldap,
private string $baseDn,
private ?string $searchDn = null,
#[\SensitiveParameter] private ?string $searchPassword = null,
private array $defaultRoles = [],
?string $uidKey = null,
?string $filter = null,
private ?string $passwordAttribute = null,
private array $extraFields = [],
string $usernameAttribute = 'uid',
string $emailAttribute = 'mail',
string $displayNameAttribute = 'displayName',
) {
$uidKey ??= 'sAMAccountName';
$filter ??= '({uid_key}={user_identifier})';
$this->uidKey = $uidKey;
$this->defaultSearch = str_replace('{uid_key}', $uidKey, $filter);
$this->usernameAttribute = $usernameAttribute;
$this->emailAttribute = $emailAttribute;
$this->displayNameAttribute = $displayNameAttribute;
}
public function loadUserByIdentifier(string $identifier): UserInterface
{
try {
$this->ldap->bind($this->searchDn, $this->searchPassword);
} catch (InvalidCredentialsException) {
throw new InvalidSearchCredentialsException();
}
$identifier = $this->ldap->escape($identifier, '', LdapInterface::ESCAPE_FILTER);
$query = str_replace('{user_identifier}', $identifier, $this->defaultSearch);
$search = $this->ldap->query($this->baseDn, $query, ['filter' => 0 == \count($this->extraFields) ? '*' : $this->extraFields]);
$entries = $search->execute();
$count = \count($entries);
if (!$count) {
$e = new UserNotFoundException(\sprintf('User "%s" not found.', $identifier));
$e->setUserIdentifier($identifier);
throw $e;
}
if ($count > 1) {
$e = new UserNotFoundException('More than one user found.');
$e->setUserIdentifier($identifier);
throw $e;
}
$entry = $entries[0];
try {
$identifier = $this->getAttributeValue($entry, $this->uidKey);
} catch (InvalidArgumentException) {
// This is empty
}
return $this->loadUser($identifier, $entry);
}
public function refreshUser(UserInterface $user): UserInterface
{
if (!$user instanceof LdapUser) {
throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
return new LdapUser($user->getEntry(), $user->getUserIdentifier(), $user->getPassword(), $user->getRoles(), $user->getExtraFields());
}
/**
* @final
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof LdapUser) {
throw new UnsupportedUserException(\sprintf('Instances of "%s" are not supported.', get_debug_type($user)));
}
if (null === $this->passwordAttribute) {
return;
}
try {
$user->getEntry()->setAttribute($this->passwordAttribute, [$newHashedPassword]);
$this->ldap->getEntryManager()->update($user->getEntry());
$user->setPassword($newHashedPassword);
} catch (ExceptionInterface) {
// ignore failed password upgrades
}
}
public function supportsClass(string $class): bool
{
return User::class === $class;
}
/**
* Loads a user from an LDAP entry.
*/
protected function loadUser(string $identifier, Entry $entry): UserInterface
{
$extraFields = [];
foreach ($this->extraFields as $field) {
$extraFields[$field] = $this->getAttributeValue($entry, $field);
}
$dbUser = $this->getDbUser($identifier, $entry);
if (null === $dbUser) {
return $this->registerLdapUserHandler->handle(new RegisterLdapUserCommand(
name:$this->getAttributeValue($entry, $this->displayNameAttribute)[0] ?? null,
email:$this->getAttributeValue($entry, $this->emailAttribute)[0] ?? null,
username:$this->getAttributeValue($entry, $this->usernameAttribute) ?? null,
password: uniqid(),
))->user;
} else {
$dbUser
->setName($this->getAttributeValue($entry, $this->displayNameAttribute)[0] ?? null)
->setEmail($this->getAttributeValue($entry, $this->emailAttribute)[0] ?? null)
->setUsername($this->getAttributeValue($entry, $this->usernameAttribute) ?? null);
$this->userRepository->getEntityManager()->flush();
}
return $dbUser;
}
/** @return User */
private function getDbUser(string $identifier, Entry $entry): ?UserInterface
{
if (in_array($this->uidKey, ['mail', 'email'])) {
$dbUser = $this->userRepository->findOneBy(['email' => $identifier]);
} else {
$dbUser = $this->userRepository->findOneBy(['username' => $identifier]);
}
// Attempt to map LDAP user to existing user
if (null === $dbUser) {
if ($entry->hasAttribute($this->emailAttribute)) {
$dbUser = $this->userRepository->findOneBy(['email' => $this->getAttributeValue($entry, $this->emailAttribute)]);;
}
}
return $dbUser;
}
private function getAttributeValue(Entry $entry, string $attribute): mixed
{
if (!$entry->hasAttribute($attribute)) {
throw new InvalidArgumentException(\sprintf('Missing attribute "%s" for user "%s".', $attribute, $entry->getDn()));
}
$values = $entry->getAttribute($attribute);
if (!\in_array($attribute, [$this->uidKey, $this->passwordAttribute])) {
return $values;
}
if (1 !== \count($values)) {
throw new InvalidArgumentException(\sprintf('Attribute "%s" has multiple values.', $attribute));
}
return $values[0];
}
}

62
src/Util/Paginator.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
namespace App\Util;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as OrmPaginator;
class Paginator
{
/**
* @var integer
*/
private $total;
/**
* @var integer
*/
private $lastPage;
private $items;
public $currentPage = 1;
/**
* @param QueryBuilder|Query $query
* @param int $page
* @param int $limit
* @return Paginator
*/
public function paginate($query, int $page = 1, int $limit = 5): Paginator
{
$paginator = new OrmPaginator($query);
$paginator
->getQuery()
->setFirstResult($limit * ($page - 1))
->setMaxResults($limit);
$this->total = $paginator->count();
$this->lastPage = (int) ceil($paginator->count() / $paginator->getQuery()->getMaxResults());
$this->items = $paginator;
$this->currentPage = $page;
return $this;
}
public function getTotal(): int
{
return $this->total;
}
public function getLastPage(): int
{
return $this->lastPage;
}
public function getItems()
{
return $this->items;
}
}

View File

@@ -16,6 +16,18 @@
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-fixtures-bundle": {
"version": "4.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
},
"files": [
"src/DataFixtures/AppFixtures.php"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.4",
"recipe": {

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