Compare commits
79 Commits
v0.38.5
...
dev-simple
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e39cb6e9bd | ||
|
|
9e2c5410ba | ||
|
|
4087543e78 | ||
|
|
d358ef8de6 | ||
|
|
79d9d61592 | ||
|
|
4d0a198510 | ||
|
|
5da1dde24d | ||
|
|
6ed327f78e | ||
|
|
1827c1df71 | ||
|
|
0c5fd2544b | ||
|
|
a6d5e2b026 | ||
|
|
688ea98922 | ||
|
|
d55a9cfdd5 | ||
|
|
e9021c22fa | ||
|
|
939660a715 | ||
|
|
fb3e7b20ff | ||
|
|
699dbaabc3 | ||
|
|
484ac40d99 | ||
|
|
b8a22e63c9 | ||
|
|
e489f73f7c | ||
|
|
554b7774f6 | ||
|
|
86ea9d5b38 | ||
|
|
a61da34f2a | ||
|
|
225754dfe5 | ||
|
|
6e358fe1eb | ||
|
|
a2da2698e3 | ||
|
|
ee017d7ae6 | ||
|
|
b0b5211e88 | ||
|
|
c6f0220889 | ||
|
|
b1cd1cf0bf | ||
|
|
dab6504e71 | ||
|
|
e361e998b3 | ||
|
|
bc73625121 | ||
|
|
a2439f3619 | ||
|
|
edfb1b92cb | ||
|
|
1097e4c313 | ||
|
|
fe10a8d4b0 | ||
|
|
96da4ce1b0 | ||
|
|
6aad92a0c6 | ||
|
|
7fc7db0577 | ||
|
|
e5ed2d9556 | ||
|
|
fbe4e736c8 | ||
|
|
bc635d5b76 | ||
|
|
c3a9c69c91 | ||
|
|
b043bb9cc9 | ||
|
|
d9afa9b558 | ||
|
|
4f0b873bed | ||
|
|
fb067d5396 | ||
|
|
dbaf7c4880 | ||
|
|
4750c53b58 | ||
|
|
aa0ce72d35 | ||
| 6360e6495f | |||
|
|
ed2f797ac2 | ||
|
|
91f91c20fa | ||
| 2f7d276781 | |||
| e22306225b | |||
| 6a860a4d75 | |||
| 5ff89b905f | |||
| 49b017de3d | |||
| 937b673be6 | |||
| 3e04d0a82d | |||
| 706e8e9892 | |||
| 154292530a | |||
| 82c3f7bb78 | |||
| e7f8f278ee | |||
|
|
c4b3fb215c | ||
|
|
2fc6d792bc | ||
|
|
c9f1a2d93a | ||
|
|
bbdd11d1b5 | ||
|
|
1827908936 | ||
|
|
8b99a744e2 | ||
|
|
2d42b60e26 | ||
|
|
448dd12fa5 | ||
|
|
a30a554e06 | ||
|
|
bd6918abd1 | ||
|
|
9a0b0443d4 | ||
| 1726b21d1d | |||
|
|
207fd26f50 | ||
|
|
aa357725e8 |
5
.env
5
.env
@@ -13,7 +13,7 @@
|
|||||||
#
|
#
|
||||||
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
||||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
||||||
|
APP_URL=
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
APP_ENV=prod
|
APP_ENV=prod
|
||||||
APP_SECRET=
|
APP_SECRET=
|
||||||
@@ -27,9 +27,12 @@ APP_SECRET=
|
|||||||
# 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=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="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||||
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
|
MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
|
MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
###> symfony/messenger ###
|
###> symfony/messenger ###
|
||||||
# Choose one of the transports below
|
# Choose one of the transports below
|
||||||
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
||||||
|
|||||||
59
.gitea/workflows/build_base_images.yml
Normal file
59
.gitea/workflows/build_base_images.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-base-app:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: "${{ vars.REGISTRY_URL }}"
|
||||||
|
username: "${{ vars.REGISTRY_USER }}"
|
||||||
|
password: "${{ vars.REGISTRY_PASS }}"
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
env:
|
||||||
|
APP_FRANKENPHP_TAG: php8.4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
file: docker/Dockerfile.base.worker
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
APP_FRANKENPHP_TAG=${{ env.APP_FRANKENPHP_TAG }}
|
||||||
|
tags: |
|
||||||
|
code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG}
|
||||||
|
code.caldwell.digital/home/torsearch-base:latest
|
||||||
|
|
||||||
|
build-base-worker-supervisord:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: "${{ vars.REGISTRY_URL }}"
|
||||||
|
username: "${{ vars.REGISTRY_USER }}"
|
||||||
|
password: "${{ vars.REGISTRY_PASS }}"
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
file: docker/Dockerfile.base.worker
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
ALPINE_VERSION=3.22
|
||||||
|
tags: |
|
||||||
|
code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
||||||
104
.gitea/workflows/ci.yml
Normal file
104
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "main"
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
|
||||||
|
- name: Install phing
|
||||||
|
run: composer global require phing/phing
|
||||||
|
|
||||||
|
- name: Run composer
|
||||||
|
run: composer install --no-dev --no-scripts --ignore-platform-reqs -o
|
||||||
|
|
||||||
|
- name: Build tailwind
|
||||||
|
run: APP_ENV=build php bin/console tailwind:build
|
||||||
|
|
||||||
|
- name: Install frontend assets
|
||||||
|
run: APP_ENV=build php bin/console importmap:install
|
||||||
|
|
||||||
|
- name: Compile assets
|
||||||
|
run: APP_ENV=build php bin/console asset-map:compile
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: "${{ vars.REGISTRY_URL }}"
|
||||||
|
username: "${{ vars.REGISTRY_USER }}"
|
||||||
|
password: "${{ vars.REGISTRY_PASS }}"
|
||||||
|
|
||||||
|
- name: Create torsearch-app docker image
|
||||||
|
run: |
|
||||||
|
tag="${{ gitea.REF_NAME }}"
|
||||||
|
tmdb_api="${{ vars.TMDB_API }}"
|
||||||
|
version=${tag:1}
|
||||||
|
docker build --pull -f docker/Dockerfile.app \
|
||||||
|
-t code.caldwell.digital/home/torsearch-app:${version} \
|
||||||
|
-t code.caldwell.digital/home/torsearch-app:latest \
|
||||||
|
--build-arg "APP_VERSION=${version}" \
|
||||||
|
--build-arg "TMDB_API=${tmdb_api}" \
|
||||||
|
.
|
||||||
|
docker push code.caldwell.digital/home/torsearch-app:${version}
|
||||||
|
docker push code.caldwell.digital/home/torsearch-app:latest
|
||||||
|
|
||||||
|
- name: Create torsearch-worker docker image
|
||||||
|
run: |
|
||||||
|
tag="${{ gitea.REF_NAME }}"
|
||||||
|
tmdb_api="${{ vars.TMDB_API }}"
|
||||||
|
version=${tag:1}
|
||||||
|
docker build --pull -f docker/Dockerfile.worker \
|
||||||
|
-t code.caldwell.digital/home/torsearch-worker:${version} \
|
||||||
|
-t code.caldwell.digital/home/torsearch-worker:latest \
|
||||||
|
--build-arg "APP_VERSION=${version}" \
|
||||||
|
--build-arg "TMDB_API=${tmdb_api}" \
|
||||||
|
.
|
||||||
|
docker push code.caldwell.digital/home/torsearch-worker:${version}
|
||||||
|
docker push code.caldwell.digital/home/torsearch-worker:latest
|
||||||
|
|
||||||
|
- name: Create torsearch-scheduler docker image
|
||||||
|
run: |
|
||||||
|
tag="${{ gitea.REF_NAME }}"
|
||||||
|
tmdb_api="${{ vars.TMDB_API }}"
|
||||||
|
version=${tag:1}
|
||||||
|
docker build --pull -f docker/Dockerfile.scheduler \
|
||||||
|
-t code.caldwell.digital/home/torsearch-scheduler:${version} \
|
||||||
|
-t code.caldwell.digital/home/torsearch-scheduler:latest \
|
||||||
|
--build-arg "APP_VERSION=${version}" \
|
||||||
|
--build-arg "TMDB_API=${tmdb_api}" \
|
||||||
|
.
|
||||||
|
docker push code.caldwell.digital/home/torsearch-scheduler:${version}
|
||||||
|
docker push code.caldwell.digital/home/torsearch-scheduler:latest
|
||||||
|
|
||||||
|
- name: Create artifact
|
||||||
|
run: |
|
||||||
|
file="torsearch-${{ gitea.REF_NAME }}.tar.gz"
|
||||||
|
touch $file
|
||||||
|
tar -cvzf $file --exclude=$file .
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
files: |-
|
||||||
|
"torsearch-${{ gitea.REF_NAME }}.tar.gz"
|
||||||
|
|
||||||
|
# - name: Upload artifact
|
||||||
|
# uses: actions/upload-artifact@v3
|
||||||
|
# with:
|
||||||
|
# name: "torsearch-${{ gitea.REF_NAME }}.tar.gz"
|
||||||
|
# path: "torsearch-${{ gitea.REF_NAME }}.tar.gz"
|
||||||
|
# compression_level: 9
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened]
|
|
||||||
|
|
||||||
name: SonarQube Scan
|
|
||||||
jobs:
|
|
||||||
sonarqube:
|
|
||||||
name: SonarQube Trigger
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checking out
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: SonarQube Scan
|
|
||||||
uses: https://code.caldwell.digital/tools/sonarqube-action@v0.0.3
|
|
||||||
with:
|
|
||||||
host: "https://qube.caldwell.digital"
|
|
||||||
login: ${{ secrets.SONARQUBE_TOKEN }}
|
|
||||||
projectName: "torsearch"
|
|
||||||
projectBaseDir: "./src"
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,3 +24,6 @@ phpstan.neon
|
|||||||
/phpunit.xml
|
/phpunit.xml
|
||||||
/.phpunit.cache/
|
/.phpunit.cache/
|
||||||
###< phpunit/phpunit ###
|
###< phpunit/phpunit ###
|
||||||
|
|
||||||
|
docs/examples/movies/
|
||||||
|
docs/examples/tvshows/
|
||||||
|
|||||||
224
README.md
224
README.md
@@ -4,28 +4,6 @@ and download your favorite movies and tv shows. You can think of it like Stremio
|
|||||||
comparison to Stremio? That's because Torsearch uses the same source for media files that Stremio uses: Torrentio
|
comparison to Stremio? That's because Torsearch uses the same source for media files that Stremio uses: Torrentio
|
||||||
(hence the name: Torsearch).
|
(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
|
## Pics or didn't happen
|
||||||

|

|
||||||

|

|
||||||
@@ -37,14 +15,202 @@ required to make it work, I was able to re-write the app with some design patter
|
|||||||
- Search for Movies & TV Shows by their name
|
- Search for Movies & TV Shows by their name
|
||||||
- Download directly to your NAS
|
- Download directly to your NAS
|
||||||
- Monitor TV Shows for new episodes and automatically download them
|
- Monitor TV Shows for new episodes and automatically download them
|
||||||
- Browse popular media and view its download options
|
- Discover new media
|
||||||
- LDAP or local auth (OIDC coming soon)
|
- OIDC
|
||||||
|
|
||||||
## Features on the roadmap
|
|
||||||
- Requests - allow users to request new media
|
|
||||||
- OIDC auth
|
|
||||||
- Prometheus logging
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
1. Clone the repo
|
For all pieces to work, you will need to serve the application over HTTPS. Running behind an SSL terminating
|
||||||
|
reverse proxy is the recommended approach.
|
||||||
|
|
||||||
|
1. Create a `compose.yml` file
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
### The app contains the application and web server ###
|
||||||
|
app:
|
||||||
|
image: code.caldwell.digital/home/torsearch-app:${TAG}
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT}:80"
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ${LOCAL_MOVIES_DIR}:/var/download/movies
|
||||||
|
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
|
||||||
|
- mercure_data:/data
|
||||||
|
- mercure_config:/config
|
||||||
|
|
||||||
|
|
||||||
|
### The worker handles downloads and async jobs ###
|
||||||
|
worker:
|
||||||
|
image: code.caldwell.digital/home/torsearch-worker:${TAG}
|
||||||
|
volumes:
|
||||||
|
- ${LOCAL_MOVIES_DIR}:/var/download/movies
|
||||||
|
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
|
||||||
|
### The scheduler processes monitored media ###
|
||||||
|
scheduler:
|
||||||
|
image: code.caldwell.digital/home/torsearch-scheduler:${TAG}
|
||||||
|
volumes:
|
||||||
|
- ${LOCAL_MOVIES_DIR}:/var/download/movies
|
||||||
|
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
|
||||||
|
#!! If using your own database, this can be omitted !!#
|
||||||
|
database:
|
||||||
|
image: mariadb:10.11.2
|
||||||
|
volumes:
|
||||||
|
- mysql:/var/lib/mysql
|
||||||
|
env_file: .env
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
|
||||||
|
#!! If using your own redis, this can be omitted !!#
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: redis-server --maxmemory 512MB
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql:
|
||||||
|
mercure_config:
|
||||||
|
mercure_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a `.env` file
|
||||||
|
```dotenv
|
||||||
|
###################
|
||||||
|
# Torsearch #
|
||||||
|
###################
|
||||||
|
# The version of Torsearch to run. Docker will this tag.
|
||||||
|
TAG=latest
|
||||||
|
|
||||||
|
# The port for which the web server (app container) will
|
||||||
|
# serve the application on the host.
|
||||||
|
WEB_PORT=8004
|
||||||
|
|
||||||
|
# The host directories where your media is stored.
|
||||||
|
# If you would like to use a docker driver for a network
|
||||||
|
# share, update the compose.yml file to reflect that.
|
||||||
|
# These are passed into the compose file as bind mounts.
|
||||||
|
LOCAL_MOVIES_DIR="./movies"
|
||||||
|
LOCAL_TVSHOWS_DIR="./tvshows"
|
||||||
|
|
||||||
|
# Set the timezone you're in. This helps render monitored items correctly on the calendar
|
||||||
|
TZ=America/Chicago
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Symfony #
|
||||||
|
###################
|
||||||
|
# The external URL of the application where it can be reached by a browser. Should start with https://
|
||||||
|
APP_URL="<enter url>"
|
||||||
|
# Requried by Symfony Framework. Feel free to change.
|
||||||
|
APP_SECRET="70169beadfbc8101c393cbfbba27a313"
|
||||||
|
# Change to 'dev' to show logs in the browser.
|
||||||
|
APP_ENV=prod
|
||||||
|
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Mercure #
|
||||||
|
###################
|
||||||
|
# Mercure is a Caddy module built into the webserver
|
||||||
|
# that facilitates the usage of websockets to transmit
|
||||||
|
# real time data (download progress, etc.)
|
||||||
|
# TBH, I've only run into issues when changing these. If
|
||||||
|
# you have problems after changing them, just revert them.
|
||||||
|
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
|
MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
|
MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
|
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Database #
|
||||||
|
###################
|
||||||
|
# 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
|
||||||
|
MYSQL_USER=app
|
||||||
|
MYSQL_PASSWORD="P@ssword123"
|
||||||
|
MYSQL_DATABASE=app
|
||||||
|
MYSQL_HOST=database
|
||||||
|
MYSQL_RANDOM_ROOT_PASSWORD=true
|
||||||
|
DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}:3306/${MYSQL_DATABASE}?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||||
|
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Real Debrid #
|
||||||
|
###################
|
||||||
|
# Enter your Real Debrid API key which is passed to Torrentio to retrieve download options
|
||||||
|
REAL_DEBRID_KEY="<enter real debrid api key>"
|
||||||
|
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Redis #
|
||||||
|
###################
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Auth #
|
||||||
|
###################
|
||||||
|
# Options: form_login, oidc, or ldap (experimental)
|
||||||
|
# Fill the rest of the configuration based on your choice here
|
||||||
|
# No additional config is required for form_login
|
||||||
|
AUTH_METHOD=form_login
|
||||||
|
|
||||||
|
|
||||||
|
### OIDC ###
|
||||||
|
# To use OIDC, set the AUTH_METHOD to oidc and fill in the following properties
|
||||||
|
#OIDC_WELL_KNOWN_URL=
|
||||||
|
#OIDC_CLIENT_ID=
|
||||||
|
#OIDC_CLIENT_SECRET=
|
||||||
|
# Allows you to skip the login page and directly rely on your IdP for auth.
|
||||||
|
#OIDC_BYPASS_FORM_LOGIN=
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Enter the `APP_URL` in the .env file
|
||||||
|
4. Enter the `REAL_DEBRID_KEY` in the .env file
|
||||||
|
5. Enter a new `WEB_PORT` if the default doesn't work for you
|
||||||
|
4. Run `docker compose up -d`
|
||||||
|
4. Visit the app in the browser
|
||||||
|
5. Create a user
|
||||||
|
6. Visit the Preferences page to set your filter. This filter is used whenever you don't choose a specific file to
|
||||||
|
download (e.g. downloading via Monitor or clicking the "Download Season", "Download Selected", or "Download Episode" buttons).
|
||||||
|
7. Start downloading media!
|
||||||
|
|
||||||
|
## Having issues?
|
||||||
|
Submit an issue in the repo, and I'll try to address it as soon as possible. I do have a full-time job and family, so my
|
||||||
|
time is limited, but I'll do my best!
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
This is my first contribution to open-source, the community that's given me so much over the years!
|
||||||
|
This project has been my personal hobby project for the last 1.5 years. I've written and re-written it several times.
|
||||||
|
It's been my testing ground for trying new things, so if the code looks like shit, my bad. I'm a PHP developer by day and
|
||||||
|
tinkerer by night - this was my first go with Symfony/Twig components, tailwind, the Symfony RICH bundle, and a lot more.
|
||||||
|
At some point, I'll put together a contribution guide, so others can hack on it too.
|
||||||
|
|
||||||
|
No AI was used for development (only to generate a list of countries with their flag emojis). If the code is bad, it's my fault.
|
||||||
|
|
||||||
|
# Disclaimer
|
||||||
|
Torsearch does not host any media; it only combines API results from multiple sources to make browsing them easier.
|
||||||
|
Torsearch is not affiliated with Real Debrid, Torrentio, or TMDB.
|
||||||
|
|||||||
2
assets/bootstrap.js
vendored
2
assets/bootstrap.js
vendored
@@ -5,6 +5,7 @@ import DownloadOptionTr from './components/download-option-tr.js';
|
|||||||
import DownloadListRow from './components/download-list-row.js';
|
import DownloadListRow from './components/download-list-row.js';
|
||||||
import MonitorListRow from './components/monitor-list-row.js';
|
import MonitorListRow from './components/monitor-list-row.js';
|
||||||
import MovieContainer from "./components/movie-container.js";
|
import MovieContainer from "./components/movie-container.js";
|
||||||
|
import StatusCheckerSpan from "./components/status-checker-span.js";
|
||||||
|
|
||||||
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
||||||
import Popover from '@stimulus-components/popover';
|
import Popover from '@stimulus-components/popover';
|
||||||
@@ -24,3 +25,4 @@ customElements.define('movie-container', MovieContainer);
|
|||||||
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});
|
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});
|
||||||
customElements.define('download-list-row', DownloadListRow, {extends: 'tr'});
|
customElements.define('download-list-row', DownloadListRow, {extends: 'tr'});
|
||||||
customElements.define('monitor-list-row', MonitorListRow, {extends: 'tr'});
|
customElements.define('monitor-list-row', MonitorListRow, {extends: 'tr'});
|
||||||
|
customElements.define('status-checker-span', StatusCheckerSpan, {extends: 'span'});
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
download() {
|
download() {
|
||||||
|
const preferencesForm = document.querySelector('[name="user_media_preferences_form"]');
|
||||||
|
const preferences = {
|
||||||
|
resolution: preferencesForm.querySelector('[id="user_media_preferences_form_resolution"]').value,
|
||||||
|
codec: preferencesForm.querySelector('[id="user_media_preferences_form_codec"]').value,
|
||||||
|
language: preferencesForm.querySelector('[id="user_media_preferences_form_language"]').value,
|
||||||
|
quality: preferencesForm.querySelector('[id="user_media_preferences_form_quality"]').value,
|
||||||
|
provider: preferencesForm.querySelector('[id="user_media_preferences_form_provider"]').value,
|
||||||
|
}
|
||||||
fetch('/api/download', {
|
fetch('/api/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -109,12 +117,11 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
|
|||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: this.url,
|
|
||||||
title: this.mediaTitle,
|
|
||||||
filename: this.filename,
|
|
||||||
mediaType: this.mediaType,
|
mediaType: this.mediaType,
|
||||||
imdbId: this.imdbId,
|
imdbId: this.imdbId,
|
||||||
episodeId: this.episodeId
|
season: this.season,
|
||||||
|
episode: this.episode,
|
||||||
|
filter: preferences,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
|
|||||||
40
assets/components/status-checker-span.js
Normal file
40
assets/components/status-checker-span.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export default class PreviewContentDialog extends HTMLSpanElement {
|
||||||
|
#url;
|
||||||
|
#service;
|
||||||
|
#status;
|
||||||
|
|
||||||
|
#statusTexts = {
|
||||||
|
200: 'up',
|
||||||
|
204: 'up'
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusColors = {
|
||||||
|
200: 'bg-green-500',
|
||||||
|
204: 'bg-green-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.#url = this.getAttribute('url');
|
||||||
|
this.#service = this.getAttribute('service');
|
||||||
|
|
||||||
|
(async () => await this.checkStatus())();
|
||||||
|
setInterval(async () => await this.checkStatus(), 1000 * 60 * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkStatus() {
|
||||||
|
const response = await fetch(this.#url);
|
||||||
|
this.#status = response.status;
|
||||||
|
this.setAttribute('title', this.getTitle());
|
||||||
|
this.classList.remove('bg-red-500', 'bg-green-500');
|
||||||
|
this.classList.add(this.getColor());
|
||||||
|
}
|
||||||
|
|
||||||
|
getTitle() {
|
||||||
|
return `${this.#service} is ${this.#statusTexts[this.#status] ?? 'down'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
getColor() {
|
||||||
|
return this.#statusColors[this.#status] ?? 'bg-red-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
download() {
|
download() {
|
||||||
|
console.log(new FormData(document.querySelector('[name="user_media_preferences_form"]')).values());
|
||||||
fetch('/api/download', {
|
fetch('/api/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -28,7 +29,8 @@ export default class extends Controller {
|
|||||||
filename: this.filenameValue,
|
filename: this.filenameValue,
|
||||||
mediaType: this.mediaTypeValue,
|
mediaType: this.mediaTypeValue,
|
||||||
imdbId: this.imdbIdValue,
|
imdbId: this.imdbIdValue,
|
||||||
episodeId: this.episodeIdValue
|
episodeId: this.episodeIdValue,
|
||||||
|
filter: new FormData(document.querySelector('[name="user_media_preferences_form"]')).values()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
# torsearch-app is built from this base
|
# torsearch-app is built from this base
|
||||||
export APP_FRANKENPHP_TAG=php8.4
|
export APP_FRANKENPHP_TAG=php8.4
|
||||||
|
|
||||||
#docker buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.app -t code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base:latest --build-arg "FRANKENPHP_TAG=${APP_FRANKENPHP_TAG}" .
|
docker buildx build --platform=linux/amd64 -f docker/Dockerfile.base.app -t code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base:latest --build-arg "FRANKENPHP_TAG=${APP_FRANKENPHP_TAG}" .
|
||||||
docker build -f docker/Dockerfile.base.app -t code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base:latest --build-arg "FRANKENPHP_TAG=${APP_FRANKENPHP_TAG}" .
|
docker push --platform=linux/amd64 code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG}
|
||||||
docker push code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG}
|
docker push --platform=linux/amd64 code.caldwell.digital/home/torsearch-base:latest
|
||||||
docker push code.caldwell.digital/home/torsearch-base:latest
|
|
||||||
|
|
||||||
# torsearch-worker & torsearch-scheduler are built from this base
|
# torsearch-worker & torsearch-scheduler are built from this base
|
||||||
export WORKER_FRANKENPHP_TAG=php8.4-alpine
|
export WORKER_FRANKENPHP_TAG=php8.4-alpine
|
||||||
|
|
||||||
#docker buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base-worker:latest --build-arg "FRANKENPHP_TAG=${WORKER_FRANKENPHP_TAG}" .
|
docker buildx build --platform=linux/amd64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base-worker:latest --build-arg "FRANKENPHP_TAG=${WORKER_FRANKENPHP_TAG}" .
|
||||||
docker build -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base-worker:latest --build-arg "FRANKENPHP_TAG=${WORKER_FRANKENPHP_TAG}" .
|
docker push --platform=linux/amd64 code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG}
|
||||||
docker push code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG}
|
docker push --platform=linux/amd64 code.caldwell.digital/home/torsearch-base-worker:latest
|
||||||
docker push code.caldwell.digital/home/torsearch-base-worker:latest
|
|
||||||
|
|
||||||
# torsearch-worker-supervisord
|
# torsearch-worker-supervisord
|
||||||
export ALPINE_VERSION=3.22
|
export ALPINE_VERSION=3.22
|
||||||
|
|
||||||
#docker buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker-supervisord:latest --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" .
|
docker buildx build --platform=linux/amd64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker-supervisord:latest --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" .
|
||||||
docker build -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker-supervisord:latest --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" .
|
docker push --platform=linux/amd64 code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
||||||
docker push code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
|
||||||
|
|||||||
11
compose.yml
11
compose.yml
@@ -1,3 +1,5 @@
|
|||||||
|
# This file is used for local development
|
||||||
|
# see the docs/examples directory for production examples
|
||||||
services:
|
services:
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2.9.1
|
image: caddy:2.9.1
|
||||||
@@ -25,8 +27,8 @@ services:
|
|||||||
tty: true
|
tty: true
|
||||||
environment:
|
environment:
|
||||||
TZ: America/Chicago
|
TZ: America/Chicago
|
||||||
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
|
MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_PUBLISHER_JWT_KEY}"
|
||||||
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
|
MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_SUBSCRIBER_JWT_KEY}"
|
||||||
depends_on:
|
depends_on:
|
||||||
database:
|
database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -44,6 +46,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: America/Chicago
|
TZ: America/Chicago
|
||||||
|
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
build:
|
build:
|
||||||
dockerfile: docker/Dockerfile.scheduler
|
dockerfile: docker/Dockerfile.scheduler
|
||||||
@@ -77,9 +80,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: America/Chicago
|
TZ: America/Chicago
|
||||||
MYSQL_DATABASE: app
|
MYSQL_DATABASE: app
|
||||||
MYSQL_USERNAME: app
|
MYSQL_USER: app
|
||||||
MYSQL_PASSWORD: password
|
MYSQL_PASSWORD: password
|
||||||
MYSQL_ROOT_PASSWORD: password
|
MYSQL_RANDOM_ROOT_PASSWORD: true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
|
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@@ -1,40 +1,43 @@
|
|||||||
sentry:
|
when@prod: &prod
|
||||||
register_error_listener: true # Disables the ErrorListener to avoid duplicated log in sentry
|
sentry:
|
||||||
register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK
|
register_error_listener: true # Disables the ErrorListener to avoid duplicated log in sentry
|
||||||
|
register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK
|
||||||
|
|
||||||
options:
|
options:
|
||||||
release: 'torsearch@%app.version%'
|
release: 'torsearch@%app.version%'
|
||||||
enable_logs: true
|
enable_logs: true
|
||||||
traces_sample_rate: 1
|
traces_sample_rate: 1
|
||||||
profiles_sample_rate: 1
|
profiles_sample_rate: 1
|
||||||
attach_stacktrace: true
|
attach_stacktrace: true
|
||||||
|
|
||||||
tracing:
|
tracing:
|
||||||
enabled: true
|
|
||||||
dbal: # DB queries
|
|
||||||
enabled: true
|
|
||||||
cache: # cache pools
|
|
||||||
enabled: true
|
|
||||||
twig: # templating engine
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
dbal: # DB queries
|
||||||
|
enabled: true
|
||||||
|
cache: # cache pools
|
||||||
|
enabled: true
|
||||||
|
twig: # templating engine
|
||||||
|
enabled: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# (Optionally) Configure the breadcrumb handler as a service (needed for the breadcrumb Monolog handler)
|
# (Optionally) Configure the breadcrumb handler as a service (needed for the breadcrumb Monolog handler)
|
||||||
Sentry\Monolog\BreadcrumbHandler:
|
Sentry\Monolog\BreadcrumbHandler:
|
||||||
arguments:
|
arguments:
|
||||||
- '@Sentry\State\HubInterface'
|
- '@Sentry\State\HubInterface'
|
||||||
- !php/const Monolog\Logger::INFO # Configures the level of messages to capture as breadcrumbs
|
- !php/const Monolog\Logger::INFO # Configures the level of messages to capture as breadcrumbs
|
||||||
monolog:
|
monolog:
|
||||||
handlers:
|
handlers:
|
||||||
# (Optionally) Register the breadcrumb handler as a Monolog handler
|
# (Optionally) Register the breadcrumb handler as a Monolog handler
|
||||||
sentry_breadcrumbs:
|
sentry_breadcrumbs:
|
||||||
type: service
|
type: service
|
||||||
name: sentry_breadcrumbs
|
name: sentry_breadcrumbs
|
||||||
id: Sentry\Monolog\BreadcrumbHandler
|
id: Sentry\Monolog\BreadcrumbHandler
|
||||||
# Register the handler as a Monolog handler to capture messages as events
|
# Register the handler as a Monolog handler to capture messages as events
|
||||||
sentry:
|
sentry:
|
||||||
type: sentry
|
type: sentry
|
||||||
level: !php/const Monolog\Logger::ERROR # Configures the level of messages to capture as events
|
level: !php/const Monolog\Logger::ERROR # Configures the level of messages to capture as events
|
||||||
hub_id: Sentry\State\HubInterface
|
hub_id: Sentry\State\HubInterface
|
||||||
fill_extra_context: true # Enables sending monolog context to Sentry
|
fill_extra_context: true # Enables sending monolog context to Sentry
|
||||||
process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders in reported messages
|
process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders in reported messages
|
||||||
|
|
||||||
|
when@dev: *prod
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ services:
|
|||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
exclude:
|
exclude:
|
||||||
|
- '../src/Library/Dto'
|
||||||
- '../src/DependencyInjection/'
|
- '../src/DependencyInjection/'
|
||||||
- '../src/Entity/'
|
- '../src/Entity/'
|
||||||
- '../src/Kernel.php'
|
- '../src/Kernel.php'
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
image: registry.caldwell.digital/home/torsearch-app:${TAG}
|
|
||||||
ports:
|
|
||||||
- "${SWARM_PORT}:80"
|
|
||||||
environment:
|
|
||||||
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
|
|
||||||
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
|
|
||||||
deploy:
|
|
||||||
replicas: 2
|
|
||||||
volumes:
|
|
||||||
- /mnt/media/downloads/movies:/var/download/movies
|
|
||||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
|
||||||
- mercure_data:/data
|
|
||||||
- mercure_config:/config
|
|
||||||
|
|
||||||
|
|
||||||
worker:
|
|
||||||
image: registry.caldwell.digital/home/torsearch-worker:${TAG}
|
|
||||||
volumes:
|
|
||||||
- /mnt/media/downloads/movies:/var/download/movies
|
|
||||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
|
||||||
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
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mercure_config:
|
|
||||||
mercure_data:
|
|
||||||
@@ -3,6 +3,9 @@ FROM code.caldwell.digital/home/torsearch-base:php8.4
|
|||||||
ARG APP_VERSION="0.0.0-dev"
|
ARG APP_VERSION="0.0.0-dev"
|
||||||
ENV APP_VERSION="${APP_VERSION}"
|
ENV APP_VERSION="${APP_VERSION}"
|
||||||
|
|
||||||
|
ARG TMDB_API=""
|
||||||
|
ENV TMDB_API="${TMDB_API}"
|
||||||
|
|
||||||
COPY . /app
|
COPY . /app
|
||||||
COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint
|
COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint
|
||||||
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile
|
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ RUN apk add --no-cache \
|
|||||||
php84-fileinfo \
|
php84-fileinfo \
|
||||||
php84-fpm \
|
php84-fpm \
|
||||||
php84-gd \
|
php84-gd \
|
||||||
|
php84-intl \
|
||||||
php84-mbstring \
|
php84-mbstring \
|
||||||
php84-mysqli \
|
php84-mysqli \
|
||||||
php84-opcache \
|
php84-opcache \
|
||||||
php84-openssl \
|
php84-openssl \
|
||||||
php84-pdo_mysql \
|
php84-pdo_mysql \
|
||||||
|
php84-session \
|
||||||
php84-tokenizer \
|
php84-tokenizer \
|
||||||
php84-xml \
|
php84-xml \
|
||||||
supervisor
|
supervisor
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS app_image
|
|||||||
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
||||||
|
|
||||||
# Set the APP_VERSION in the image
|
# Set the APP_VERSION in the image
|
||||||
|
ARG APP_VERSION="latest"
|
||||||
ENV APP_VERSION=${APP_VERSION}
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
|
ARG TMDB_API=""
|
||||||
|
ENV TMDB_API="${TMDB_API}"
|
||||||
|
|
||||||
# Copy the actual application code from the previously built app
|
# Copy the actual application code from the previously built app
|
||||||
COPY --chown=1000:1000 --from=app_image /app /app
|
COPY --chown=1000:1000 --from=app_image /app /app
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS app_image
|
|||||||
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
||||||
|
|
||||||
# Set the APP_VERSION in the image
|
# Set the APP_VERSION in the image
|
||||||
|
ARG APP_VERSION="latest"
|
||||||
ENV APP_VERSION=${APP_VERSION}
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
|
ARG TMDB_API=""
|
||||||
|
ENV TMDB_API="${TMDB_API}"
|
||||||
|
|
||||||
# Copy the actual application code from the previously built app
|
# Copy the actual application code from the previously built app
|
||||||
COPY --chown=1000:1000 --from=app_image /app /app
|
COPY --chown=1000:1000 --from=app_image /app /app
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Torsearch #
|
# Torsearch #
|
||||||
###################
|
###################
|
||||||
# The version of Torsearch to run. Docker will this tag.
|
# The version of Torsearch to run. Docker will this tag.
|
||||||
TAG=0.38.0
|
TAG=latest
|
||||||
|
|
||||||
# The port for which the web server (app container) will
|
# The port for which the web server (app container) will
|
||||||
# serve the application on the host.
|
# serve the application on the host.
|
||||||
@@ -11,8 +11,8 @@ WEB_PORT=8004
|
|||||||
# The host directories where your media is stored.
|
# The host directories where your media is stored.
|
||||||
# If you would like to use a docker driver for a network
|
# If you would like to use a docker driver for a network
|
||||||
# share, update the compose.yml file to reflect that.
|
# share, update the compose.yml file to reflect that.
|
||||||
LOCAL_MOVIES_DIR="<enter movies dir>"
|
LOCAL_MOVIES_DIR="./movies"
|
||||||
LOCAL_TVSHOWS_DIR="<enter tvshows dir>"
|
LOCAL_TVSHOWS_DIR="./tvshows"
|
||||||
|
|
||||||
# Set the timezone you're in. This helps render monitored items correctly on the calendar
|
# Set the timezone you're in. This helps render monitored items correctly on the calendar
|
||||||
TZ=America/Chicago
|
TZ=America/Chicago
|
||||||
@@ -34,6 +34,8 @@ APP_ENV=prod
|
|||||||
# Mercure is a Caddy module built into the webserver
|
# Mercure is a Caddy module built into the webserver
|
||||||
# that facilitates the usage of websockets to transmit
|
# that facilitates the usage of websockets to transmit
|
||||||
# real time data (download progress, etc.)
|
# real time data (download progress, etc.)
|
||||||
|
# TBH, I've only run into issues when changing these. If
|
||||||
|
# you have problems after changing them, just revert them.
|
||||||
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
||||||
@@ -45,11 +47,11 @@ MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
|
|||||||
# Use the DATABASE_URL below to use the MariaDB container
|
# Use the DATABASE_URL below to use the MariaDB container
|
||||||
# provided in the example.compose.yml file, or remove this
|
# provided in the example.compose.yml file, or remove this
|
||||||
# line and fill in the details of your own MySQL/MariaDB server
|
# line and fill in the details of your own MySQL/MariaDB server
|
||||||
MYSQL_RANDOM_ROOT_PASSWORD=true
|
|
||||||
MYSQL_DATABASE=app
|
|
||||||
MYSQL_USER=app
|
MYSQL_USER=app
|
||||||
MYSQL_PASSWORD=password
|
MYSQL_PASSWORD="P@ssword123"
|
||||||
|
MYSQL_DATABASE=app
|
||||||
MYSQL_HOST=database
|
MYSQL_HOST=database
|
||||||
|
MYSQL_RANDOM_ROOT_PASSWORD=true
|
||||||
DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}:3306/${MYSQL_DATABASE}?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}:3306/${MYSQL_DATABASE}?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||||
|
|
||||||
|
|
||||||
@@ -60,15 +62,6 @@ DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}:3306/${MYSQL
|
|||||||
REAL_DEBRID_KEY="<enter real debrid api key>"
|
REAL_DEBRID_KEY="<enter real debrid api key>"
|
||||||
|
|
||||||
|
|
||||||
###################
|
|
||||||
# TMDB #
|
|
||||||
###################
|
|
||||||
# Enter your 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="<enter tmdb api key>"
|
|
||||||
|
|
||||||
|
|
||||||
###################
|
###################
|
||||||
# Redis #
|
# Redis #
|
||||||
###################
|
###################
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ services:
|
|||||||
- "${WEB_PORT}:80"
|
- "${WEB_PORT}:80"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
depends_on:
|
depends_on:
|
||||||
- database
|
database:
|
||||||
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOCAL_MOVIES_DIR}:/var/download/movies
|
- ${LOCAL_MOVIES_DIR}:/var/download/movies
|
||||||
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
|
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
|
||||||
@@ -43,7 +44,7 @@ services:
|
|||||||
- mysql:/var/lib/mysql
|
- mysql:/var/lib/mysql
|
||||||
env_file: .env
|
env_file: .env
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "mysqladmin", "-u", "${MYSQL_USER}", "-p", "${MYSQL_PASSWORD}" ,"ping", "-h", "localhost" ]
|
test: ["CMD", "mysqladmin", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
35
migrations/Version20260321191300.php
Normal file
35
migrations/Version20260321191300.php
Normal 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 Version20260321191300 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 CHANGE url url VARCHAR(1024) 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 download CHANGE url url VARCHAR(1024) NOT NULL
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
nomad.deploy.hcl
129
nomad.deploy.hcl
@@ -1,129 +0,0 @@
|
|||||||
variable "image_tag" {
|
|
||||||
type = string
|
|
||||||
description = "Docker image tag to deploy."
|
|
||||||
default = "latest"
|
|
||||||
}
|
|
||||||
|
|
||||||
job "torsearch" {
|
|
||||||
datacenters = [ "home" ]
|
|
||||||
type = "service"
|
|
||||||
|
|
||||||
group "app" {
|
|
||||||
count = 2
|
|
||||||
|
|
||||||
update {
|
|
||||||
max_parallel = 4
|
|
||||||
min_healthy_time = "30s"
|
|
||||||
healthy_deadline = "3m"
|
|
||||||
auto_revert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
network {
|
|
||||||
port "app" {
|
|
||||||
to = 80
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task "app" {
|
|
||||||
driver = "docker"
|
|
||||||
|
|
||||||
config {
|
|
||||||
image = "registry.caldwell.digital/home/torsearch-app:${var.image_tag}"
|
|
||||||
ports = ["app"]
|
|
||||||
}
|
|
||||||
|
|
||||||
env {
|
|
||||||
MERCURE_PUBLISHER_JWT_KEY = "!ChangeThisMercureHubJWTSecretKey!"
|
|
||||||
MERCURE_SUBSCRIBER_JWT_KEY = "!ChangeThisMercureHubJWTSecretKey!"
|
|
||||||
}
|
|
||||||
|
|
||||||
service {
|
|
||||||
name = "torsearch-app"
|
|
||||||
provider = "nomad"
|
|
||||||
port = "app"
|
|
||||||
|
|
||||||
meta {
|
|
||||||
nomad_ingress_enabled = true
|
|
||||||
nomad_ingress_hostname = "torsearch-nomad.caldwell.digital"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
group "worker" {
|
|
||||||
count = 2
|
|
||||||
update {
|
|
||||||
max_parallel = 4
|
|
||||||
min_healthy_time = "30s"
|
|
||||||
healthy_deadline = "3m"
|
|
||||||
auto_revert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
volume "media" {
|
|
||||||
type = "host"
|
|
||||||
source = "media"
|
|
||||||
read_only = false
|
|
||||||
}
|
|
||||||
|
|
||||||
task "worker" {
|
|
||||||
driver = "docker"
|
|
||||||
|
|
||||||
volume_mount {
|
|
||||||
volume = "media"
|
|
||||||
destination = "/var/download"
|
|
||||||
read_only = false
|
|
||||||
}
|
|
||||||
|
|
||||||
config {
|
|
||||||
image = "registry.caldwell.digital/home/torsearch-worker:${var.image_tag}"
|
|
||||||
args = [
|
|
||||||
"-vv"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
service {
|
|
||||||
name = "torsearch-worker"
|
|
||||||
provider = "nomad"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
group "scheduler" {
|
|
||||||
count = 1
|
|
||||||
|
|
||||||
update {
|
|
||||||
max_parallel = 2
|
|
||||||
min_healthy_time = "30s"
|
|
||||||
healthy_deadline = "3m"
|
|
||||||
auto_revert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
volume "media" {
|
|
||||||
type = "host"
|
|
||||||
source = "media"
|
|
||||||
read_only = false
|
|
||||||
}
|
|
||||||
|
|
||||||
task "scheduler" {
|
|
||||||
driver = "docker"
|
|
||||||
|
|
||||||
volume_mount {
|
|
||||||
volume = "media"
|
|
||||||
destination = "/var/download"
|
|
||||||
read_only = false
|
|
||||||
}
|
|
||||||
|
|
||||||
config {
|
|
||||||
image = "registry.caldwell.digital/home/torsearch-scheduler:${var.image_tag}"
|
|
||||||
args = [
|
|
||||||
"-vv"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
service {
|
|
||||||
name = "torsearch-scheduler"
|
|
||||||
provider = "nomad"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -113,9 +113,15 @@ class MediaFiles
|
|||||||
return Map::from($results);
|
return Map::from($results);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createMovieDirectory(string $path): string
|
public function createMovieDirectory(string $title, string|int $year, string $imdbId): string
|
||||||
{
|
{
|
||||||
$path = $this->moviesPath . DIRECTORY_SEPARATOR . $path;
|
$path = sprintf(
|
||||||
|
'%s' . DIRECTORY_SEPARATOR . '%s (%s) [imdbid-%s]',
|
||||||
|
$this->moviesPath,
|
||||||
|
$title,
|
||||||
|
$year,
|
||||||
|
$imdbId
|
||||||
|
);
|
||||||
|
|
||||||
if (false === $this->filesystem->exists($path)) {
|
if (false === $this->filesystem->exists($path)) {
|
||||||
$this->filesystem->mkdir($path);
|
$this->filesystem->mkdir($path);
|
||||||
@@ -124,9 +130,16 @@ class MediaFiles
|
|||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createTvShowDirectory(string $path): string
|
public function createTvShowDirectory(string $title, string|int $year, string|int $season, string|int $episode, string $imdbId): string
|
||||||
{
|
{
|
||||||
$path = $this->tvShowsPath . DIRECTORY_SEPARATOR . $path;
|
$path = sprintf(
|
||||||
|
'%s' . DIRECTORY_SEPARATOR . '%s (%s) [imdbid-%s]' . DIRECTORY_SEPARATOR . 'Season %s',
|
||||||
|
$this->tvShowsPath,
|
||||||
|
$title,
|
||||||
|
$year,
|
||||||
|
$imdbId,
|
||||||
|
str_pad($season, 2, '0', STR_PAD_LEFT),
|
||||||
|
);
|
||||||
|
|
||||||
if (false === $this->filesystem->exists($path)) {
|
if (false === $this->filesystem->exists($path)) {
|
||||||
$this->filesystem->mkdir($path);
|
$this->filesystem->mkdir($path);
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ use OneToMany\RichBundle\Contract\CommandInterface;
|
|||||||
class DownloadMediaCommand implements CommandInterface
|
class DownloadMediaCommand implements CommandInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $url,
|
|
||||||
public string $title,
|
|
||||||
public string $filename,
|
|
||||||
public string $mediaType,
|
|
||||||
public string $imdbId,
|
public string $imdbId,
|
||||||
public int $userId,
|
public string $mediaType,
|
||||||
public ?int $downloadId = null,
|
public int|string|null $season = null,
|
||||||
|
public int|string|null $episode = null,
|
||||||
|
public string|null $url = null,
|
||||||
|
public array|null $filter = null,
|
||||||
|
public int|null $downloadId = null,
|
||||||
|
public int|null $userId = null,
|
||||||
public ?string $mercureAlertTopic = null,
|
public ?string $mercureAlertTopic = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,26 @@ namespace App\Download\Action\Handler;
|
|||||||
|
|
||||||
use App\Base\Enum\MediaType;
|
use App\Base\Enum\MediaType;
|
||||||
use App\Base\Service\Broadcaster;
|
use App\Base\Service\Broadcaster;
|
||||||
|
use App\Base\Service\MediaFiles;
|
||||||
|
use App\Base\Util\EpisodeId;
|
||||||
use App\Download\Action\Command\DownloadMediaCommand;
|
use App\Download\Action\Command\DownloadMediaCommand;
|
||||||
use App\Download\Action\Result\DownloadMediaResult;
|
use App\Download\Action\Result\DownloadMediaResult;
|
||||||
use App\Download\DownloadEvents;
|
use App\Download\DownloadEvents;
|
||||||
|
use App\Download\DownloadOptionEvaluator;
|
||||||
use App\Download\Framework\Entity\Download;
|
use App\Download\Framework\Entity\Download;
|
||||||
use App\Download\Framework\Repository\DownloadRepository;
|
use App\Download\Framework\Repository\DownloadRepository;
|
||||||
use App\Download\Downloader\DownloaderInterface;
|
use App\Download\Downloader\DownloaderInterface;
|
||||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||||
|
use App\Library\Dto\MediaFileDto;
|
||||||
|
use App\Search\Action\Command\GetMediaInfoCommand;
|
||||||
|
use App\Search\Action\Handler\GetMediaInfoHandler;
|
||||||
|
use App\Search\Action\Result\GetMediaInfoResult;
|
||||||
|
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
|
||||||
|
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||||
|
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
|
||||||
|
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||||
|
use App\Torrentio\Result\TorrentioResult;
|
||||||
|
use App\User\Dto\UserPreferencesFactory;
|
||||||
use App\User\Framework\Repository\UserRepository;
|
use App\User\Framework\Repository\UserRepository;
|
||||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||||
@@ -18,19 +31,63 @@ use OneToMany\RichBundle\Contract\ResultInterface;
|
|||||||
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
|
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
|
|
||||||
|
|
||||||
/** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */
|
/** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */
|
||||||
readonly class DownloadMediaHandler implements HandlerInterface
|
readonly class DownloadMediaHandler implements HandlerInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MessageBusInterface $bus,
|
private DownloadOptionEvaluator $downloadOptionEvaluator,
|
||||||
private DownloaderInterface $downloader,
|
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
|
||||||
private DownloadRepository $downloadRepository,
|
private GetMovieOptionsHandler $getMovieOptionsHandler,
|
||||||
private UserRepository $userRepository, private Broadcaster $broadcaster,
|
private GetMediaInfoHandler $getMediaInfoHandler,
|
||||||
|
private MessageBusInterface $bus,
|
||||||
|
private DownloaderInterface $downloader,
|
||||||
|
private DownloadRepository $downloadRepository,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private Broadcaster $broadcaster,
|
||||||
|
private MediaFiles $mediaFiles,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(CommandInterface $command): ResultInterface
|
public function handle(CommandInterface $command): ResultInterface
|
||||||
{
|
{
|
||||||
$user = $this->userRepository->find($command->userId);
|
$user = $this->userRepository->find($command->userId);
|
||||||
|
/** @var \App\Download\Framework\Entity\Download $download */
|
||||||
|
$download = $this->downloadRepository->find($command->downloadId);
|
||||||
|
$media = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand(
|
||||||
|
$command->imdbId,
|
||||||
|
$command->mediaType,
|
||||||
|
$command->season,
|
||||||
|
$command->episode,
|
||||||
|
));
|
||||||
|
$downloadOptions = match ($command->mediaType) {
|
||||||
|
MediaType::Movie->value => $this->getMovieOptionsHandler->handle(
|
||||||
|
new GetMovieOptionsCommand(
|
||||||
|
$media->media->tmdbId,
|
||||||
|
$media->media->imdbId
|
||||||
|
)
|
||||||
|
),
|
||||||
|
MediaType::TvShow->value => $this->getTvShowOptionsHandler->handle(
|
||||||
|
new GetTvShowOptionsCommand(
|
||||||
|
$media->media->tmdbId,
|
||||||
|
$media->media->imdbId,
|
||||||
|
$command->season,
|
||||||
|
$command->episode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
$filter = $command->filter !== null
|
||||||
|
? UserPreferencesFactory::createFromArray($command->filter)
|
||||||
|
: UserPreferencesFactory::createFromUser($user);
|
||||||
|
|
||||||
|
$matchingOption = $this->downloadOptionEvaluator->evaluateOptions($downloadOptions->results, $filter);
|
||||||
|
|
||||||
|
$download->setUrl($matchingOption->url);
|
||||||
|
$download->setTitle($media->media->title);
|
||||||
|
$download->setFileName(
|
||||||
|
$this->getFilename(MediaType::from($command->mediaType), $media, $matchingOption)
|
||||||
|
);
|
||||||
|
|
||||||
$this->bus->dispatch(new AddEventLogCommand(
|
$this->bus->dispatch(new AddEventLogCommand(
|
||||||
$user,
|
$user,
|
||||||
DownloadEvents::DOWNLOAD_STARTED->type(),
|
DownloadEvents::DOWNLOAD_STARTED->type(),
|
||||||
@@ -38,20 +95,6 @@ readonly class DownloadMediaHandler implements HandlerInterface
|
|||||||
(array) $command
|
(array) $command
|
||||||
));
|
));
|
||||||
|
|
||||||
if (null === $command->downloadId) {
|
|
||||||
$download = $this->downloadRepository->insert(
|
|
||||||
$user,
|
|
||||||
$command->url,
|
|
||||||
$command->title,
|
|
||||||
$command->filename,
|
|
||||||
$command->imdbId,
|
|
||||||
$command->mediaType,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$download = $this->downloadRepository->find($command->downloadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->validateDownloadUrl($download->getUrl());
|
$this->validateDownloadUrl($download->getUrl());
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
@@ -69,8 +112,8 @@ readonly class DownloadMediaHandler implements HandlerInterface
|
|||||||
|
|
||||||
$this->downloader->download(
|
$this->downloader->download(
|
||||||
$command->mediaType,
|
$command->mediaType,
|
||||||
$command->title,
|
$download->getUrl(),
|
||||||
$command->url,
|
$this->getFilepath(MediaType::from($command->mediaType), $media, $matchingOption),
|
||||||
$download->getId()
|
$download->getId()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,6 +134,54 @@ readonly class DownloadMediaHandler implements HandlerInterface
|
|||||||
return new DownloadMediaResult(200, "Success.");
|
return new DownloadMediaResult(200, "Success.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getFilepath(MediaType $mediaType, GetMediaInfoResult $media, TorrentioResult $option): ?string
|
||||||
|
{
|
||||||
|
return match ($mediaType) {
|
||||||
|
MediaType::Movie => $this->mediaFiles->createMovieDirectory(
|
||||||
|
$media->media->title,
|
||||||
|
$media->media->year,
|
||||||
|
$media->media->imdbId,
|
||||||
|
),
|
||||||
|
MediaType::TvShow => $this->mediaFiles->createTvShowDirectory(
|
||||||
|
$media->media->title,
|
||||||
|
$media->media->year,
|
||||||
|
$media->season,
|
||||||
|
$media->episode,
|
||||||
|
$media->media->imdbId,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFilename(MediaType $mediaType, GetMediaInfoResult $media, TorrentioResult $option): ?string
|
||||||
|
{
|
||||||
|
$fileType = $option->ptn->container;
|
||||||
|
return (match ($mediaType) {
|
||||||
|
MediaType::Movie => function () use ($media, $fileType) {
|
||||||
|
$template = "%s (%s) [imdbid-%s].%s";
|
||||||
|
return sprintf(
|
||||||
|
$template,
|
||||||
|
$media->media->title,
|
||||||
|
$media->media->year,
|
||||||
|
$media->media->imdbId,
|
||||||
|
$fileType
|
||||||
|
);
|
||||||
|
},
|
||||||
|
MediaType::TvShow => function () use ($media, $fileType) {
|
||||||
|
$template = "%s %s.%s";
|
||||||
|
$episodeId = EpisodeId::fromSeasonEpisodeNumbers(
|
||||||
|
$media->season,
|
||||||
|
$media->episode,
|
||||||
|
);
|
||||||
|
return sprintf(
|
||||||
|
$template,
|
||||||
|
$media->media->title,
|
||||||
|
$episodeId,
|
||||||
|
$fileType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
public function validateDownloadUrl(string $downloadUrl)
|
public function validateDownloadUrl(string $downloadUrl)
|
||||||
{
|
{
|
||||||
$badFileSizes = [
|
$badFileSizes = [
|
||||||
@@ -99,6 +190,7 @@ readonly class DownloadMediaHandler implements HandlerInterface
|
|||||||
|
|
||||||
$badFileLocations = [
|
$badFileLocations = [
|
||||||
'https://torrentio.strem.fun/videos/failed_infringement_v2.mp4' => 'Removed for Copyright Infringement.',
|
'https://torrentio.strem.fun/videos/failed_infringement_v2.mp4' => 'Removed for Copyright Infringement.',
|
||||||
|
'https://torrentio.strem.fun/videos/downloading_v2.mp4' => 'Your torrent is downloading to your debrid provider.'
|
||||||
];
|
];
|
||||||
|
|
||||||
$headers = get_headers($downloadUrl, true);
|
$headers = get_headers($downloadUrl, true);
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
namespace App\Download\Action\Input;
|
namespace App\Download\Action\Input;
|
||||||
|
|
||||||
use App\Download\Action\Command\DownloadMediaCommand;
|
use App\Download\Action\Command\DownloadMediaCommand;
|
||||||
|
use OneToMany\RichBundle\Attribute\PropertyIgnored;
|
||||||
use OneToMany\RichBundle\Attribute\SourceRequest;
|
use OneToMany\RichBundle\Attribute\SourceRequest;
|
||||||
|
use OneToMany\RichBundle\Attribute\SourceSecurity;
|
||||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||||
use OneToMany\RichBundle\Contract\InputInterface;
|
use OneToMany\RichBundle\Contract\InputInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
/** @implements InputInterface<DownloadMediaInput> */
|
/** @implements InputInterface<DownloadMediaInput> */
|
||||||
class DownloadMediaInput implements InputInterface
|
class DownloadMediaInput implements InputInterface
|
||||||
@@ -13,39 +16,60 @@ class DownloadMediaInput implements InputInterface
|
|||||||
public ?string $mercureAlertTopic = null;
|
public ?string $mercureAlertTopic = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
#[SourceRequest('url')]
|
#[SourceRequest('imdbId')]
|
||||||
public string $url,
|
public string $imdbId,
|
||||||
|
|
||||||
#[SourceRequest('title')]
|
|
||||||
public string $title,
|
|
||||||
|
|
||||||
#[SourceRequest('filename')]
|
|
||||||
public string $filename,
|
|
||||||
|
|
||||||
#[SourceRequest('mediaType')]
|
#[SourceRequest('mediaType')]
|
||||||
public string $mediaType,
|
public string $mediaType,
|
||||||
|
|
||||||
#[SourceRequest('imdbId')]
|
#[SourceRequest('season', nullify: true)]
|
||||||
public string $imdbId,
|
public int|string|null $season = null,
|
||||||
|
|
||||||
#[SourceRequest('episodeId', nullify: true)]
|
#[SourceRequest('episode', nullify: true)]
|
||||||
public ?string $episodeId = null,
|
public int|string|null $episode = null,
|
||||||
|
|
||||||
public ?int $userId = null,
|
#[SourceRequest('url', nullify: true)]
|
||||||
|
public string|null $url = null,
|
||||||
|
|
||||||
public ?int $downloadId = null,
|
#[SourceRequest('filter', nullify: true)]
|
||||||
|
public array|null $filter = null,
|
||||||
|
|
||||||
|
#[PropertyIgnored]
|
||||||
|
public int|null $downloadId = null,
|
||||||
|
|
||||||
|
#[PropertyIgnored]
|
||||||
|
public int|null $userId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function setUserId(int $userId): static
|
||||||
|
{
|
||||||
|
$this->userId = $userId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDownloadId(int $downloadId): static
|
||||||
|
{
|
||||||
|
$this->downloadId = $downloadId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMercureAlertTopic(string $mercureAlertTopic): static
|
||||||
|
{
|
||||||
|
$this->mercureAlertTopic = $mercureAlertTopic;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function toCommand(): CommandInterface
|
public function toCommand(): CommandInterface
|
||||||
{
|
{
|
||||||
return new DownloadMediaCommand(
|
return new DownloadMediaCommand(
|
||||||
$this->url,
|
|
||||||
$this->title,
|
|
||||||
$this->filename,
|
|
||||||
$this->mediaType,
|
|
||||||
$this->imdbId,
|
$this->imdbId,
|
||||||
$this->userId,
|
$this->mediaType,
|
||||||
|
$this->season,
|
||||||
|
$this->episode,
|
||||||
|
$this->url,
|
||||||
|
$this->filter,
|
||||||
$this->downloadId,
|
$this->downloadId,
|
||||||
|
$this->userId,
|
||||||
$this->mercureAlertTopic,
|
$this->mercureAlertTopic,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,15 @@ class DownloadOptionEvaluator
|
|||||||
// return false;
|
// return false;
|
||||||
//}
|
//}
|
||||||
|
|
||||||
if (false === $this->validateDownloadUrl($result->url)) {
|
// if (false === $this->validateDownloadUrl($result->url)) {
|
||||||
return false;
|
// return false;
|
||||||
}
|
// }
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// dd($filter);
|
||||||
|
|
||||||
if ($matches->count() > 0) {
|
if ($matches->count() > 0) {
|
||||||
return Map::from($matches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
|
return Map::from($matches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,7 @@ class DownloadOptionEvaluator
|
|||||||
{
|
{
|
||||||
$badFileLocations = [
|
$badFileLocations = [
|
||||||
'https://torrentio.strem.fun/videos/failed_infringement_v2.mp4' => 'Removed for Copyright Infringement.',
|
'https://torrentio.strem.fun/videos/failed_infringement_v2.mp4' => 'Removed for Copyright Infringement.',
|
||||||
|
'https://torrentio.strem.fun/videos/downloading_v2.mp4' => 'Your torrent is downloading to your debrid provider.'
|
||||||
];
|
];
|
||||||
|
|
||||||
$headers = get_headers($downloadUrl, true);
|
$headers = get_headers($downloadUrl, true);
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ interface DownloaderInterface
|
|||||||
* @return void
|
* @return void
|
||||||
* Downloads the requested file.
|
* Downloads the requested file.
|
||||||
*/
|
*/
|
||||||
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void;
|
public function download(string $mediaType, string $url, string $downloadPath, ?int $downloadId): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use App\Base\Service\MediaFiles;
|
|||||||
use App\Download\DownloadEvents;
|
use App\Download\DownloadEvents;
|
||||||
use App\Download\Framework\Entity\Download;
|
use App\Download\Framework\Entity\Download;
|
||||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||||
|
use App\Search\Action\Command\GetMediaInfoCommand;
|
||||||
|
use App\Search\Action\Handler\GetMediaInfoHandler;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
||||||
use Symfony\Component\Messenger\MessageBusInterface;
|
use Symfony\Component\Messenger\MessageBusInterface;
|
||||||
@@ -22,7 +24,6 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private MessageBusInterface $bus,
|
private MessageBusInterface $bus,
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private MediaFiles $mediaFiles,
|
|
||||||
private CacheInterface $cache,
|
private CacheInterface $cache,
|
||||||
private readonly Broadcaster $broadcaster,
|
private readonly Broadcaster $broadcaster,
|
||||||
) {}
|
) {}
|
||||||
@@ -30,15 +31,12 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void
|
public function download(string $mediaType, string $url, string $downloadPath, ?int $downloadId): void
|
||||||
{
|
{
|
||||||
/** @var Download $downloadEntity */
|
/** @var Download $downloadEntity */
|
||||||
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
|
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences();
|
|
||||||
$path = $this->getDownloadPath($mediaType, $title, $downloadPreferences);
|
|
||||||
|
|
||||||
$processArgs = ['wget', '-O', $downloadEntity->getFilename(), $url];
|
$processArgs = ['wget', '-O', $downloadEntity->getFilename(), $url];
|
||||||
|
|
||||||
if ($downloadEntity->getStatus() === 'Paused') {
|
if ($downloadEntity->getStatus() === 'Paused') {
|
||||||
@@ -48,13 +46,9 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
$downloadEntity->setProgress(0);
|
$downloadEntity->setProgress(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
fwrite(STDOUT, implode(" ", $processArgs));
|
$process = (new Process($processArgs))->setWorkingDirectory($downloadPath);
|
||||||
|
|
||||||
$process = (new Process($processArgs))->setWorkingDirectory($path);
|
|
||||||
|
|
||||||
$process->setTimeout(1800); // 30 min
|
$process->setTimeout(1800); // 30 min
|
||||||
$process->setIdleTimeout(600); // 10 min
|
$process->setIdleTimeout(600); // 10 min
|
||||||
|
|
||||||
$process->start();
|
$process->start();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -93,7 +87,7 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
} catch (ProcessFailedException $exception) {
|
} catch (ProcessFailedException $exception) {
|
||||||
$downloadEntity->setStatus('Failed');
|
$downloadEntity->setStatus('Failed');
|
||||||
$this->bus->dispatch(new AddEventLogCommand(
|
$this->bus->dispatch(new AddEventLogCommand(
|
||||||
$downloadEntity->getUser()->getId(),
|
$downloadEntity->getUser(),
|
||||||
DownloadEvents::DOWNLOAD_ERROR->type(),
|
DownloadEvents::DOWNLOAD_ERROR->type(),
|
||||||
DownloadEvents::DOWNLOAD_ERROR->message() . ': ' . $exception->getMessage(),
|
DownloadEvents::DOWNLOAD_ERROR->message() . ': ' . $exception->getMessage(),
|
||||||
(array) $downloadEntity
|
(array) $downloadEntity
|
||||||
@@ -103,22 +97,6 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
$this->entityManager->flush();
|
$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");
|
|
||||||
}
|
|
||||||
|
|
||||||
private function alertComplete(Download $download): void
|
private function alertComplete(Download $download): void
|
||||||
{
|
{
|
||||||
if ("tvshows" === $download->getMediaType()) {
|
if ("tvshows" === $download->getMediaType()) {
|
||||||
@@ -129,4 +107,4 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
|
|
||||||
$this->broadcaster->alert('Success', $message, sendPush: true);
|
$this->broadcaster->alert('Success', $message, sendPush: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Download\Downloader;
|
|
||||||
|
|
||||||
use App\Message\DownloadMessage;
|
|
||||||
use App\Message\DownloadMovieMessage;
|
|
||||||
use App\Message\DownloadTvShowMessage;
|
|
||||||
|
|
||||||
class WgetDownloader implements DownloaderInterface
|
|
||||||
{
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @inheritDoc
|
|
||||||
* SSHs into the NAS and performs the download.
|
|
||||||
* This way retains the fast DL speed on the NAS.
|
|
||||||
*/
|
|
||||||
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void
|
|
||||||
{
|
|
||||||
// SSHs into the NAS, cds into movies dir, makes new dir based on filename, cds into that dir, downloads movie
|
|
||||||
system(sprintf(
|
|
||||||
'sh /var/www/bash/app/wget_download.sh "%s" "%s" "%s"',
|
|
||||||
$baseDir,
|
|
||||||
$title,
|
|
||||||
$url
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ namespace App\Download\Framework\Controller;
|
|||||||
|
|
||||||
use App\Base\Service\Broadcaster;
|
use App\Base\Service\Broadcaster;
|
||||||
use App\Download\Action\Handler\DeleteDownloadHandler;
|
use App\Download\Action\Handler\DeleteDownloadHandler;
|
||||||
|
use App\Download\Action\Handler\DownloadMediaHandler;
|
||||||
use App\Download\Action\Handler\PauseDownloadHandler;
|
use App\Download\Action\Handler\PauseDownloadHandler;
|
||||||
use App\Download\Action\Handler\ResumeDownloadHandler;
|
use App\Download\Action\Handler\ResumeDownloadHandler;
|
||||||
use App\Download\Action\Input\DeleteDownloadInput;
|
use App\Download\Action\Input\DeleteDownloadInput;
|
||||||
@@ -12,6 +13,7 @@ use App\Download\Action\Input\DownloadSeasonInput;
|
|||||||
use App\Download\Action\Input\PauseDownloadInput;
|
use App\Download\Action\Input\PauseDownloadInput;
|
||||||
use App\Download\Action\Input\ResumeDownloadInput;
|
use App\Download\Action\Input\ResumeDownloadInput;
|
||||||
use App\Download\DownloadEvents;
|
use App\Download\DownloadEvents;
|
||||||
|
use App\Download\Framework\Entity\Download;
|
||||||
use App\Download\Framework\Repository\DownloadRepository;
|
use App\Download\Framework\Repository\DownloadRepository;
|
||||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -31,19 +33,19 @@ class ApiController extends AbstractController
|
|||||||
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
|
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
|
||||||
public function download(
|
public function download(
|
||||||
DownloadMediaInput $input,
|
DownloadMediaInput $input,
|
||||||
|
DownloadMediaHandler $handler,
|
||||||
): Response {
|
): Response {
|
||||||
$download = $this->downloadRepository->insert(
|
$download = $this->downloadRepository->insertNew(
|
||||||
$this->getUser(),
|
$this->getUser(),
|
||||||
$input->url,
|
|
||||||
$input->title,
|
|
||||||
$input->filename,
|
|
||||||
$input->imdbId,
|
$input->imdbId,
|
||||||
$input->mediaType,
|
$input->mediaType,
|
||||||
$input->episodeId,
|
$input->season,
|
||||||
|
$input->episode,
|
||||||
);
|
);
|
||||||
$input->downloadId = $download->getId();
|
$input->setDownloadId($download->getId());
|
||||||
$input->userId = $this->getUser()->getId();
|
$input->setUserId($this->getUser()->getId());
|
||||||
$input->mercureAlertTopic = $this->requestStack->getSession()->get('mercure_alert_topic');
|
$input->setMercureAlertTopic($this->requestStack->getSession()->get('mercure_alert_topic'));
|
||||||
|
$input->toCommand();
|
||||||
|
|
||||||
$this->bus->dispatch(new AddEventLogCommand(
|
$this->bus->dispatch(new AddEventLogCommand(
|
||||||
$this->getUser(),
|
$this->getUser(),
|
||||||
@@ -60,7 +62,7 @@ class ApiController extends AbstractController
|
|||||||
|
|
||||||
$this->broadcaster->alert(
|
$this->broadcaster->alert(
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: "$input->title added to Queue."
|
message: "Added to Queue."
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
|
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
|
||||||
@@ -121,4 +123,22 @@ class ApiController extends AbstractController
|
|||||||
|
|
||||||
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
|
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/api/download', name: 'api_get_downloads', methods: ['GET'])]
|
||||||
|
public function getDownloads(DownloadRepository $repository): Response
|
||||||
|
{
|
||||||
|
$downloads = $repository->findBy(['user' => $this->getUser()]);
|
||||||
|
|
||||||
|
return $this->json(['status' => 200, 'message' => 'Success', 'data' => ['downloads' => $downloads]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/download/{id}', name: 'api_get_download', methods: ['GET'])]
|
||||||
|
public function getDownload(Download $download): Response
|
||||||
|
{
|
||||||
|
if ($download->getUser() === $this->getUser()) {
|
||||||
|
return $this->json(['status' => 200, 'message' => 'Success', 'data' => ['download' => $download]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json(['status' => 404, 'message' => 'Success'], 404);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class Download
|
|||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
private ?string $title = null;
|
private ?string $title = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 1024)]
|
#[ORM\Column(length: 1024, nullable: true)]
|
||||||
private ?string $url = null;
|
private ?string $url = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 1024, nullable: true)]
|
#[ORM\Column(length: 1024, nullable: true)]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Download\Framework\Repository;
|
namespace App\Download\Framework\Repository;
|
||||||
|
|
||||||
|
use App\Base\Util\EpisodeId;
|
||||||
use App\Base\Util\Paginator;
|
use App\Base\Util\Paginator;
|
||||||
use App\Download\Framework\Entity\Download;
|
use App\Download\Framework\Entity\Download;
|
||||||
use App\User\Framework\Entity\User;
|
use App\User\Framework\Entity\User;
|
||||||
@@ -56,6 +57,34 @@ class DownloadRepository extends ServiceEntityRepository
|
|||||||
return $this->paginator->paginate($query, $pageNumber, $perPage);
|
return $this->paginator->paginate($query, $pageNumber, $perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function insertNew(
|
||||||
|
UserInterface $user,
|
||||||
|
string $imdbId,
|
||||||
|
string $mediaType,
|
||||||
|
int|null $season = null,
|
||||||
|
int|null $episode = null,
|
||||||
|
string $status = 'New'
|
||||||
|
): Download {
|
||||||
|
/** @var User $user */
|
||||||
|
$download = (new Download())
|
||||||
|
->setUser($user)
|
||||||
|
->setImdbId($imdbId)
|
||||||
|
->setMediaType($mediaType)
|
||||||
|
->setProgress(0)
|
||||||
|
->setStatus($status);
|
||||||
|
|
||||||
|
if (null !== $season && null !== $episode) {
|
||||||
|
$download->setEpisodeId(
|
||||||
|
EpisodeId::fromSeasonEpisodeNumbers($season, $episode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->getEntityManager()->persist($download);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
|
||||||
|
return $download;
|
||||||
|
}
|
||||||
|
|
||||||
public function insert(
|
public function insert(
|
||||||
UserInterface $user,
|
UserInterface $user,
|
||||||
string $url,
|
string $url,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Monitor\Framework\Entity\Monitor;
|
|||||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||||
use App\Tmdb\Dto\TmdbEpisodeDto;
|
use App\Tmdb\Dto\TmdbEpisodeDto;
|
||||||
use App\Tmdb\TmdbClient;
|
use App\Tmdb\TmdbClient;
|
||||||
|
use App\Tmdb\TmdbResult;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -36,18 +37,14 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
|||||||
public function handle(CommandInterface $command): ResultInterface
|
public function handle(CommandInterface $command): ResultInterface
|
||||||
{
|
{
|
||||||
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
|
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
|
||||||
|
|
||||||
$monitor = $this->monitorRepository->find($command->monitorId);
|
$monitor = $this->monitorRepository->find($command->monitorId);
|
||||||
$this->refreshData($monitor);
|
$showTmdbData = $this->tmdb->tvshowDetails($monitor->getImdbId());
|
||||||
|
|
||||||
|
$this->refreshData($monitor, $showTmdbData);
|
||||||
|
|
||||||
// Check current episodes
|
// Check current episodes
|
||||||
$downloadedEpisodes = $this->mediaFiles
|
$downloadedEpisodes = $this->getDownloadedEpisodes($monitor->getTitle(), $showTmdbData);
|
||||||
->getEpisodes($monitor->getTitle())
|
|
||||||
->map(fn($episode) => (object)(new PTN())->parse($episode))
|
|
||||||
->filter(fn($episode) => property_exists($episode, 'episode')
|
|
||||||
&& property_exists($episode, 'season')
|
|
||||||
&& null !== $episode->episode
|
|
||||||
&& null !== $episode->season
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
|
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
|
||||||
|
|
||||||
@@ -130,6 +127,33 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getDownloadedEpisodes(string $title, TmdbResult $showTmdbData): Map
|
||||||
|
{
|
||||||
|
// Episodes in folder w/o the year
|
||||||
|
$downloadedEpisodes = $this->mediaFiles
|
||||||
|
->getEpisodes($title)
|
||||||
|
->map(fn($episode) => (object) new PTN()->parse($episode))
|
||||||
|
->filter(fn($episode) =>
|
||||||
|
property_exists($episode, 'episode')
|
||||||
|
&& property_exists($episode, 'season')
|
||||||
|
&& null !== $episode->episode
|
||||||
|
&& null !== $episode->season
|
||||||
|
);
|
||||||
|
|
||||||
|
return $downloadedEpisodes->concat(
|
||||||
|
// Episodes in folder w/ the year
|
||||||
|
$this->mediaFiles
|
||||||
|
->getEpisodes(sprintf("%s (%s)", $title, $showTmdbData->year))
|
||||||
|
->map(fn($episode) => (object) new PTN()->parse($episode))
|
||||||
|
->filter(fn($episode) =>
|
||||||
|
property_exists($episode, 'episode')
|
||||||
|
&& property_exists($episode, 'season')
|
||||||
|
&& null !== $episode->episode
|
||||||
|
&& null !== $episode->season
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function episodeReleasedAfterMonitorCreated(
|
private function episodeReleasedAfterMonitorCreated(
|
||||||
string|DateTimeImmutable $monitorStartDate,
|
string|DateTimeImmutable $monitorStartDate,
|
||||||
TmdbEpisodeDto $episodeInShow
|
TmdbEpisodeDto $episodeInShow
|
||||||
@@ -159,11 +183,11 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
|||||||
]) !== null;
|
]) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function refreshData(Monitor $monitor)
|
private function refreshData(Monitor $monitor, TmdbResult $showTmdbData)
|
||||||
{
|
{
|
||||||
if (null === $monitor->getPoster()) {
|
if (null === $monitor->getPoster()) {
|
||||||
$this->logger->info('> [MonitorTvShowHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
|
$this->logger->info('> [MonitorTvShowHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
|
||||||
$poster = $this->tmdb->tvshowDetails($monitor->getImdbId())->poster;
|
$poster = $showTmdbData->poster;
|
||||||
if (null !== $poster && "" !== $poster) {
|
if (null !== $poster && "" !== $poster) {
|
||||||
$monitor->setPoster($poster);
|
$monitor->setPoster($poster);
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/Monitor/MonitorTypes.php
Normal file
10
src/Monitor/MonitorTypes.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Monitor;
|
||||||
|
|
||||||
|
enum MonitorTypes: string
|
||||||
|
{
|
||||||
|
case TV_EPISODE = "tvepisode";
|
||||||
|
case TV_SEASON = "tvseason";
|
||||||
|
case TV_SERIES = "tvseries";
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use App\Base\Util\ImdbMatcher;
|
|||||||
use App\Tmdb\Dto\TmdbEpisodeDto;
|
use App\Tmdb\Dto\TmdbEpisodeDto;
|
||||||
use App\Tmdb\Dto\WatchProviderDto;
|
use App\Tmdb\Dto\WatchProviderDto;
|
||||||
use Psr\Cache\CacheItemPoolInterface;
|
use Psr\Cache\CacheItemPoolInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
use Symfony\Component\HttpClient\HttpClient;
|
use Symfony\Component\HttpClient\HttpClient;
|
||||||
@@ -60,6 +61,7 @@ class TmdbClient
|
|||||||
private readonly SerializerInterface $serializer,
|
private readonly SerializerInterface $serializer,
|
||||||
private readonly CacheItemPoolInterface $cache,
|
private readonly CacheItemPoolInterface $cache,
|
||||||
private readonly EventDispatcherInterface $eventDispatcher,
|
private readonly EventDispatcherInterface $eventDispatcher,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
#[Autowire(env: 'TMDB_API')] string $apiKey,
|
#[Autowire(env: 'TMDB_API')] string $apiKey,
|
||||||
#[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
|
#[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
|
||||||
) {
|
) {
|
||||||
@@ -293,7 +295,12 @@ class TmdbClient
|
|||||||
!in_array($mediaType, [MediaType::Movie->value, MediaType::TvShow->value])) {
|
!in_array($mediaType, [MediaType::Movie->value, MediaType::TvShow->value])) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return $this->repos[$mediaType]->getApi()->getExternalIds($tmdbId);
|
try {
|
||||||
|
return $this->repos[$mediaType]->getApi()->getExternalIds($tmdbId);
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$this->logger->warning("[TmdbClient] Error getting external ids for $tmdbId: " . $throwable->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function findByImdbId(string $imdbId): array
|
private function findByImdbId(string $imdbId): array
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
|||||||
|
|
||||||
class UserPreferencesFactory
|
class UserPreferencesFactory
|
||||||
{
|
{
|
||||||
|
public static function createFromArray(array $data): UserPreferences
|
||||||
|
{
|
||||||
|
return new UserPreferences(
|
||||||
|
resolution: static::getArrayValue($data, 'resolution'),
|
||||||
|
codec: static::getArrayValue($data, 'codec'),
|
||||||
|
language: static::getArrayValue($data, 'language'),
|
||||||
|
provider: static::getArrayValue($data, 'provider'),
|
||||||
|
quality: static::getArrayValue($data, 'quality'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** @param User $user */
|
/** @param User $user */
|
||||||
public static function createFromUser(UserInterface $user): UserPreferences
|
public static function createFromUser(UserInterface $user): UserPreferences
|
||||||
{
|
{
|
||||||
@@ -30,4 +41,21 @@ class UserPreferencesFactory
|
|||||||
$value = explode(',', $value);
|
$value = explode(',', $value);
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function getArrayValue(array $data, string $key): array|null
|
||||||
|
{
|
||||||
|
if (!array_key_exists($key, $data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("" === $data[$key]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === is_string($data[$key])) {
|
||||||
|
return [$data[$key]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data[$key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class RegistrationFormType extends AbstractType
|
|||||||
->add('plainPassword', PasswordType::class, [
|
->add('plainPassword', PasswordType::class, [
|
||||||
// instead of being set onto the object directly,
|
// instead of being set onto the object directly,
|
||||||
// this is read and encoded in the controller
|
// this is read and encoded in the controller
|
||||||
|
'label' => 'Password',
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
'attr' => ['autocomplete' => 'new-password'],
|
'attr' => ['autocomplete' => 'new-password'],
|
||||||
'constraints' => [
|
'constraints' => [
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ module.exports = {
|
|||||||
"bg-green-400",
|
"bg-green-400",
|
||||||
"bg-purple-400",
|
"bg-purple-400",
|
||||||
"bg-orange-400",
|
"bg-orange-400",
|
||||||
|
"bg-red-500",
|
||||||
|
"bg-green-500",
|
||||||
"bg-blue-600",
|
"bg-blue-600",
|
||||||
"bg-rose-600",
|
"bg-rose-600",
|
||||||
"bg-black/20",
|
"bg-black/20",
|
||||||
|
"text-red-500",
|
||||||
|
"text-green-500",
|
||||||
"alert-success",
|
"alert-success",
|
||||||
"alert-warning",
|
"alert-warning",
|
||||||
"font-bold",
|
"font-bold",
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">500</h2>
|
<h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">500</h2>
|
||||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
|
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[540px] border-orange-500 border-2 text-gray-50 animate-fade">
|
||||||
<div class="flex flex-col m-0 text-center">
|
<div class="flex flex-col m-0 text-center">
|
||||||
<h3 class="text-2xl text-bold text-center text-gray-50">Oh crap!</h3>
|
<h3 class="text-2xl text-bold text-center text-gray-50">Oh crap!</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-2">There are many things I'm capable of, but this ain't one of 'em!</p>
|
<p class="mb-2">There are many things I'm capable of, but this ain't one of 'em!</p>
|
||||||
<pre class="bg-gray-800 text-white p-4 rounded-md overflow-x-auto">
|
<p class="text-sm mb-1">
|
||||||
<code class="language-plaintext">
|
<code>{{ exception.file }}</code>
|
||||||
{{ exception.message }}
|
</p>
|
||||||
</code>
|
<pre class="bg-gray-800 text-white p-4 rounded-md overflow-x-auto"><code class="language-plaintext">{{ exception.message|trim }}</code></pre>
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,6 +6,20 @@
|
|||||||
<div class="md:flex md:items-center md:gap-12">
|
<div class="md:flex md:items-center md:gap-12">
|
||||||
<nav aria-label="Global" class="md:block">
|
<nav aria-label="Global" class="md:block">
|
||||||
<ul class="ml-4 flex items-end md:items-center md:gap-6 text-sm">
|
<ul class="ml-4 flex items-end md:items-center md:gap-6 text-sm">
|
||||||
|
<li>
|
||||||
|
<div class="flex flex-row justify-center items-start gap-2 p-2 w-10 mt-1 h-6 rounded-lg border border-orange-500 text-orange-500">
|
||||||
|
<status-checker-span
|
||||||
|
class="h-2 w-2 rounded-full text-green-600 bg-green-600"
|
||||||
|
url="https://torrentio.strem.fun"
|
||||||
|
service="Torrentio">
|
||||||
|
</status-checker-span>
|
||||||
|
<status-checker-span
|
||||||
|
class="h-2 w-2 rounded-full text-green-600 bg-green-600"
|
||||||
|
url="https://api.themoviedb.org/3"
|
||||||
|
service="TMDB">
|
||||||
|
</status-checker-span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false" title="View upcoming episodes of the shows you're subscribed to.">
|
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false" title="View upcoming episodes of the shows you're subscribed to.">
|
||||||
<twig:ux:icon name="solar:calendar-linear" width="25px" class="text-orange-500" />
|
<twig:ux:icon name="solar:calendar-linear" width="25px" class="text-orange-500" />
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="flex flex-row gap-2 mb-2">
|
<div class="flex flex-row gap-2 mb-2">
|
||||||
<input type="hidden" name="movie_folder" id="movie_folder_hidden" value="0" />
|
<input type="hidden" name="movie_folder" id="movie_folder_hidden" value="0" />
|
||||||
<input type="checkbox" name="movie_folder" id="movie_folder" value="1" {{ downloadPreferences['movie_folder'].getPreferenceValue() == true ? 'checked' }} />
|
<input type="checkbox" name="movie_folder" id="movie_folder" value="1" {{ downloadPreferences['movie_folder'].getPreferenceValue() == true ? 'checked' }} />
|
||||||
<label class="text-gray-50" for="movie_folder">Store movies in a new directory?</label>
|
<label class="text-gray-50" for="movie_folder">Create a new directory for each movie?</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
|
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user