Compare commits

...

35 Commits

Author SHA1 Message Date
Brock H Caldwell
2fc6d792bc chore: renames form field
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m33s
2026-03-03 22:07:06 -06:00
Brock H Caldwell
c9f1a2d93a chore: updates readme
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-03-03 22:04:22 -06:00
Brock H Caldwell
bbdd11d1b5 fix: rewords form question
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m32s
2026-03-03 21:59:14 -06:00
Brock H Caldwell
1827908936 chore: updates readme
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m32s
2026-03-03 20:16:49 -06:00
Brock H Caldwell
8b99a744e2 chore: updates readme
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m32s
2026-03-03 20:12:00 -06:00
Brock H Caldwell
2d42b60e26 chore: cleanup
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m32s
2026-03-03 19:50:16 -06:00
Brock H Caldwell
448dd12fa5 chore: adds note to compose.yml 2026-03-03 18:59:50 -06:00
Brock H Caldwell
a30a554e06 fix: mysql healthcheck
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-03-01 22:54:06 -06:00
Brock H Caldwell
bd6918abd1 fix: injects TMDB API token in build
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-03-01 21:57:02 -06:00
Brock H Caldwell
9a0b0443d4 chore: lists filename in error output
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-03-01 14:03:04 -06:00
1726b21d1d fix: uses latest tag by default
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-03-01 18:37:09 +00:00
Brock H Caldwell
207fd26f50 fix: prevents 'your torrent is being downloaded' downloads from download seasson handler
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-02-11 16:27:49 -06:00
Brock H Caldwell
aa357725e8 fix: prevents 'your torrent is being downloaded' downloads
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m13s
2026-02-11 16:21:17 -06:00
Brock H Caldwell
759f64ea22 fix(DownloadSeasonHandler): actually captures season/episode numbers 2026-02-08 21:51:02 -06:00
Brock H Caldwell
cc88660c07 fix(DownloadSeasonHandler): captures episode id
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-02-08 15:11:14 -06:00
Brock H Caldwell
dbcc24c49f fix(DownloadOptionEvaluator): bad logic checking filters
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -50s
2026-02-08 12:34:50 -06:00
Brock H Caldwell
939b059872 fix: adds download url check for monitors
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m0s
2026-02-06 19:41:09 -06:00
Brock H Caldwell
f968e7e622 feat: allows configuring whether to cache torrentio results
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-02-06 15:23:39 -06:00
Brock H Caldwell
7958f50ff7 fix: includes missing files from last commit 2026-02-06 15:23:18 -06:00
Brock H Caldwell
f4644d40ef feat: notifies user of bad RD download (failed for copyright, etc.)
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 13s
2026-02-06 09:55:19 -06:00
Brock H Caldwell
37516c7f02 fix: closes modal when clicking dismiss button
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -59s
2026-02-06 09:51:07 -06:00
Brock H Caldwell
c7956f5f0b fix: pushes alert dismiss button to end of div 2026-02-06 09:47:14 -06:00
Brock H Caldwell
fdf8714033 fix: supports random mysql root password 2026-02-05 18:51:06 -06:00
Brock H Caldwell
0e667fc7aa chore: updates example compose
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 14s
2026-01-25 12:57:25 -06:00
Brock H Caldwell
2effa0fb07 feat: new Discover section shows watch providers for results
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 24s
SonarQube Scan / SonarQube Trigger (push) Failing after 36s
2025-11-11 23:08:20 -06:00
Brock H Caldwell
c2474942a1 fix: uses new action
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 28s
2025-11-10 16:54:49 -06:00
Brock H Caldwell
c175dddede fix: hard codes qube host
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 9s
2025-11-10 16:29:35 -06:00
Brock H Caldwell
0e1d8e15e3 fix: hard codes qube host
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 10s
2025-11-10 16:26:00 -06:00
Brock H Caldwell
d38f8ba4be fix: adds gitea workflow
Some checks failed
SonarQube Scan / SonarQube Trigger (pull_request) Failing after 9s
2025-11-10 15:45:01 -06:00
Brock H Caldwell
e20def1325 task: adds gitea action to trigger sonarqube scan 2025-11-10 15:42:53 -06:00
Brock H Caldwell
ed69ed61b8 fix: sentry release 2025-11-09 10:08:41 -06:00
Brock H Caldwell
9c8e625316 fix(Sentry): uses correct version parameter 2025-11-09 10:00:46 -06:00
Brock H Caldwell
ef6ed20a0b fix: adds sentry release 2025-11-09 09:53:21 -06:00
Brock H Caldwell
da0eab652b test(Sentry): enables logs 2025-11-08 23:14:30 -06:00
Brock H Caldwell
17de41dc57 fix(Calendar): null attachment 2025-11-08 22:23:49 -06:00
53 changed files with 1163 additions and 435 deletions

3
.env
View File

@@ -68,3 +68,6 @@ SENTRY_JS_URL=
# - only include media originally
# produced in this language
TMDB_ORIGINAL_LANGUAGE=en
# Cache Torrentio Results
TORRENTIO_CACHE_RESULTS=true

View File

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

@@ -24,3 +24,6 @@ phpstan.neon
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###
docs/examples/movies/
docs/examples/tvshows/

224
README.md
View File

@@ -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
(hence the name: Torsearch).
After two failed attempts at running a media server, I decided to hang up my hat and give up my dream of a self-hosted
media server. I figured the days of torrenting were mostly over and everybody ranting & raving about their media collections
must be going to Walmart and buying up the bucket of old movies they have. That's until I stumbled across Stremio.
At first, it seemed too good to be true, but I was yearning for something just sketchy enough to try out. What could
go wrong with handing over my card information to an unknown organization across the pond? At the end of the day,
the cost benefit analysis landed in my favor, and about 30 minutes after purchasing my Real Debrid subscription and
setting up Stremio, I was in business.
My mind was blown. I might not have the most "cultured" taste in media, but it had everything I searched for and more! After
watching a few movies, I noticed the "Copy Download Link" button. "What's this lil guy do?" I asked myself. Duh, it
downloads the f*****g movie. And there's the 💡flashing over my head. There's gotta be a way to automate this, I told myself.
After a month of studying Stremio's code and lots of tinkering, I finally figured it out. Torrentio is the magic behind
the scenes. You feed it a Real Debrid API key and an IMDB ID, and it gives you a list of results to download that media.
Easy peasy.
In about an hour I had a proof of concept working. It wasn't pretty, but it wasn't supposed to be. That proof-of-concept
has blossomed into the beautiful Torsearch that I've been using nearly every day since then. The code in this repo
is a complete re-write of the proof-of-concept that started out ugly and ended up even uglier. Knowing the core functionality
required to make it work, I was able to re-write the app with some design patterns in place.
## Pics or didn't happen
![Torsearch Homepage](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_homepage.png)
![TV Show Result](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_severance.png)
@@ -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
- Download directly to your NAS
- Monitor TV Shows for new episodes and automatically download them
- Browse popular media and view its download options
- LDAP or local auth (OIDC coming soon)
- Discover new media
- OIDC
## Features on the roadmap
- Requests - allow users to request new media
- OIDC auth
- Prometheus logging
## 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.

View File

@@ -14,5 +14,6 @@ export default class extends Controller {
"3000"
));
this.element.addEventListener('mouseover', () => clearTimeout(timer));
this.element.querySelector('.modal-close').addEventListener('click', () => this.element.remove());
}
}

View File

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

View File

@@ -1,3 +1,5 @@
# This file is used for local development
# see the docs/examples directory for production examples
services:
caddy:
image: caddy:2.9.1
@@ -44,6 +46,7 @@ services:
environment:
TZ: America/Chicago
scheduler:
build:
dockerfile: docker/Dockerfile.scheduler
@@ -77,9 +80,9 @@ services:
environment:
TZ: America/Chicago
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_USER: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
MYSQL_RANDOM_ROOT_PASSWORD: true
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s

View File

@@ -23,6 +23,6 @@ return [
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true],
SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::class => ['all' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true],
Sentry\SentryBundle\SentryBundle::class => ['prod' => true, 'dev' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
];

View File

@@ -1,9 +1,10 @@
when@prod:
sentry:
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:
release: 'torsearch@%app.version%'
enable_logs: true
traces_sample_rate: 1
profiles_sample_rate: 1
attach_stacktrace: true

View File

@@ -6,6 +6,14 @@ controllersBase:
defaults:
schemes: [ 'https' ]
controllersDiscover:
resource:
path: ../src/Discover/Framework/Controller/
namespace: App\Discover\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersEventLog:
resource:
path: ../src/EventLog/Framework/Controller/

View File

@@ -52,6 +52,9 @@ parameters:
sentry.dsn: '%env(SENTRY_DSN)%'
sentry.javascript_url: '%env(SENTRY_JS_URL)%'
# Torrentio
torrentio.cache_results: '%env(bool:TORRENTIO_CACHE_RESULTS)%'
services:
# default configuration for services in *this* file
_defaults:

View File

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

View File

@@ -3,6 +3,9 @@ FROM code.caldwell.digital/home/torsearch-base:php8.4
ARG APP_VERSION="0.0.0-dev"
ENV APP_VERSION="${APP_VERSION}"
ARG TMDB_API=""
ENV TMDB_API="${TMDB_API}"
COPY . /app
COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -17,6 +17,9 @@ FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
# Set the APP_VERSION in the image
ENV APP_VERSION=${APP_VERSION}
ARG TMDB_API=""
ENV TMDB_API="${TMDB_API}"
# Copy the actual application code from the previously built app
COPY --chown=1000:1000 --from=app_image /app /app

View File

@@ -17,6 +17,9 @@ FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
# Set the APP_VERSION in the image
ENV APP_VERSION=${APP_VERSION}
ARG TMDB_API=""
ENV TMDB_API="${TMDB_API}"
# Copy the actual application code from the previously built app
COPY --chown=1000:1000 --from=app_image /app /app

View File

@@ -1,58 +1,92 @@
# App must be served over HTTPS (requirement of Mercure)
# Either serve behind an SSL terminating reverse proxy
# or pass your certificates into the 'app' container.
# Please omit any trailing slashes. The APP_URL is
# used to generate the Mercure URL behind the scenes.
APP_URL="https://dev.caldwell.digital"
###################
# 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.
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.
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
DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
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"
# Fill in your MySQL/MariaDB connection details
#DATABASE_URL="mysql://<mysql user>:<mysql pass>@<mysql host>:3306/<mysql db name>?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
# Enter your Real Debrid API key
# This key is never saved anywhere
# else and is passed to Torrentio
# to retrieve download options
REAL_DEBRID_KEY=""
###################
# Real Debrid #
###################
# Enter your Real Debrid API key is passed to Torrentio to retrieve download options
REAL_DEBRID_KEY="<enter real debrid api key>"
# 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=""
# Use your own Redis instance or use the
# below value to use the container included
# in the example compose.yml file.
###################
# 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 ###
# Change to "oidc" to and provide the required
# environment variables below to use OIDC auth.
###################
# 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
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=
### OIDC ###
#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=
# LDAP Config: To use LDAP, enter the below fields
# and run 'php bin/console config:set auth.method ldap'
### LDAP (*** Experimental! ***) ###
# LDAP Config: To use LDAP, enter the below fields and run 'php bin/console config:set auth.method ldap'
# (LDAP is still in progress and not ready for use)
#LDAP_HOST=
#LDAP_PORT=

View File

@@ -1,78 +1,56 @@
services:
### The app contains the application and web server ###
app:
image: code.caldwell.digital/home/torsearch-app:latest
image: code.caldwell.digital/home/torsearch-app:${TAG}
ports:
- '8006:80'
env_file:
- .env
volumes:
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
- "${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
# Downloads happen in this container. Replicate this
# container to run multiple downloads simultaneously.
# Map your "movies" folder to /var/download/movies
# Map your "TV shows" folder to /var/download/tvshows
# If your folders are on another machine, use an NFS volume.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
### The worker handles downloads and async jobs ###
worker:
image: code.caldwell.digital/home/torsearch-worker:latest
image: code.caldwell.digital/home/torsearch-worker:${TAG}
volumes:
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
command: -vv --time-limit=3600 --limit=10
env_file:
- .env
restart: always
- ${LOCAL_MOVIES_DIR}:/var/download/movies
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
env_file: .env
depends_on:
app:
condition: service_healthy
- app
# This container handles the monitoring for new media. When new
# monitors are added, jobs are periodically dispatched to this
# container, and the desired media is searched for and downloaded.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
### The scheduler processes monitored media ###
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest
image: code.caldwell.digital/home/torsearch-scheduler:${TAG}
volumes:
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
env_file:
- .env
command: -vv
environment:
TZ: America/Chicago
restart: always
- ${LOCAL_MOVIES_DIR}:/var/download/movies
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
env_file: .env
depends_on:
app:
condition: service_healthy
- app
#!! If using your own database, this can be omitted !!#
database:
image: mariadb:10.11.2
volumes:
- mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
env_file: .env
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
test: ["CMD", "mysqladmin", "ping"]
interval: 5s
timeout: 5s
retries: 10
#!! If using your own redis, this can be omitted !!#
redis:
image: redis:latest
volumes:
@@ -80,12 +58,6 @@ services:
command: redis-server --maxmemory 512MB
restart: unless-stopped
# **Optional**
# Provides a simple method of viewing the database
adminer:
image: adminer
ports:
- "8081:8080"
volumes:
mysql:

View File

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

View File

@@ -4,6 +4,7 @@ namespace App\Base;
use App\Base\Dto\AppVersionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class ConfigResolver
@@ -14,6 +15,7 @@ final class ConfigResolver
public function __construct(
private readonly DenormalizerInterface $denormalizer,
private readonly RequestStack $requestStack,
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
@@ -62,6 +64,9 @@ final class ConfigResolver
#[Autowire(param: 'sentry.javascript_url')]
private ?string $sentryJavascriptUrl = null,
#[Autowire(param: 'torrentio.cache_results')]
private ?bool $torrentioCacheResults = null,
) {}
public function validate(): bool
@@ -110,6 +115,11 @@ final class ConfigResolver
return $this->authOidcBypassFormLogin;
}
public function isTorrentioCacheEnabled(): bool
{
return $this->torrentioCacheResults;
}
public function getAppVersion(): AppVersionDto
{
$matches = [];

View File

@@ -24,10 +24,10 @@ readonly class Broadcaster
private LoggerInterface $logger,
) {}
public function alert(string $title, string $message, string $type = "success", bool $sendPush = false): void
public function alert(string $title, string $message, string $type = "success", bool $sendPush = false, ?string $mercureAlertTopic = null): void
{
try {
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$userAlertTopic = $mercureAlertTopic ?? $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
@@ -39,7 +39,7 @@ readonly class Broadcaster
);
$this->hub->publish($update);
} catch (\Throwable $exception) {
// ToDo: look for better handling to get message to end user
$this->logger->error('Unable to publish alert: ' . $exception->getMessage());
}
if (true === $sendPush && in_array($this->notificationTransport, ['ntfy'])) {

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Discover\Framework\Controller;
use App\Base\Enum\MediaType;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbMovieGenre;
use App\Tmdb\TmdbTvShowGenre;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/discover')]
class WebController extends AbstractController
{
#[Route('/', name: 'app.discover')]
public function index(TmdbClient $tmdb)
{
$movies = $tmdb->popularMovies(18);
$tvshows = $tmdb->popularTvShows(18);
return $this->render('discover/index.html.twig', [
'movies' => $movies,
'shows' => $tvshows,
]);
}
#[Route('/{mediaType}/{genreId?}', name: 'app.discover.browse')]
public function browse(string $mediaType, ?string $genreId, TmdbClient $tmdb)
{
if (null === $genreId) {
if (MediaType::tryFrom($mediaType) === null) {
return new Response(status: 404);
}
return $this->render('discover/browse.html.twig', [
'genres' => self::getGenres($mediaType),
'media_type' => $mediaType,
]);
}
if (MediaType::tryFrom($mediaType) === null) {
return new Response(status: 404);
}
if (TmdbMovieGenre::tryFrom($genreId) === null &&
TmdbTvShowGenre::tryFrom($genreId) === null
) {
return new Response(status: 404);
}
$results = match ($mediaType) {
MediaType::Movie->value => $tmdb->discoverMovies(
[TmdbMovieGenre::from($genreId)->value]
),
MediaType::TvShow->value => $tmdb->discoverTvShows(
[TmdbTvShowGenre::from($genreId)->value]
),
};
return $this->render('discover/browse_genre.html.twig', [
'media' => $results,
'genre' => TmdbMovieGenre::from($genreId)->name,
'genre_id' => $genreId,
'media_type' => $mediaType,
]);
}
#[Route('/{mediaType}/{genreId}', name: 'app.discover.browse_genre')]
public function browseGenre(string $mediaType, string $genreId, TmdbClient $tmdb)
{
if (MediaType::tryFrom($mediaType) === null) {
return new Response(status: 404);
}
if (TmdbMovieGenre::tryFrom($genreId) === null &&
TmdbTvShowGenre::tryFrom($genreId) === null
) {
return new Response(status: 404);
}
$results = match ($mediaType) {
MediaType::Movie->value => $tmdb->discoverMovies(
[TmdbMovieGenre::from($genreId)->value]
),
MediaType::TvShow->value => $tmdb->discoverTvShows(
[TmdbTvShowGenre::from($genreId)->value]
),
};
return $this->render('discover/browse.html.twig', [
'media' => $results,
'media_type' => $mediaType,
]);
}
private static function getGenres(string $mediaType): array
{
return match ($mediaType) {
MediaType::Movie->value => [
'Action' => TmdbMovieGenre::Action->value,
'Adventure' => TmdbMovieGenre::Adventure->value,
'Animation' => TmdbMovieGenre::Animation->value,
'Comedy' => TmdbMovieGenre::Comedy->value,
'Crime' => TmdbMovieGenre::Crime->value,
'Documentary' => TmdbMovieGenre::Documentary->value,
'Drama' => TmdbMovieGenre::Drama->value,
'Family' => TmdbMovieGenre::Family->value,
'Fantasy' => TmdbMovieGenre::Fantasy->value,
'History' => TmdbMovieGenre::History->value,
'Horror' => TmdbMovieGenre::Horror->value,
'Music' => TmdbMovieGenre::Music->value,
'Mystery' => TmdbMovieGenre::Mystery->value,
'Romance' => TmdbMovieGenre::Romance->value,
'Science Fiction' => TmdbMovieGenre::ScienceFiction->value,
'TV Movie' => TmdbMovieGenre::TvMovie->value,
'Thriller' => TmdbMovieGenre::Thriller->value,
'War' => TmdbMovieGenre::War->value,
'Western' => TmdbMovieGenre::Western->value,
],
MediaType::TvShow->value => [
'Action & Adventure' => TmdbTvShowGenre::ActionAndAdventure->value,
'Animation' => TmdbTvShowGenre::Animation->value,
'Comedy' => TmdbTvShowGenre::Comedy->value,
'Crime' => TmdbTvShowGenre::Crime->value,
'Documentary' => TmdbTvShowGenre::Documentary->value,
'Drama' => TmdbTvShowGenre::Drama->value,
'Family' => TmdbTvShowGenre::Family->value,
'Kids' => TmdbTvShowGenre::Kids->value,
'Mystery' => TmdbTvShowGenre::Mystery->value,
'News' => TmdbTvShowGenre::News->value,
'Reality' => TmdbTvShowGenre::Reality->value,
'Sci-Fi & Fantasy' => TmdbTvShowGenre::SciFiAndFantasy->value,
'Soap' => TmdbTvShowGenre::Soap->value,
'Talk' => TmdbTvShowGenre::Talk->value,
'War & Politics' => TmdbTvShowGenre::WarAndPolitics->value,
'Western' => TmdbTvShowGenre::Western->value,
],
default => [],
};
}
}

View File

@@ -17,5 +17,6 @@ class DownloadMediaCommand implements CommandInterface
public string $imdbId,
public int $userId,
public ?int $downloadId = null,
public ?string $mercureAlertTopic = null,
) {}
}

View File

@@ -2,9 +2,12 @@
namespace App\Download\Action\Handler;
use App\Base\Enum\MediaType;
use App\Base\Service\Broadcaster;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\DownloadEvents;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface;
use App\EventLog\Action\Command\AddEventLogCommand;
@@ -22,7 +25,7 @@ readonly class DownloadMediaHandler implements HandlerInterface
private MessageBusInterface $bus,
private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository,
private UserRepository $userRepository, private Broadcaster $broadcaster,
) {}
public function handle(CommandInterface $command): ResultInterface
@@ -49,6 +52,16 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId);
}
try {
$this->validateDownloadUrl($download->getUrl());
} catch (\Throwable $exception) {
$download->setProgress(100);
$download->setStatus('Failed');
$this->downloadRepository->getEntityManager()->flush();
$this->sendFailedDownloadAlert($download, $command->mercureAlertTopic, $exception->getMessage());
return new DownloadMediaResult(400, $exception->getMessage());
}
try {
if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
@@ -77,4 +90,31 @@ readonly class DownloadMediaHandler implements HandlerInterface
));
return new DownloadMediaResult(200, "Success.");
}
public function validateDownloadUrl(string $downloadUrl)
{
$badFileSizes = [
2119075, // copyright infringement
];
$badFileLocations = [
'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);
if (array_key_exists($headers['Location'], $badFileLocations)) {
throw new \Exception($badFileLocations[$headers['Location']]);
}
}
private function sendFailedDownloadAlert(Download $download, string $mercureAlertTopic, ?string $message = null): void
{
$this->broadcaster->alert(
title: 'Download Failed',
message: $message ?? "{$download->getTitle()} failed to download.",
type: 'warning',
mercureAlertTopic: $mercureAlertTopic
);
}
}

View File

@@ -10,6 +10,8 @@ use App\Download\Action\Command\DownloadSeasonCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Action\Result\DownloadSeasonResult;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
@@ -32,7 +34,7 @@ readonly class DownloadSeasonHandler implements HandlerInterface
private MessageBusInterface $bus,
private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private UserRepository $userRepository,
private UserRepository $userRepository, private DownloadRepository $downloadRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
@@ -68,13 +70,16 @@ readonly class DownloadSeasonHandler implements HandlerInterface
if (null !== $result) {
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching result');
$this->logger->info('> [DownloadTvSeasonHandler] ......Dispatching DownloadMediaCommand for "' . $series->title . '" season ' . $command->season . ' episode ' . $episode->episodeNumber);
$download = $this->createDownload($command, $result->url, $series->title, $result->filename, $episode->episodeNumber);
$this->logger->info('> [DownloadTvSeasonHandler] ......Created Download entity with id ' . $download->getId());
$downloadCommand = new DownloadMediaCommand(
$result->url,
$series->title,
$result->filename,
'tvshows',
$command->imdbId,
$command->userId,
$download->getUrl(),
$download->getTitle(),
$download->getFilename(),
$download->getMediaType(),
$download->getImdbId(),
$download->getUser()->getId(),
$download->getId()
);
$this->bus->dispatch($downloadCommand);
$downloadCommands[] = $downloadCommand;
@@ -90,19 +95,27 @@ readonly class DownloadSeasonHandler implements HandlerInterface
);
}
private function getDownloadedEpisodes(string $title)
private function createDownload(DownloadSeasonCommand $command, string $url, string $title, string $filename, int $episodeNumber): Download
{
// Check current episodes
$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->episodeNumber
&& null !== $episode->season
)
->rekey(fn($episode) => $episode->episodeNumber);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
$download = new Download();
$download->setUrl($url);
$download->setTitle($title);
$download->setFilename($filename);
$download->setImdbId($command->imdbId);
$download->setMediaType(MediaType::TvShow->value);
$download->setEpisodeId($this->getEpisodeNumber($command->season, $episodeNumber));
$download->setUser($this->userRepository->find($command->userId));
$this->downloadRepository->getEntityManager()->persist($download);
$this->downloadRepository->getEntityManager()->flush();
return $download;
}
private function getEpisodeNumber(int $season, int $episode): string
{
return sprintf(
"S%sE%s",
str_pad($season, 2, "0", STR_PAD_LEFT),
str_pad($episode, 2, "0", STR_PAD_LEFT)
);
}
}

View File

@@ -10,6 +10,8 @@ use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DownloadMediaInput> */
class DownloadMediaInput implements InputInterface
{
public ?string $mercureAlertTopic = null;
public function __construct(
#[SourceRequest('url')]
public string $url,
@@ -44,6 +46,7 @@ class DownloadMediaInput implements InputInterface
$this->imdbId,
$this->userId,
$this->downloadId,
$this->mercureAlertTopic,
);
}
}

View File

@@ -21,7 +21,12 @@ class DownloadOptionEvaluator
return false;
}
if (false === $this->validateSize($result, $filter)) {
// todo: This is arbitrary- revisit in the future
//if (false === $this->validateSize($result, $filter)) {
// return false;
//}
if (false === $this->validateDownloadUrl($result->url)) {
return false;
}
@@ -47,15 +52,15 @@ class DownloadOptionEvaluator
$valid = false;
}
if (null !== $filter->codec && in_array($result->codec, $filter->codec)) {
if (null !== $filter->codec && !in_array($result->codec, $filter->codec)) {
$valid = false;
}
if (null !== $filter->quality && in_array($result->quality, $filter->quality)) {
if (null !== $filter->quality && !in_array($result->quality, $filter->quality)) {
$valid = false;
}
if (null !== $filter->provider && in_array($result->provider, $filter->provider)) {
if (null !== $filter->provider && !in_array($result->provider, $filter->provider)) {
$valid = false;
}
@@ -79,4 +84,19 @@ class DownloadOptionEvaluator
return false;
}
public function validateDownloadUrl(string $downloadUrl)
{
$badFileLocations = [
'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);
if (array_key_exists($headers['Location'], $badFileLocations)) {
return false;
}
return true;
}
}

View File

@@ -15,6 +15,7 @@ use App\Download\DownloadEvents;
use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -24,7 +25,7 @@ class ApiController extends AbstractController
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly Broadcaster $broadcaster,
private readonly Broadcaster $broadcaster, private readonly RequestStack $requestStack,
) {}
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
@@ -42,6 +43,7 @@ class ApiController extends AbstractController
);
$input->downloadId = $download->getId();
$input->userId = $this->getUser()->getId();
$input->mercureAlertTopic = $this->requestStack->getSession()->get('mercure_alert_topic');
$this->bus->dispatch(new AddEventLogCommand(
$this->getUser(),

View File

@@ -98,6 +98,14 @@ class DownloadRepository extends ServiceEntityRepository
return $download;
}
public function updateProgress(int $id, int $progress): Download
{
$download = $this->find($id);
$download->setProgress($progress);
$this->getEntityManager()->flush();
return $download;
}
public function delete(int $id)
{
$entity = $this->find($id);
@@ -115,4 +123,16 @@ class DownloadRepository extends ServiceEntityRepository
->getQuery()
->getResult();
}
public function badDownloadUrls()
{
return $this->createQueryBuilder('d')
->select('d.url')
->andWhere('d.status = :status')
->andWhere('d.progress = 100')
->setParameter('status', 'Failed')
->distinct()
->getQuery()
->getResult();
}
}

View File

@@ -29,10 +29,13 @@ class CalendarController extends AbstractController
$monitors = $monitorRepository->whereAirDateNotNull();
$calendar->event(Map::from($monitors)->map(function (Monitor $monitor) {
return new Event($monitor->getTitle())
$event = new Event($monitor->getTitle())
->startsAt($monitor->getAirDate())
->attachment($monitor->getPoster())
->fullDay();
if (null !== $monitor->getPoster()) {
$event->attachment($monitor->getPoster());
}
return $event;
})->toArray());
return new Response($calendar->get(), 200, [

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Tmdb\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class WatchProviderDto
{
const BASE_LOGO_PATH = 'https://image.tmdb.org/t/p/w185';
#[SerializedPath('[provider_id]')]
public int $id;
#[SerializedPath('[display_priority]')]
public int $displayPriority;
#[SerializedPath('[provider_name]')]
public string $name;
#[SerializedPath('[logo_path]')]
public string $logo {
set(string $value) => self::BASE_LOGO_PATH . $value;
}
public string $url;
}

View File

@@ -2,13 +2,18 @@
namespace App\Tmdb\Framework\Controller;
use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher;
use App\Library\Action\Result\LibrarySearchResult;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbMovieGenre;
use App\Tmdb\TmdbResult;
use App\Tmdb\TmdbTvShowGenre;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboBundle;
class ApiController extends AbstractController
{
@@ -47,4 +52,50 @@ class ApiController extends AbstractController
'results' => $results,
]);
}
#[Route('/api/tmdb/watch-providers/{mediaType}/{tmdbId}', name: 'api.tmdb.watch_providers', methods: ['GET'])]
public function watchProviders(string $mediaType, string $tmdbId, Request $request, TmdbClient $tmdb)
{
$result = $tmdb->watchProviders($tmdbId, $mediaType);
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse(['providers' => $result], $request);
}
return $this->json($result);
}
#[Route('/api/tmdb/genre/{mediaType}/{genreId}', name: 'api.tmdb.genre', methods: ['GET'])]
public function genreResults(string $mediaType, string $genreId, Request $request, TmdbClient $tmdb)
{
$genre = TmdbMovieGenre::from($genreId);
$results['media_type'] = $mediaType;
$results['genre'] = $genre->name;
$results['genre_id'] = $genre->value;
$results['media'] = match($mediaType) {
MediaType::Movie->value => $tmdb->discoverMovies(
[TmdbMovieGenre::from($genre->value)],
),
MediaType::TvShow->value => $tmdb->discoverTvShows(
[TmdbTvShowGenre::from($genreId)]
)
};
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse(['result' => $results], $request);
}
return $this->json($results);
}
private function sendFragmentResponse(mixed $result, Request $request): Response
{
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->renderBlock(
'discover/fragments.html.twig',
$request->query->get('block'),
[
'result' => $result,
'target' => $request->query->get('target')
]
);
}
}

View File

@@ -7,6 +7,7 @@ use App\Base\Enum\MediaType;
use App\Tmdb\Dto\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto;
use App\Tmdb\Dto\WatchProviderDto;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
@@ -28,6 +29,7 @@ class TmdbResultDenormalizer implements DenormalizerInterface
$result->directors = $this->getDirectors($data);
$result->producers = $this->getProducers($data);
$result->creators = $this->getCreators($data);
$result->watchProviders = $this->getWatchProviders($data);
return $result;
}
@@ -87,6 +89,17 @@ class TmdbResultDenormalizer implements DenormalizerInterface
->toArray();
}
public function getWatchProviders(array $data): ?array
{
if (!array_key_exists('watch/providers', $data)) {
return null;
}
// ToDo: Make region configurable
return Map::from($data['watch/providers']['results']['US']['flatrate'])
->map(fn($item) => $this->normalizer->denormalize($item, WatchProviderDto::class))
->toArray();
}
public function supportsDenormalization(
mixed $data,
string $type,

View File

@@ -6,9 +6,11 @@ use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\Dto\WatchProviderDto;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Serializer\SerializerInterface;
use Tmdb\Api\Find;
use Tmdb\Client;
@@ -31,7 +33,7 @@ use Tmdb\Token\Api\BearerToken;
class TmdbClient
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
const APPEND_TO_RESPONSE = 'external_ids,credits,watch/providers';
const APPEND_TO_RESPONSE = 'external_ids,credits';
protected Client $client;
protected MovieRepository $movieRepository;
@@ -139,6 +141,50 @@ class TmdbClient
return $this->parseListOfResults($results);
}
public function discoverMovies(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map
{
if (!empty($genres) && $genres[0] instanceof TmdbMovieGenre) {
$genres = array_map(fn ($genre) => $genre->value, $genres);
}
$results = $this->discoverRepository->getApi()->discoverMovies([
'page' => $page,
'page_size' => $pageSize,
'with_genres' => implode(',', $genres),
'with_original_language' => $this->originalLanguage,
'append_to_response' => static::APPEND_TO_RESPONSE,
]);
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::Movie->value;
return $result;
});
return $this->parseListOfResults(
$results,
);
}
public function discoverTvshows(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map
{
if (!empty($genres) && $genres[0] instanceof TmdbTvShowGenre) {
$genres = array_map(fn ($genre) => $genre->value, $genres);
}
$results = $this->discoverRepository->getApi()->discoverTv([
'page' => $page,
'page_size' => $pageSize,
'with_genres' => implode(',', $genres),
'with_original_language' => $this->originalLanguage,
'append_to_response' => static::APPEND_TO_RESPONSE,
]);
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::TvShow->value;
return $result;
});
return $this->parseListOfResults(
$results,
);
}
public function movieDetails(string $imdbId): ?TmdbResult
{
$tmdbId = $this->findByImdbId($imdbId)['id'];
@@ -201,6 +247,14 @@ class TmdbClient
);
}
public function watchProviders(string $tmdbId, string $mediaType): Map
{
$results = $this->repos[$mediaType]->getApi()->getWatchProviders($tmdbId)['results']['US']['flatrate'];
return Map::from($results)->map(function ($result) {
return $this->serializer->denormalize($result, WatchProviderDto::class);
});
}
public function popularMovies(int $resultCount = 6): Map
{
$results = $this->discoverRepository->getApi()->discoverMovies([

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Tmdb;
enum TmdbMovieGenre: int
{
case Action = 28;
case Adventure = 12;
case Animation = 16;
case Comedy = 35;
case Crime = 80;
case Documentary = 99;
case Drama = 18;
case Family = 10751;
case Fantasy = 14;
case History = 36;
case Horror = 27;
case Music = 10402;
case Mystery = 9648;
case Romance = 10749;
case ScienceFiction = 878;
case TvMovie = 10770;
case Thriller = 53;
case War = 10752;
case Western = 37;
}

View File

@@ -7,6 +7,7 @@ use App\Tmdb\Dto\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\Dto\WatchProviderDto;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\SerializedPath;
@@ -57,5 +58,6 @@ class TmdbResult
public ?int $runtime = null,
public ?int $numberSeasons = null,
public ?int $latestSeason = null,
public ?array $watchProviders = null
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Tmdb;
enum TmdbTvShowGenre: int
{
case ActionAndAdventure = 10759;
case Animation = 16;
case Comedy = 35;
case Crime = 80;
case Documentary = 99;
case Drama = 18;
case Family = 10751;
case Kids = 10762;
case Mystery = 9648;
case News = 10763;
case Reality = 10764;
case SciFiAndFantasy = 10765;
case Soap = 10766;
case Talk = 10767;
case WarAndPolitics = 10768;
case Western = 37;
}

View File

@@ -2,14 +2,25 @@
namespace App\Torrentio\Client;
use Aimeos\Map;
use App\Download\Framework\Repository\DownloadRepository;
use App\Torrentio\Result\ResultFactory;
use App\Torrentio\Exception\TorrentioRateLimitException;
use Psr\Log\LoggerInterface;
class Torrentio
{
private array $badDownloadUrls = [];
public function __construct(
private readonly HttpClient $client,
) {}
private readonly DownloadRepository $downloadRepository, private readonly LoggerInterface $logger,
) {
$badDownloadUrls = $this->downloadRepository->badDownloadUrls();
$this->badDownloadUrls = Map::from($badDownloadUrls)
->map(fn ($url) => $url['url'])
->toArray();
}
public function search(string $imdbCode, string $type, bool $parseResults = true): array
{
@@ -47,6 +58,16 @@ class Torrentio
continue;
}
$url = explode('/', $stream['url']);
$filename = urldecode(end($url));
$url[count($url) - 1] = $filename;
$url = implode('/', $url);
if (in_array($stream['url'], $this->badDownloadUrls)) {
$this->logger->warning($stream['url'] . ' was skipped because it was identified as a bad download url.');
continue;
}
if (
array_key_exists('behaviorHints', $stream) &&
array_key_exists('bingeGroup', $stream['behaviorHints'])

View File

@@ -2,6 +2,7 @@
namespace App\Torrentio\Framework\Controller;
use App\Base\ConfigResolver;
use App\Base\Service\Broadcaster;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
@@ -27,6 +28,7 @@ final class WebController extends AbstractController
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly ConfigResolver $configResolver,
private readonly Broadcaster $broadcaster,
) {}
@@ -40,10 +42,14 @@ final class WebController extends AbstractController
$input->imdbId
);
if (true === $this->configResolver->isTorrentioCacheEnabled()) {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getMovieOptionsHandler->handle($input->toCommand());
});
} else {
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
}
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
@@ -66,10 +72,14 @@ final class WebController extends AbstractController
);
try {
if (true === $this->configResolver->isTorrentioCacheEnabled()) {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getTvShowOptionsHandler->handle($input->toCommand());
});
} else {
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
}
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Twig\Components;
use Aimeos\Map;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class PosterContainer
{
public array|Map $media;
public string $mediaType;
public ?string $genreId = null;
public ?string $genre = null;
// Only show 6 results and a 'more' button
public bool $tease = true;
}

View File

@@ -20,6 +20,7 @@ class RegistrationFormType extends AbstractType
->add('plainPassword', PasswordType::class, [
// instead of being set onto the object directly,
// this is read and encoded in the controller
'label' => 'Password',
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{ pwa() }}
<title>{% block title %}Welcome!{% endblock %}</title>
<title>{% block title %}Torsearch{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">

View File

@@ -2,15 +2,14 @@
{% block body %}
<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">
<h3 class="text-2xl text-bold text-center text-gray-50">Oh crap!</h3>
</div>
<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">
<code class="language-plaintext">
{{ exception.message }}
</code>
</pre>
<p class="text-sm mb-1">
<code>{{ exception.file }}</code>
</p>
<pre class="bg-gray-800 text-white p-4 rounded-md overflow-x-auto"><code class="language-plaintext">{{ exception.message|trim }}</code></pre>
</div>
{% endblock %}

View File

@@ -2,16 +2,14 @@
class="alert alert-{{ type|default('success') }}"
role="alert"
>
<div class="flex justify-between items-center">
<div class="flex items-center">
<svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
</svg>
<h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3>
<twig:ux:icon name="ic:twotone-cancel" style="text-align:right" width="16.75px" height="16.75px" class="modal-close rounded-full align-end text-red-600 hover:text-red-700" />
<span class="sr-only">Info</span>
</div>
<twig:ux:icon name="ic:twotone-cancel" width="16.75px" height="16.75px" class="modal-close rounded-full align-end text-red-600 hover:text-red-700" />
</div>
<div class="mt-2 text-sm w-[300px] font-bold overflow-hidden text-wrap">
{{ message }}

View File

@@ -29,6 +29,15 @@
</a>
</li>
<li>
<a href="{{ path('app.discover') }}"
class="block rounded-lg
bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60
px-4 py-2 text-sm font-medium text-gray-50">
Discover
</a>
</li>
<li>
<a href="{{ path('app_user_preferences') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">

View File

@@ -1,14 +1,21 @@
<div{{ attributes }}>
{% if image != null and image != "https://image.tmdb.org/t/p/w500" %}
<a href="{{ path('app_search_result', {
mediaType: mediaType,
imdbId: imdbId
}) }}">
<img src="{{ preload(image) }}" class="w-full md:w-40 rounded-md" />
<img src="{{ preload(image) }}" class="w-full rounded-md" />
</a>
{% else %}
<div class="w-full md:w-32 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="16" name="hugeicons:loading-01" />
</div>
{% endif %}
<a href="{{ path('app_search_result', {
mediaType: mediaType,
imdbId: imdbId
}) }}">
<h3 class="text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
<h3 class="mt-2 text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
</a>
</div>

View File

@@ -0,0 +1,30 @@
<div{{ attributes.defaults(stimulus_controller('discover_media_results')) }} class="flex flex-col">
<div class="grid grid-cols-1 md:grid-cols-6 gap-4">
{% for i in range(0, media|length - 1) %}
{% if i > 5 and tease is true %}
{% set class_list = "hidden" %}
{% else %}
{% set class_list = "" %}
{% endif %}
{% set poster = media[i] %}
<twig:Poster data-discover-media-results-target="poster"
imdbId="{{ poster.imdbId }}"
tmdbId="{{ poster.tmdbId }}"
title="{{ poster.title }}"
description="{{ poster.description }}"
image="{{ poster.poster }}"
year="{{ poster.year }}"
mediaType="movies"
class="pb-2 w-full rounded-lg {{ class_list }}"
/>
{% endfor %}
</div>
{% if tease == true %}
<div class="inline-flex self-end text-white">
<button data-discover-media-results-target="moreBtn" data-action="click->discover-media-results#moreResults" href="#" class="underline">More</button>
<a data-discover-media-results-target="moreLink" href="{{ url('app.discover.browse', {mediaType: mediaType, page: 2, genreId: genreId}) }}" class="underline hidden">More &gt;</a>
</div>
{% endif %}
</div>

View File

@@ -1,7 +1,7 @@
<div{{ attributes }}>
<div class="p-4 flex flex-col md:flex-row gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
{% if poster != null and poster != "https://image.tmdb.org/t/p/w500" %}
<img class="w-full md:w-24 rounded-lg" src="{{ poster }}" />
<img class="w-full md:w-24 rounded-lg" src="{{ preload(poster) }}" />
{% else %}
<div class="w-full md:w-32 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="16" name="hugeicons:loading-01" />

View File

@@ -0,0 +1,17 @@
{% extends 'base.html.twig' %}
{% block title %}Discover {{ media_type|capitalize }} &mdash; {{ parent() }}{% endblock %}
{% block h2 %}Discover {{ media_type|capitalize }}{% endblock %}
{% block body %}
<div class="p-4 flex flex-col gap-4">
{% for genreTitle, genreId in genres %}
<twig:Turbo:Frame id="genre_{{ media_type }}_{{ genreId }}" src="{{ path('api.tmdb.genre', {
mediaType: media_type,
genreId: genreId,
block: 'genre_results',
target: 'genre_' ~ media_type~ '_' ~ genreId
}) }}">
</twig:Turbo:Frame>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'base.html.twig' %}
{% block title %}Discover {{ genre }} {{ media_type|capitalize }} &mdash; {{ parent() }}{% endblock %}
{% block h2 %}Discover {{ genre }} {{ media_type|capitalize }}{% endblock %}
{% block body %}
<div class="p-4 flex flex-col gap-4">
<twig:Card title="{{ genre }}" class="w-full">
<twig:PosterContainer tease="'false'" genreId="{{ genre_id }}" mediaType="{{ media_type }}" media="{{ media }}"></twig:PosterContainer>
</twig:Card>
</div>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% block watch_providers %}
{% if result.providers %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
<div class="flex flex-row justify-start items-end gap-1 mt-2">
{% for provider in result.providers %}
<a href="#">
<img class="w-10 h-10 rounded-lg" src="{{ provider.logo }}" alt="{{ provider.name }}" title="{{ provider.name }}" />
</a>
{% endfor %}
</div>
</template>
</turbo-stream>
{% endif %}
{% endblock %}
{% block genre_results %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
<twig:Card title="{{ result.result.genre }}" class="w-full">
<twig:PosterContainer genreId="{{ result.result.genre_id }}" mediaType="{{ result.result.media_type }}" media="{{ result.result.media }}"></twig:PosterContainer>
</twig:Card>
</template>
</turbo-stream>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends 'base.html.twig' %}
{% block title %}Discover &mdash; {{ parent() }}{% endblock %}
{% block h2 %}Discover New Media{% endblock %}
{% block body %}
<div class="p-4 flex flex-col gap-4">
<twig:Card title="Popular Movies" class="w-full">
<twig:PosterContainer mediaType="movies" media="{{ movies }}" />
</twig:Card>
<twig:Card title="Popular Shows" class="w-full">
<twig:PosterContainer mediaType="tvshows" media="{{ shows }}" />
</twig:Card>
</div>
{% endblock %}

View File

@@ -76,12 +76,13 @@
{% if results.media.genres != null %}
<div id="genres" class="text-gray-50 my-4">
{% for genre in results.media.genres %}
<small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small>
<a href="{{ url('app.discover.browse_genre', {mediaType: results.media.mediaType, genreId: genre.id}) }}" class="px-2 py-1 border border-orange-500 rounded-full text-sm">{{ genre }}</a>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col gap-2">
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-orange-500 rounded-lg text-white">
@@ -118,6 +119,14 @@
</span>
</div>
{% endif %}
<twig:Turbo:Frame id="watch_providers_frame" src="{{ path('api.tmdb.watch_providers', {
mediaType: results.media.mediaType,
tmdbId: results.media.tmdbId,
block: 'watch_providers',
target: 'watch_providers_frame'
}) }}">
</twig:Turbo:Frame>
</div>
</div>
</div>

View File

@@ -30,7 +30,7 @@
<div class="flex flex-row gap-2 mb-2">
<input type="hidden" name="movie_folder" id="movie_folder_hidden" value="0" />
<input type="checkbox" name="movie_folder" id="movie_folder" value="1" {{ downloadPreferences['movie_folder'].getPreferenceValue() == true ? 'checked' }} />
<label class="text-gray-50" for="movie_folder">Store movies in a new directory?</label>
<label class="text-gray-50" for="movie_folder">Create a new directory for each movie?</label>
</div>
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
</form>