Compare commits

...

85 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
Brock H Caldwell
d2eaccaf93 test: removes sentry release config 2025-11-08 18:44:00 -06:00
Brock H Caldwell
37e13347b2 feat(Download): adds streaming and local download options 2025-11-08 16:26:38 -06:00
Brock H Caldwell
7dd40b4525 feat: makes sentry more configurable 2025-11-08 14:38:55 -06:00
Brock H Caldwell
2315b995e0 fix: adds test exception 2025-11-08 12:40:09 -06:00
Brock H Caldwell
83401f2a7a fix: disable sentry traces 2025-11-08 12:39:01 -06:00
Brock H Caldwell
50bcb4e1df feat(Tmdb): adds pre-filter option to filter by media language 2025-11-08 12:34:53 -06:00
Brock H Caldwell
ea569b480d fix: download season handler 2025-11-07 23:24:59 -06:00
Brock H Caldwell
50ec0c1d6f qol(Monitors): adds back buttons, provides more data about child monitors 2025-11-07 21:24:27 -06:00
Brock H Caldwell
4ae70115b5 feat: additional info displayed on child monitor page 2025-11-07 12:59:24 -06:00
Brock H Caldwell
f4982af991 feat: landing page for show monitors 2025-11-06 15:26:57 -06:00
Brock H Caldwell
f253b33910 feat: shows monitor poster on modal 2025-11-06 15:16:59 -06:00
Brock H Caldwell
ec0d2a198c feat: shows monitor air date 2025-11-05 23:49:13 -06:00
Brock H Caldwell
1f1c6f775f feat: adds poster to monitors & ical 2025-11-05 23:42:40 -06:00
Brock H Caldwell
cd14a197aa feat: stores poster with tv show monitors 2025-11-05 23:21:15 -06:00
Brock H Caldwell
a9031df3c3 feat: only shows top level monitors on dashboard and children on dedicated page 2025-11-05 22:35:12 -06:00
Brock H Caldwell
55ab9d840e feat: adds page to list child monitors 2025-11-05 22:19:11 -06:00
Brock H Caldwell
3001e85715 fix: missing placeholders for n/a monitor attributes 2025-11-05 20:48:55 -06:00
Brock H Caldwell
f4125cc37c fix: sentry release 2025-11-05 20:41:11 -06:00
Brock H Caldwell
a3408d9852 fix: include release with sentry 2025-11-05 20:31:43 -06:00
Brock H Caldwell
0048423a46 fix: sets tracing sample to 1 2025-11-05 14:13:14 -06:00
Brock H Caldwell
2468e4d5b6 fix: adds more sentry config 2025-11-05 11:35:19 -06:00
Brock H Caldwell
445224d368 fix: error twig template 2025-11-05 11:23:57 -06:00
Brock H Caldwell
9a660279be fix: sentry 2025-11-05 00:12:32 -06:00
Brock H Caldwell
c1adedf74d feat: sentry integration 2025-11-04 23:53:31 -06:00
Brock H Caldwell
9a0e7fce26 fix(Scheduler): copy from app image 2025-11-04 20:08:43 -06:00
Brock H Caldwell
d90b4d7863 fix(Worker): pulls /app from app image again 2025-11-04 20:03:27 -06:00
Brock H Caldwell
2860d2e949 fix(Worker): installs more php mods 2025-11-04 19:27:50 -06:00
Brock H Caldwell
ad2bbfd48c fix: install php-dom 2025-11-04 14:48:39 -06:00
Brock H Caldwell
5e306c6740 fix: adds php84-xml 2025-11-04 14:24:42 -06:00
Brock H Caldwell
56129de3f9 fix: uses correct command 2025-11-04 13:50:27 -06:00
Brock H Caldwell
22b2b46da5 fix: disable multiplatform builds 2025-11-04 13:23:35 -06:00
Brock H Caldwell
7cc48ffc73 chore: docker cleanup/refactoring 2025-11-04 12:55:19 -06:00
Brock H Caldwell
adb79db8f5 feat: allows configuring the worker service and processes via command options and env vars 2025-11-04 12:19:37 -06:00
Brock H Caldwell
9ca87af938 wip: generates supervisord config at runtime 2025-11-04 09:54:41 -06:00
Brock H Caldwell
b01840b111 fix: adds process for async transport to worker 2025-11-03 16:35:26 -06:00
Brock H Caldwell
1759b6dfdc fix: send monitors to new transport 2025-11-03 14:45:29 -06:00
Brock H Caldwell
2d3bc35e45 fix: incorrect transport for scheduler 2025-11-02 23:04:51 -06:00
Brock H Caldwell
69f07b57ce fix(worker): removes unnecessary arguments passed to container 2025-11-02 22:51:13 -06:00
Brock H Caldwell
b86028acee wip: uses supervisord for worker and scheduler 2025-11-02 21:29:48 -06:00
Brock H Caldwell
b67781fe23 fix: adds created/updated at to event log 2025-11-02 16:16:19 -06:00
Brock H Caldwell
6920b82684 fix(migration): tries to set default value of null for created_at 2025-11-02 13:17:48 -06:00
Brock H Caldwell
0f291aa147 fix(migrations): migrations complains about sessions table not existing 2025-11-02 09:58:57 -06:00
Brock H Caldwell
c4160081a1 feat: custom 500 page 2025-11-02 00:13:59 -05:00
Brock H Caldwell
5d414590cb feat: logs monitor events 2025-11-02 00:07:31 -05:00
Brock H Caldwell
d28b743684 feat: logs download events 2025-11-01 23:58:15 -05:00
Brock H Caldwell
c4e8e9b35e feat: custom 404 page 2025-11-01 23:50:04 -05:00
Brock H Caldwell
6fbd56c952 task: adds event log module 2025-11-01 15:27:21 -05:00
Brock H Caldwell
f5732fbcea task: adds psalm.xml 2025-11-01 14:06:07 -05:00
Brock H Caldwell
0f095ab7f8 fix: bad parsing of torrentio episode results 2025-10-25 12:59:15 -05:00
Brock H Caldwell
2e376337fa fix: rekeys season list 2025-10-19 21:37:49 -05:00
128 changed files with 3874 additions and 508 deletions

View File

@@ -12,3 +12,4 @@ phpstan.dist.neon
phpunit.dist.xml
nomad.deploy.hcl
deployment.properties
var/download/*

13
.env
View File

@@ -58,3 +58,16 @@ OIDC_BYPASS_FORM_LOGIN=false
NOTIFICATION_TRANSPORT=
NTFY_DSN=
###> sentry/sentry-symfony ###
SENTRY_DSN=
SENTRY_JS_URL=
###< sentry/sentry-symfony ###
# TMDB 'with_original_language' option
# - 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

@@ -67,6 +67,9 @@ export default class MonitorListRow extends HTMLTableRowElement {
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Downloaded At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Air Date</div>
</th>
</tr>
</thead>
<tbody>
@@ -107,6 +110,9 @@ export default class MonitorListRow extends HTMLTableRowElement {
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('downloaded-at') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('air-date') ?? "-"}</div>
</td>
</tr>
</tbody>
</table>

View File

@@ -28,6 +28,9 @@ export default class PreviewContentDialog extends HTMLDialogElement {
}
display({ heading, content }) {
if (this.hasAttribute('mdWidth')) {
this.style.width = this.getAttribute('mdWidth');
}
this.setHeading(heading);
this.setContent(content);
this.showModal();

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

@@ -23,7 +23,12 @@ export default class extends Controller {
}
if (null !== content && undefined !== content && "" !== content) {
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
if (['', null, undefined].includes(monitor.getAttribute('parent-id'))) {
window.location.href = `/monitors/${monitor.getAttribute('monitor-id')}`;
} else {
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
}
}
})
})

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2zm11.5 5.175l3.5 1.556V4.269l-3.5 1.556zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"/></svg>

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M4.406 1.342A5.53 5.53 0 0 1 8 0c2.69 0 4.923 2 5.166 4.579C14.758 4.804 16 6.137 16 7.773C16 9.569 14.502 11 12.687 11H10a.5.5 0 0 1 0-1h2.688C13.979 10 15 8.988 15 7.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 2.825 10.328 1 8 1a4.53 4.53 0 0 0-2.941 1.1c-.757.652-1.153 1.438-1.153 2.055v.448l-.445.049C2.064 4.805 1 5.952 1 7.318C1 8.785 2.23 10 3.781 10H6a.5.5 0 0 1 0 1H3.781C1.708 11 0 9.366 0 7.318c0-1.763 1.266-3.223 2.942-3.593c.143-.863.698-1.723 1.464-2.383"/><path d="M7.646 15.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 14.293V5.5a.5.5 0 0 0-1 0v8.793l-2.146-2.147a.5.5 0 0 0-.708.708z"/></g></svg>

After

Width:  |  Height:  |  Size: 720 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/></g></svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path fill="currentColor" d="M2048 1088H250l787 787l-90 90L6 1024L947 83l90 90l-787 787h1798z"/></svg>

After

Width:  |  Height:  |  Size: 167 B

View File

@@ -1,14 +1,22 @@
# torsearch-app is built from this base
export APP_FRANKENPHP_TAG=php8.4
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 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 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 code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG}
docker push code.caldwell.digital/home/torsearch-base:latest
# torsearch-worker & torsearch-scheduler are built from this base
export WORKER_FRANKENPHP_TAG=php8.4-alpine
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 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 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 code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG}
docker push code.caldwell.digital/home/torsearch-base-worker:latest
# torsearch-worker-supervisord
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 build -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker-supervisord:latest --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" .
docker push code.caldwell.digital/home/torsearch-base-worker-supervisord:latest

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
@@ -34,10 +36,8 @@ services:
worker:
build:
dockerfile: docker/Dockerfile.base.worker
dockerfile: docker/Dockerfile.worker
context: .
args:
FRANKENPHP_TAG: php8.4-alpine
restart: unless-stopped
volumes:
- $PWD:/app
@@ -45,22 +45,20 @@ services:
tty: true
environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume async --time-limit=3600 -vv
scheduler:
build:
dockerfile: docker/Dockerfile.base.worker
dockerfile: docker/Dockerfile.scheduler
context: .
args:
FRANKENPHP_TAG: php8.4-alpine
restart: unless-stopped
volumes:
- $PWD:/app
- $PWD/var/download:/var/download
tty: true
environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume scheduler_monitor -vv
tty: true
WORKER_MONITOR: 2
redis:
@@ -82,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
@@ -96,6 +94,8 @@ services:
image: adminer
ports:
- "8081:8080"
environment:
ADMINER_DEFAULT_SERVER: database
volumes:

View File

@@ -29,6 +29,7 @@
"phpstan/phpdoc-parser": "^2.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"sentry/sentry-symfony": "^5.6",
"spatie/icalendar-generator": "^3.0",
"spomky-labs/pwa-bundle": "^1.2",
"stof/doctrine-extensions-bundle": "^1.14",
@@ -46,6 +47,7 @@
"symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.3.*",
"symfony/monolog-bundle": "^3.10",
"symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*",
"symfony/object-mapper": "7.3.*",

601
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e055bbbbe5836c92bb147b6dbb1d1d46",
"content-hash": "952def2c32d975032ac0061e5aa37319",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -2552,6 +2552,66 @@
],
"time": "2025-03-27T12:30:47+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.5.0",
@@ -2681,6 +2741,109 @@
},
"time": "2025-02-06T08:48:15+00:00"
},
{
"name": "monolog/monolog",
"version": "3.9.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
},
"provide": {
"psr/log-implementation": "3.0.0"
},
"require-dev": {
"aws/aws-sdk-php": "^3.0",
"doctrine/couchdb": "~1.0@dev",
"elasticsearch/elasticsearch": "^7 || ^8",
"ext-json": "*",
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
"predis/predis": "^1.1 || ^2",
"rollbar/rollbar": "^4.0",
"ruflin/elastica": "^7 || ^8",
"symfony/mailer": "^5.4 || ^6",
"symfony/mime": "^5.4 || ^6"
},
"suggest": {
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
"ext-mbstring": "Allow to work properly with unicode symbols",
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
"ext-openssl": "Required to send log messages using SSL",
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
"rollbar/rollbar": "Allow sending log messages to Rollbar",
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Monolog\\": "src/Monolog"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
"homepage": "https://github.com/Seldaek/monolog",
"keywords": [
"log",
"logging",
"psr-3"
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.9.0"
},
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2025-03-24T10:02:05+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.10.0",
@@ -4757,6 +4920,196 @@
],
"time": "2023-12-12T12:06:11+00:00"
},
{
"name": "sentry/sentry",
"version": "4.17.1",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "5c696b8de57e841a2bf3b6f6eecfd99acfdda80c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/5c696b8de57e841a2bf3b6f6eecfd99acfdda80c",
"reference": "5c696b8de57e841a2bf3b6f6eecfd99acfdda80c",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"monolog/monolog": "^1.6|^2.0|^3.0",
"phpbench/phpbench": "^1.0",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5|^9.6",
"vimeo/psalm": "^4.17"
},
"suggest": {
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Sentry\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "PHP SDK for Sentry (http://sentry.io)",
"homepage": "http://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.17.1"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2025-10-23T15:19:24+00:00"
},
{
"name": "sentry/sentry-symfony",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-symfony.git",
"reference": "9867751f5091b55d7e3a223f48d88e228132e073"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/9867751f5091b55d7e3a223f48d88e228132e073",
"reference": "9867751f5091b55d7e3a223f48d88e228132e073",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.1.1",
"jean85/pretty-package-versions": "^1.5||^2.0",
"php": "^7.2||^8.0",
"sentry/sentry": "^4.16.0",
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/polyfill-php80": "^1.22",
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0"
},
"require-dev": {
"doctrine/dbal": "^2.13||^3.3||^4.0",
"doctrine/doctrine-bundle": "^2.6",
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
"masterminds/html5": "^2.8",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "1.12.5",
"phpstan/phpstan-phpunit": "1.4.0",
"phpstan/phpstan-symfony": "1.4.10",
"phpunit/phpunit": "^8.5.40||^9.6.21",
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/monolog-bundle": "^3.4",
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0",
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0",
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0",
"vimeo/psalm": "^4.3||^5.16.0"
},
"suggest": {
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
},
"type": "symfony-bundle",
"autoload": {
"files": [
"src/aliases.php"
],
"psr-4": {
"Sentry\\SentryBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Symfony integration for Sentry (http://getsentry.com)",
"homepage": "http://getsentry.com",
"keywords": [
"errors",
"logging",
"sentry",
"symfony"
],
"support": {
"issues": "https://github.com/getsentry/sentry-symfony/issues",
"source": "https://github.com/getsentry/sentry-symfony/tree/5.6.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2025-09-24T13:41:01+00:00"
},
{
"name": "spatie/icalendar-generator",
"version": "3.0.0",
@@ -7637,6 +7990,169 @@
],
"time": "2025-02-19T08:51:26+00:00"
},
{
"name": "symfony/monolog-bridge",
"version": "v7.3.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bridge.git",
"reference": "c66a65049c75f3ddf03d73c8c9ed61405779ce47"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c66a65049c75f3ddf03d73c8c9ed61405779ce47",
"reference": "c66a65049c75f3ddf03d73c8c9ed61405779ce47",
"shasum": ""
},
"require": {
"monolog/monolog": "^3",
"php": ">=8.2",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
"symfony/console": "<6.4",
"symfony/http-foundation": "<6.4",
"symfony/security-core": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0",
"symfony/http-client": "^6.4|^7.0",
"symfony/mailer": "^6.4|^7.0",
"symfony/messenger": "^6.4|^7.0",
"symfony/mime": "^6.4|^7.0",
"symfony/security-core": "^6.4|^7.0",
"symfony/var-dumper": "^6.4|^7.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Monolog\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Monolog with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/monolog-bridge/tree/v7.3.5"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-10-14T19:16:15+00:00"
},
{
"name": "symfony/monolog-bundle",
"version": "v3.10.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bundle.git",
"reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
"reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
"shasum": ""
},
"require": {
"monolog/monolog": "^1.25.1 || ^2.0 || ^3.0",
"php": ">=7.2.5",
"symfony/config": "^5.4 || ^6.0 || ^7.0",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0",
"symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0"
},
"require-dev": {
"symfony/console": "^5.4 || ^6.0 || ^7.0",
"symfony/phpunit-bridge": "^6.3 || ^7.0",
"symfony/yaml": "^5.4 || ^6.0 || ^7.0"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MonologBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony MonologBundle",
"homepage": "https://symfony.com",
"keywords": [
"log",
"logging"
],
"support": {
"issues": "https://github.com/symfony/monolog-bundle/issues",
"source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2023-11-06T17:08:13+00:00"
},
{
"name": "symfony/notifier",
"version": "v7.3.0",
@@ -8777,6 +9293,89 @@
],
"time": "2025-04-04T13:12:05+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f",
"reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/http-message": "^1.0|^2.0",
"symfony/http-foundation": "^6.4|^7.0"
},
"conflict": {
"php-http/discovery": "<1.15",
"symfony/http-kernel": "<6.4"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
"symfony/browser-kit": "^6.4|^7.0",
"symfony/config": "^6.4|^7.0",
"symfony/event-dispatcher": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "PSR HTTP message bridge",
"homepage": "https://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"support": {
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-26T08:57:56+00:00"
},
{
"name": "symfony/routing",
"version": "v7.3.0",

View File

@@ -23,4 +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, 'dev' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
];

View File

@@ -41,7 +41,13 @@ doctrine:
is_bundle: false
dir: '%kernel.project_dir%/src/Monitor/Framework/Entity'
prefix: 'App\Monitor\Framework\Entity'
alias: Download
alias: Monitor
EventLog:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/EventLog/Framework/Entity'
prefix: 'App\EventLog\Framework\Entity'
alias: EventLog
controller_resolver:
auto_mapping: false

View File

@@ -15,6 +15,26 @@ framework:
max_retries: 1
multiplier: 1
download:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
queue_name: download
retry_strategy:
max_retries: 3
multiplier: 1
monitor:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
queue_name: monitor
retry_strategy:
max_retries: 1
multiplier: 1
media_cache:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
@@ -36,12 +56,12 @@ framework:
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
'App\Download\Action\Command\DownloadMediaCommand': async
'App\Download\Action\Command\DownloadMediaCommand': download
'App\Download\Action\Command\DownloadSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async
'App\Monitor\Action\Command\MonitorMovieCommand': async
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': monitor
'App\Monitor\Action\Command\MonitorTvSeasonCommand': monitor
'App\Monitor\Action\Command\MonitorTvShowCommand': monitor
'App\Monitor\Action\Command\MonitorMovieCommand': monitor
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': media_cache
# when@test:

View File

@@ -0,0 +1,63 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
nested:
type: stream
path: php://stderr
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
channels: [deprecation]
path: php://stderr
formatter: monolog.formatter.json

View File

@@ -0,0 +1,40 @@
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
tracing:
enabled: true
dbal: # DB queries
enabled: true
cache: # cache pools
enabled: true
twig: # templating engine
enabled: true
services:
# (Optionally) Configure the breadcrumb handler as a service (needed for the breadcrumb Monolog handler)
Sentry\Monolog\BreadcrumbHandler:
arguments:
- '@Sentry\State\HubInterface'
- !php/const Monolog\Logger::INFO # Configures the level of messages to capture as breadcrumbs
monolog:
handlers:
# (Optionally) Register the breadcrumb handler as a Monolog handler
sentry_breadcrumbs:
type: service
name: sentry_breadcrumbs
id: Sentry\Monolog\BreadcrumbHandler
# Register the handler as a Monolog handler to capture messages as events
sentry:
type: sentry
level: !php/const Monolog\Logger::ERROR # Configures the level of messages to capture as events
hub_id: Sentry\State\HubInterface
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

View File

@@ -1,6 +1,7 @@
twig:
globals:
version: '%app.version%'
sentry_javascript_url: '%sentry.javascript_url%'
file_name_pattern: '*.twig'
date:
format: 'm/d/Y'

View File

@@ -6,6 +6,22 @@ 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/
namespace: App\EventLog\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersLibrary:
resource:
path: ../src/Library/Framework/Controller/

View File

@@ -48,6 +48,13 @@ parameters:
notification.transport: '%env(NOTIFICATION_TRANSPORT)%'
notification.ntfy.dsn: '%env(NTFY_DSN)%'
# Sentry
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,57 +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
depends_on:
- database
worker:
image: registry.caldwell.digital/home/torsearch-worker:${TAG}
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv --time-limit=3600 --limit=10
deploy:
replicas: 2
depends_on:
- app
scheduler:
image: registry.caldwell.digital/home/torsearch-scheduler:${TAG}
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
depends_on:
- app
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
volumes:
mysql:
mercure_config:
mercure_data:
redis_data:

View File

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

@@ -1,19 +1,40 @@
ARG FRANKENPHP_TAG
#####
# This version of Torsearch runs the scheduler, downloader, and worker
# in a single container. Each process is managerd by supervisord
# and can be configured via environment variables and more
# than one of these containers cans till be run.
#####
ARG ALPINE_VERSION="3.22"
FROM alpine:${ALPINE_VERSION} AS build_stage
FROM dunglas/frankenphp:${FRANKENPHP_TAG}
RUN apk add --no-cache \
curl \
php84 \
php84-ctype \
php84-curl \
php84-dom \
php84-fileinfo \
php84-fpm \
php84-gd \
php84-mbstring \
php84-mysqli \
php84-opcache \
php84-openssl \
php84-pdo_mysql \
php84-tokenizer \
php84-xml \
supervisor
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
RUN ln -s /usr/bin/php84 /usr/bin/php
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN mkdir -p /etc/supervisor/conf.d
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
# We start supervisord and supervisord starts
# respective service in the configuration file.
ENTRYPOINT ["/app/bin/console", "init:worker"]
# Message transports can be enabled by passing them as command options.
# Inlcluding a number with the option will start that amount of processes
# for the transport. Not supplying a number will default to 1.
# --download --monitor OR --download 2 --monitor
RUN apk add --no-cache wget

View File

@@ -1,10 +1,27 @@
FROM code.caldwell.digital/home/torsearch-base-worker:php8.4-alpine
###
# This version of Torsearch can run the scheduler, downloader, and
# worker in one container. Each process is managerd by supervisord
# and can be configured via environment variables, and more than
# one of these containers can still be run.
###
ARG APP_VERSION="0.0.0-dev"
ENV APP_VERSION="${APP_VERSION}"
# Default to latest, but should pass in a version
ARG APP_VERSION="latest"
COPY . /app
# Set aside the actual app image so we can copy the app from there
FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS app_image
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ]
# Start with our base worker image
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0
# 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
# To retain backwards compatibility, default to async & download transports
CMD [ "--monitor" ]

View File

@@ -1,10 +1,27 @@
FROM code.caldwell.digital/home/torsearch-base-worker:php8.4-alpine
###
# This version of Torsearch can run the scheduler, downloader, and
# worker in one container. Each process is managerd by supervisord
# and can be configured via environment variables, and more than
# one of these containers can still be run.
###
ARG APP_VERSION="0.0.0-dev"
ENV APP_VERSION="${APP_VERSION}"
# Default to latest, but should pass in a version
ARG APP_VERSION="latest"
COPY . /app
# Set aside the actual app image so we can copy the app from there
FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS app_image
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]
# Start with our base worker image
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0
# 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
# To retain backwards compatibility, default to async & download transports
CMD [ "--async", "--download" ]

10
docker/worker/async.conf Normal file
View File

@@ -0,0 +1,10 @@
[program:torsearch-worker]
command=/app/bin/console messenger:consume async -vv --time-limit 3600
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
startretries=3
process_name=%(program_name)s_%(process_num)02d

View File

@@ -0,0 +1,10 @@
[program:torsearch-downloader]
command=/app/bin/console messenger:consume download -vv --time-limit 3600
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
startretries=3
process_name=%(program_name)s_%(process_num)02d

View File

@@ -0,0 +1,10 @@
[program:torsearch-scheduler]
command=/app/bin/console messenger:consume scheduler_monitor monitor -vv --time-limit 21600
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
startretries=3
process_name=%(program_name)s_%(process_num)02d

View File

@@ -0,0 +1,5 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid

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

@@ -21,10 +21,7 @@ final class Version20250831013403 extends AbstractMigration
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE sessions
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DATETIME NOT NULL
DROP TABLE IF EXISTS sessions
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(1024) DEFAULT NULL
@@ -37,9 +34,6 @@ final class Version20250831013403 extends AbstractMigration
$this->addSql(<<<'SQL'
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
SQL);

View File

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

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251101211617 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'
CREATE TABLE event_log (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) DEFAULT NULL, message LONGTEXT DEFAULT NULL, context JSON DEFAULT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE event_log
SQL);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251102004627 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 event_log ADD user_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE event_log ADD CONSTRAINT FK_9EF0AD16A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_9EF0AD16A76ED395 ON event_log (user_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE event_log DROP FOREIGN KEY FK_9EF0AD16A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_9EF0AD16A76ED395 ON event_log
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE event_log DROP user_id
SQL);
}
}

View File

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

View File

@@ -0,0 +1,34 @@
<?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 Version20251106045808 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD poster 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 monitor DROP poster
SQL);
}
}

View File

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

17
psalm.xml Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0"?>
<psalm
errorLevel="7"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
findUnusedBaselineEntry="true"
findUnusedCode="true"
>
<projectFiles>
<directory name="src" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
</psalm>

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,
@@ -53,6 +55,18 @@ final class ConfigResolver
#[Autowire(param: 'notification.ntfy.dsn')]
private ?string $notificationNtfyDsn = null,
#[Autowire(param: 'sentry.dsn')]
private ?string $sentryDsn = null,
#[Autowire(param: 'sentry.environment')]
private ?string $sentryEnvironment = null,
#[Autowire(param: 'sentry.javascript_url')]
private ?string $sentryJavascriptUrl = null,
#[Autowire(param: 'torrentio.cache_results')]
private ?bool $torrentioCacheResults = null,
) {}
public function validate(): bool
@@ -101,6 +115,11 @@ final class ConfigResolver
return $this->authOidcBypassFormLogin;
}
public function isTorrentioCacheEnabled(): bool
{
return $this->torrentioCacheResults;
}
public function getAppVersion(): AppVersionDto
{
$matches = [];
@@ -120,4 +139,13 @@ final class ConfigResolver
]
];
}
public function getSentryConfig()
{
return [
'dsn' => $this->sentryDsn,
'environment' => $this->sentryEnvironment,
'javascript_url' => $this->sentryJavascriptUrl,
];
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Base\Framework\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Twig\Environment;
class InitWorker extends Command
{
public function __construct(
private Environment $twig,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setName('init:worker')
->addOption('async', null, InputOption::VALUE_OPTIONAL, 'Run the async worker.',false)
->addOption('download', null, InputOption::VALUE_OPTIONAL, 'Run the download worker.', false)
->addOption('monitor', null, InputOption::VALUE_OPTIONAL, 'Run the monitor worker.', false)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$configFile = $this->twig->render("config/supervisord.conf.twig", []);
if ($this->optionExists($input, $output, 'async')) {
$configFile .= $this->twig->render("config/async.conf.twig", [
'replicas' => $this->getOptionValue('async', $input),
]) . "\n\n";
}
if ($this->optionExists($input, $output, 'download')) {
$configFile .= $this->twig->render("config/download.conf.twig", [
'replicas' => $this->getOptionValue('download', $input),
]). "\n\n";
}
if ($this->optionExists($input, $output, 'monitor')) {
$configFile .= $this->twig->render("config/monitor.conf.twig", [
'replicas' => $this->getOptionValue('monitor', $input),
]). "\n\n";
}
if ("" !== $configFile) {
$output->writeln("[init:worker] Writing /etc/supervisor/conf.d/supervisord.conf");
file_put_contents("/etc/supervisor/conf.d/supervisord.conf", $configFile);
$output->writeln("[init:worker] Starting supervisord");
shell_exec("/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf");
return Command::SUCCESS;
}
$output->writeln("[init:worker] No workers selected. Exiting.");
return Command::FAILURE;
}
private function optionExists(InputInterface $input, OutputInterface $output, string $option): bool
{
if ($input->getOption($option) !== false) {
$value = $input->getOption($option) ?? 1;
$output->writeln("[init:worker] transport: $option // $value // input var");;
return true;
}
$optionKey = 'WORKER_' . strtoupper($option);
if (array_key_exists($optionKey, $_SERVER) && $_SERVER[$optionKey] !== null && $_SERVER[$optionKey] !== '') {
$output->writeln("[init:worker] transport: $option // $_SERVER[$optionKey] // env var");
return true;
}
return false;
}
private function getOptionValue(string $option, InputInterface $input): int
{
if ($input->getOption($option) !== false) {
return $input->getOption($option) ?? 1;
}
$optionKey = 'WORKER_' . strtoupper($option);
return $_SERVER[$optionKey];
}
}

View File

@@ -50,7 +50,7 @@ final class IndexController extends AbstractController
#[Route('/test')]
public function monitorTvShow(MonitorTvShowHandler $handler): Response
{
// $handler->handle(new MonitorTvShowCommand(82));
throw new \Exception('Test');
return $this->render('index/test.html.twig', []);
}
}

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

@@ -168,6 +168,37 @@ class MediaFiles
return false;
}
/**
* @param string $tvshowTitle
* @return array<SplFileInfo>|false
*/
public function tvshowExists(string $tvshowTitle): Map|false
{
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);
if ($existingEpisodes->isEmpty()) {
return false;
}
$episodes = new Map;
/** @var SplFileInfo $episode */
foreach ($existingEpisodes as $episode) {
$ptn = (object) (new PTN())->parse($episode->getFilename());
if (!property_exists($ptn, 'season') || !property_exists($ptn, 'episode')) {
continue;
}
$episodes->push($episode);
}
if ($episodes->count() > 0) {
return $episodes;
}
return false;
}
public function movieExists(string $title)
{
$filepath = $this->moviesPath . DIRECTORY_SEPARATOR . $title;

View File

@@ -33,7 +33,6 @@ class Paginator
public function paginate($query, int $page = 1, int $limit = 5): Paginator
{
$paginator = new OrmPaginator($query);
$paginator
->getQuery()
->setFirstResult($limit * ($page - 1))

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

@@ -4,17 +4,22 @@ namespace App\Download\Action\Handler;
use App\Download\Action\Command\DeleteDownloadCommand;
use App\Download\Action\Result\DeleteDownloadResult;
use App\Download\DownloadEvents;
use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Library\Action\Command\DeleteMediaFileCommand;
use App\Library\Action\Handler\DeleteMediaFileHandler;
use App\Monitor\MonitorEvents;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<DeleteDownloadCommand, DeleteDownloadResult> */
readonly class DeleteDownloadHandler implements HandlerInterface
{
public function __construct(
private MessageBusInterface $bus,
private DownloadRepository $downloadRepository,
private DeleteMediaFileHandler $deleteMediaFileHandler,
) {}
@@ -31,6 +36,13 @@ readonly class DeleteDownloadHandler implements HandlerInterface
}
$this->downloadRepository->delete($command->downloadId);
$this->bus->dispatch(new AddEventLogCommand(
$download->getUser(),
DownloadEvents::DOWNLOAD_DELETED->type(),
DownloadEvents::DOWNLOAD_DELETED->message(),
(array) $download
));
return new DeleteDownloadResult(
status: 200,
message: 'Success',

View File

@@ -2,30 +2,45 @@
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;
use App\User\Framework\Repository\UserRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */
readonly class DownloadMediaHandler implements HandlerInterface
{
public function __construct(
private MessageBusInterface $bus,
private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository, private Broadcaster $broadcaster,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$user = $this->userRepository->find($command->userId);
$this->bus->dispatch(new AddEventLogCommand(
$user,
DownloadEvents::DOWNLOAD_STARTED->type(),
DownloadEvents::DOWNLOAD_STARTED->message(),
(array) $command
));
if (null === $command->downloadId) {
$download = $this->downloadRepository->insert(
$this->userRepository->find($command->userId),
$user,
$command->url,
$command->title,
$command->filename,
@@ -37,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');
@@ -57,6 +82,39 @@ readonly class DownloadMediaHandler implements HandlerInterface
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);
}
$this->bus->dispatch(new AddEventLogCommand(
$user,
DownloadEvents::DOWNLOAD_FINISHED->type(),
DownloadEvents::DOWNLOAD_FINISHED->message(),
(array) $command
));
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;
@@ -26,13 +28,13 @@ use Symfony\Component\Messenger\MessageBusInterface;
readonly class DownloadSeasonHandler implements HandlerInterface
{
public function __construct(
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private TmdbClient $tmdb,
private MessageBusInterface $bus,
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private TmdbClient $tmdb,
private MessageBusInterface $bus,
private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private UserRepository $userRepository,
private UserRepository $userRepository, private DownloadRepository $downloadRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
@@ -46,14 +48,14 @@ readonly class DownloadSeasonHandler implements HandlerInterface
$downloadCommands = [];
foreach ($episodesInSeason as $episode) {
$this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode['episode_number']);
$this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode->episodeNumber);
$results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$series->tmdbId,
$command->imdbId,
$command->season,
$episode['episode_number']
$episode->episodeNumber
)
);
@@ -67,14 +69,17 @@ 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['episode_number']);
$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->episode
&& null !== $episode->season
)
->rekey(fn($episode) => $episode->episode);
$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

@@ -0,0 +1,59 @@
<?php
namespace App\Download\Action\Handler;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbClient;
use Carbon\Carbon;
use DateTimeImmutable;
use Psr\Log\LoggerInterface;
trait MonitorHandlerTrait
{
private MonitorRepository $monitorRepository;
private LoggerInterface $logger;
private TmdbClient $tmdb;
private function episodeReleasedAfterMonitorCreated(
string|DateTimeImmutable $monitorStartDate,
TmdbEpisodeDto $episodeInShow
): bool {
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
$episodeAirDate = Carbon::parse($episodeInShow->airDate);
return $episodeAirDate >= $monitorStartDate;
}
private function episodeExists(TmdbEpisodeDto $episodeInShow, Map $downloadedEpisodes): bool
{
return $downloadedEpisodes->filter(
fn(object $episode) => $episode->episode === $episodeInShow->episodeNumber
&& $episode->season === $episodeInShow->seasonNumber
)->count() > 0;
}
private function monitorExists(Monitor $monitor, TmdbEpisodeDto $episode): bool
{
return $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $episode->seasonNumber,
'episode' => $episode->episodeNumber,
'status' => ['New', 'Active', 'In Progress']
]) !== null;
}
private function refreshData(Monitor $monitor)
{
if (null === $monitor->getPoster()) {
$this->logger->info('> [MonitorTvShowHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
$poster = $this->tmdb->tvshowDetails($monitor->getImdbId())->poster;
if (null !== $poster && "" !== $poster) {
$monitor->setPoster($poster);
}
}
}
}

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

@@ -0,0 +1,34 @@
<?php
namespace App\Download;
enum DownloadEvents
{
case DOWNLOAD_ADDED;
case DOWNLOAD_STARTED;
case DOWNLOAD_FINISHED;
case DOWNLOAD_DELETED;
case DOWNLOAD_ERROR;
public function type(): string
{
return match ($this) {
self::DOWNLOAD_ADDED => 'download_added',
self::DOWNLOAD_STARTED => 'download_started',
self::DOWNLOAD_FINISHED => 'download_finished',
self::DOWNLOAD_DELETED => 'download_deleted',
self::DOWNLOAD_ERROR => 'download_error',
};
}
public function message(): string
{
return match ($this) {
self::DOWNLOAD_ADDED => 'A new download has been added.',
self::DOWNLOAD_STARTED => 'A download has started.',
self::DOWNLOAD_FINISHED => 'A download has finished.',
self::DOWNLOAD_DELETED => 'A download has been deleted.',
self::DOWNLOAD_ERROR => 'A download has encountered an error.',
};
}
}

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

@@ -4,9 +4,12 @@ namespace App\Download\Downloader;
use App\Base\Service\Broadcaster;
use App\Base\Service\MediaFiles;
use App\Download\DownloadEvents;
use App\Download\Framework\Entity\Download;
use App\EventLog\Action\Command\AddEventLogCommand;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
use Symfony\Contracts\Cache\CacheInterface;
@@ -17,6 +20,7 @@ class ProcessDownloader implements DownloaderInterface
* @var RedisAdapter $cache
*/
public function __construct(
private MessageBusInterface $bus,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private CacheInterface $cache,
@@ -88,6 +92,12 @@ class ProcessDownloader implements DownloaderInterface
}
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
$this->bus->dispatch(new AddEventLogCommand(
$downloadEntity->getUser()->getId(),
DownloadEvents::DOWNLOAD_ERROR->type(),
DownloadEvents::DOWNLOAD_ERROR->message() . ': ' . $exception->getMessage(),
(array) $downloadEntity
));
}
$this->entityManager->flush();

View File

@@ -11,8 +11,11 @@ use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Action\Input\DownloadSeasonInput;
use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput;
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;
@@ -20,9 +23,9 @@ use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly Broadcaster $broadcaster,
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly Broadcaster $broadcaster, private readonly RequestStack $requestStack,
) {}
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
@@ -40,6 +43,14 @@ 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(),
DownloadEvents::DOWNLOAD_ADDED->type(),
DownloadEvents::DOWNLOAD_ADDED->message(),
(array) $download
));
try {
$this->bus->dispatch($input->toCommand());

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

@@ -0,0 +1,17 @@
<?php
namespace App\EventLog\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/** @implements CommandInterface<AddEventLogCommand> */
class AddEventLogCommand implements CommandInterface
{
public function __construct(
public UserInterface $user,
public string $type,
public string $message,
public array $context,
) {}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\EventLog\Action\Handler;
use App\EventLog\Framework\Repository\EventLogRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\EventLog\Action\Result\AddEventLogResult;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/*** @implements HandlerInterface<AddEventLogCommand> */
class AddEventLogHandler implements HandlerInterface
{
public function __construct(
private EventLogRepository $repository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$eventLog = $this->repository->insert(
user: $command->user,
type: $command->type,
message: $command->message,
context: $command->context
);
return new AddEventLogResult($eventLog);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\EventLog\Action\Input;
use App\EventLog\Action\Command\AddEventLogCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<AddEventLogCommand> */
class AddEventLogInput implements InputInterface
{
public function __construct(
#[SourceQuery('type')]
#[SourceRequest('type')]
public string $type,
#[SourceQuery('message')]
#[SourceRequest('message')]
public string $message,
#[SourceQuery('context')]
#[SourceRequest('context')]
public array $context = []
){}
public function toCommand(): CommandInterface
{
return new AddEventLogCommand(
$this->type,
$this->message,
$this->context
);
}
}

View File

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

View File

@@ -0,0 +1,28 @@
<?php
namespace App\EventLog\Framework\Controller;
use App\Base\Service\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class EventLogController extends AbstractController
{
public function __construct(
private readonly Broadcaster $broadcaster,
) {}
#[Route('/alert', name: 'app_alert')]
public function index(): Response
{
$this->broadcaster->alert(
'Added to queue',
'This is a testy test!'
);
return $this->json([
'Success' => 'Published'
]);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\EventLog\Framework\Entity;
use App\EventLog\Framework\Repository\EventLogRepository;
use App\User\Framework\Entity\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
#[ORM\Entity(repositoryClass: EventLogRepository::class)]
class EventLog
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(nullable: true)]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $type = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $message = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $context = null;
#[ORM\ManyToOne(inversedBy: 'eventLogs')]
private ?User $user = null;
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(?string $type): self
{
$this->type = $type;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(?string $message): self
{
$this->message = $message;
return $this;
}
public function getContext(): ?array
{
return $this->context;
}
public function setContext(?array $context): self
{
$this->context = $context;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\EventLog\Framework\Repository;
use App\EventLog\Framework\Entity\EventLog;
use App\User\Framework\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EventLog>
*/
class EventLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EventLog::class);
}
public function insert(
User $user,
string $type,
string $message,
array $context = []
): EventLog {
$eventLog = new EventLog()
->setUser($user)
->setType($type)
->setMessage($message)
->setContext($context);
$this->getEntityManager()->persist($eventLog);
$this->getEntityManager()->flush();
return $eventLog;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Library\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<GetMediaFromLibraryCommand>
*/
class GetMediaFromLibraryCommand implements CommandInterface
{
public function __construct(
public ?int $userId = null,
public ?string $mediaType = null,
public ?string $imdbId = null,
public ?string $title = null,
public ?string $season = null,
public ?string $episode = null,
) {}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Library\Action\Handler;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Service\MediaFiles;
use App\Base\Util\PTN;
use App\Library\Action\Command\GetMediaFromLibraryCommand;
use App\Library\Action\Result\GetMediaFromLibraryResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Psr\Log\LoggerInterface;
/**
* @implements HandlerInterface<GetMediaFromLibraryCommand,GetMediaFromLibraryResult>
*/
class GetMediaInfoFromLibraryHandler implements HandlerInterface
{
public function __construct(
private readonly TmdbClient $tmdb,
private readonly MediaFiles $mediaFiles,
private readonly LoggerInterface $logger,
private readonly MonitorRepository $monitorRepository,
) {}
public function handle(C $command): R
{
$result = new GetMediaFromLibraryResult();
$tmdbResult = $this->fetchTmdbData($command->imdbId, $command->mediaType);
if (null === $tmdbResult) {
$this->logger->warning('[GetMediaInfoFromLibraryHandler] TMDb result was not found, this may lead to issues in the rest of the library search', (array) $command);
}
$this->setResultExists($tmdbResult->mediaType, $tmdbResult->title, $result);
if ($result->notExists()) {
return $result;
}
$this->parseFromTmdbResult($tmdbResult, $result);
if ($command->mediaType === MediaType::TvShow->value) {
$this->setEpisodes($tmdbResult, $result);
$this->setSeasons($tmdbResult, $result);
$this->setMonitors($command->userId, $command->imdbId, $result);
}
return $result;
}
private function fetchTmdbData(string $imdbId, string $mediaType): ?TmdbResult
{
return match($mediaType) {
MediaType::Movie->value => $this->tmdb->movieDetails($imdbId),
MediaType::TvShow->value => $this->tmdb->tvShowDetails($imdbId),
default => null,
};
}
private function setResultExists(string $mediaType, string $title, GetMediaFromLibraryResult $result): void
{
$fsResult = match($mediaType) {
MediaType::Movie->value => $this->mediaFiles->movieExists($title),
MediaType::TvShow->value => $this->mediaFiles->tvShowExists($title),
default => false,
};
if (false === $fsResult) {
$result->setExists(false);
} else {
$result->setExists(true);
}
}
public function parseFromTmdbResult(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
{
$result->setTitle($tmdbResult->title);
$result->setMediaType($tmdbResult->mediaType);
$result->setImdbId($tmdbResult->imdbId);
}
public function setEpisodes(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
{
$existingEpisodeFiles = $this->mediaFiles->tvshowExists($tmdbResult->title);
$existingEpisodeMap = [];
foreach ($existingEpisodeFiles as $file) {
/** @var \SplFileInfo $file */
$ptn = (object) new PTN()->parse($file->getBasename());
if (!array_key_exists($ptn->season, $existingEpisodeMap)) {
$existingEpisodeMap[$ptn->season] = [];
}
if (!in_array($ptn->episode, $existingEpisodeMap[$ptn->season])) {
$existingEpisodeMap[$ptn->season][] = $ptn->episode;
}
}
$existingEpisodes = [];
$missingEpisodes = [];
foreach ($tmdbResult->episodes as $season => $episodes) {
foreach ($episodes as $episode) {
if (array_key_exists($season, $existingEpisodeMap)) {
if (in_array($episode->episodeNumber, $existingEpisodeMap[$season])) {
$existingEpisodes[] = $episode;
} else {
$missingEpisodes[] = $episode;
}
} else {
$missingEpisodes[] = $episode;
}
}
}
$result->setEpisodes($existingEpisodes);
$result->setMissingEpisodes($missingEpisodes);
}
public function setSeasons(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
{
$existingEpisodeFiles = $this->mediaFiles->tvshowExists($tmdbResult->title);
$existingEpisodeMap = [];
foreach ($existingEpisodeFiles as $file) {
/** @var \SplFileInfo $file */
$ptn = (object) new PTN()->parse($file->getBasename());
$existingEpisodeMap[$ptn->season][] = $ptn->episode;
}
$existingFullSeasons = [];
$existingPartialSeasons = [];
$missingSeasons = [];
foreach ($existingEpisodeMap as $season => $episodes) {
if (count($tmdbResult->episodes[$season]) === count($episodes)) {
$existingFullSeasons[] = $season;
} elseif (count($episodes) > 0) {
$existingPartialSeasons[] = $season;
}
}
$seasons = array_keys($tmdbResult->episodes);
foreach ($seasons as $season) {
if (!in_array($season, $existingFullSeasons) && !in_array($season, $existingPartialSeasons)) {
$missingSeasons[] = $season;
}
}
$result->setSeasons($existingFullSeasons);
$result->setPartialSeasons($existingPartialSeasons);
$result->setMissingSeasons($missingSeasons);
}
public function setMonitors(int $userId, string $imdbId, GetMediaFromLibraryResult $result)
{
$result->setMonitorCount(
$this->monitorRepository->countUserChildrenByParentId($userId, $imdbId)
);
$result->setActiveMonitorCount(
$this->monitorRepository->countUserActiveChildrenByParentId($userId, $imdbId)
);
$result->setCompleteMonitorCount(
$this->monitorRepository->countUserCompleteChildrenByParentId($userId, $imdbId)
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Library\Action\Input;
use App\Library\Action\Command\GetMediaFromLibraryCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<GetMediaInfoFromLibraryInput, GetMediaFromLibraryCommand>
*/
class GetMediaInfoFromLibraryInput implements InputInterface
{
public function __construct(
#[SourceRequest('imdbId', nullify: true)]
public ?string $imdbId = null,
#[SourceRequest('title', nullify: true)]
public ?string $title = null,
#[SourceRequest('season', nullify: true)]
public ?string $season = null,
#[SourceRequest('episode', nullify: true)]
public ?string $episode = null,
) {}
public function toCommand(): C
{
if (null === $this->imdbId && null === $this->title) {
throw new \InvalidArgumentException('Either imdbId or title must be set', 400);
}
return new GetMediaFromLibraryCommand(
imdbId: $this->imdbId,
title: $this->title,
season: $this->season,
episode: $this->episode,
);
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Library\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class GetMediaFromLibraryResult implements ResultInterface
{
private bool $exists;
private ?string $title = null;
private ?string $imdbId = null;
private ?string $mediaType = null;
private ?array $episodes = null;
private ?array $missingEpisodes = null;
private ?array $seasons = null;
private ?array $partialSeasons = null;
private ?array $missingSeasons = null;
private ?int $monitorCount = null; // Monitor Repo
private ?int $activeMonitorCount = null; // Monitor Repo
private ?int $completeMonitorCount = null; // Monitor Repo
public function exists(): bool
{
return $this->exists;
}
public function notExists(): bool
{
return !$this->exists;
}
public function setExists(bool $exists): void
{
$this->exists = $exists;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): void
{
$this->title = $title;
}
public function getImdbId(): ?string
{
return $this->imdbId;
}
public function setImdbId(?string $imdbId): void
{
$this->imdbId = $imdbId;
}
public function getMediaType(): ?string
{
return $this->mediaType;
}
public function setMediaType(?string $mediaType): void
{
$this->mediaType = $mediaType;
}
public function getEpisodes(): ?array
{
return $this->episodes;
}
public function setEpisodes(?array $episodes): void
{
$this->episodes = $episodes;
}
public function getEpisodeCount(): ?int
{
return count($this->episodes);
}
public function getMissingEpisodes(): ?array
{
return $this->missingEpisodes;
}
public function setMissingEpisodes(?array $missingEpisodes): void
{
$this->missingEpisodes = $missingEpisodes;
}
public function getMissingEpisodeCount(): ?int
{
return count($this->missingEpisodes);
}
public function getSeasons(): ?array
{
return $this->seasons;
}
public function setSeasons(?array $seasons): void
{
$this->seasons = $seasons;
}
public function getSeasonCount(): ?int
{
return count($this->seasons);
}
public function getPartialSeasons(): ?array
{
return $this->partialSeasons;
}
public function setPartialSeasons(?array $partialSeasons): void
{
$this->partialSeasons = $partialSeasons;
}
public function getPartialSeasonCount(): ?int
{
return count($this->partialSeasons);
}
public function getMissingSeasons(): ?array
{
return $this->missingSeasons;
}
public function setMissingSeasons(?array $missingSeasons): void
{
$this->missingSeasons = $missingSeasons;
}
public function getMissingSeasonCount(): ?int
{
return count($this->missingSeasons);
}
public function getMonitorCount(): ?int
{
return $this->monitorCount;
}
public function setMonitorCount(?int $monitorCount): void
{
$this->monitorCount = $monitorCount;
}
public function getActiveMonitorCount(): ?int
{
return $this->activeMonitorCount;
}
public function setActiveMonitorCount(?int $activeMonitorCount): void
{
$this->activeMonitorCount = $activeMonitorCount;
}
public function getCompleteMonitorCount(): ?int
{
return $this->completeMonitorCount;
}
public function setCompleteMonitorCount(?int $completeMonitorCount): void
{
$this->completeMonitorCount = $completeMonitorCount;
}
}

View File

@@ -2,29 +2,38 @@
namespace App\Monitor\Action\Handler;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Monitor\Action\Command\AddMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\MonitorEvents;
use App\Tmdb\TmdbClient;
use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<AddMonitorCommand> */
readonly class AddMonitorHandler implements HandlerInterface
{
public function __construct(
private MessageBusInterface $bus,
private MonitorRepository $movieMonitorRepository,
private UserRepository $userRepository,
private TmdbClient $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$user = $this->userRepository->find($command->userId);
$poster = $this->getPoster($command->imdbId);
$monitor = (new Monitor())
->setUser($user)
->setPoster($poster)
->setTmdbId($command->tmdbId)
->setImdbId($command->imdbId)
->setTitle($command->title)
@@ -35,6 +44,13 @@ readonly class AddMonitorHandler implements HandlerInterface
->setSearchCount(0)
->setStatus('New');
$this->bus->dispatch(new AddEventLogCommand(
$user,
MonitorEvents::MONITOR_ADDED->type(),
MonitorEvents::MONITOR_ADDED->message(),
(array) $monitor
));
$this->movieMonitorRepository->getEntityManager()->persist($monitor);
$this->movieMonitorRepository->getEntityManager()->flush();
@@ -45,4 +61,10 @@ readonly class AddMonitorHandler implements HandlerInterface
]
);
}
private function getPoster(string $imdbId): ?string
{
$data = $this->tmdb->tvShowDetails($imdbId);
return $data->poster;
}
}

View File

@@ -2,22 +2,22 @@
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\AddMonitorCommand;
use App\Download\DownloadEvents;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Monitor\Action\Command\DeleteMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult;
use App\Monitor\Action\Result\DeleteMonitorResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable;
use App\Monitor\MonitorEvents;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<DeleteMonitorCommand, DeleteMonitorResult> */
readonly class DeleteMonitorHandler implements HandlerInterface
{
public function __construct(
private MessageBusInterface $bus,
private MonitorRepository $monitorRepository,
) {}
@@ -27,6 +27,13 @@ readonly class DeleteMonitorHandler implements HandlerInterface
$this->monitorRepository->getEntityManager()->remove($monitor);
$this->monitorRepository->getEntityManager()->flush();
$this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(),
MonitorEvents::MONITOR_DELETED->type(),
MonitorEvents::MONITOR_DELETED->message(),
(array) $monitor
));
return new DeleteMonitorResult(
status: 'OK',
result: [],

View File

@@ -6,9 +6,12 @@ use App\Base\Util\EpisodeId;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\MonitorEvents;
use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
@@ -41,6 +44,14 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
try {
$monitor = $this->monitorRepository->find($command->movieMonitorId);
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
$this->refreshData($monitor);
$this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(),
MonitorEvents::MONITOR_STARTED->type(),
MonitorEvents::MONITOR_STARTED->message(),
(array) $command
));
$episodeData = $this->tmdb->tvEpisodeDetails($monitor->getTmdbId(), $monitor->getImdbId(), $monitor->getSeason(), $monitor->getEpisode());
@@ -116,12 +127,25 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$this->logger->error('> [MonitorTvEpisodeHandler] ...Exception thrown: ' . $exception->getMessage());
$this->logger->error($exception->getMessage());
$monitor->setStatus('Active');
$this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(),
MonitorEvents::MONITOR_ERROR->type(),
MonitorEvents::MONITOR_ERROR->message() . ': ' . $exception->getMessage(),
(array) $monitor
));
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->monitorRepository->getEntityManager()->flush();
$this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(),
MonitorEvents::MONITOR_FINISHED->type(),
MonitorEvents::MONITOR_FINISHED->message(),
(array) $monitor
));
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
@@ -129,4 +153,15 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
]
);
}
private function refreshData(Monitor $monitor)
{
if (null === $monitor->getPoster()) {
$this->logger->info('> [MonitorTvEpisodeHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
$poster = $monitor->getParent()->getPoster();
if (null !== $poster && "" !== $poster) {
$monitor->setPoster($poster);
}
}
}
}

View File

@@ -37,6 +37,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
{
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
$monitor = $this->monitorRepository->find($command->monitorId);
$this->refreshData($monitor);
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
@@ -157,4 +158,15 @@ readonly class MonitorTvShowHandler implements HandlerInterface
'status' => ['New', 'Active', 'In Progress']
]) !== null;
}
private function refreshData(Monitor $monitor)
{
if (null === $monitor->getPoster()) {
$this->logger->info('> [MonitorTvShowHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
$poster = $this->tmdb->tvshowDetails($monitor->getImdbId())->poster;
if (null !== $poster && "" !== $poster) {
$monitor->setPoster($poster);
}
}
}
}

View File

@@ -98,6 +98,7 @@ class ApiController extends AbstractController
'allDay' => true,
'backgroundColor' => $eventColors[$monitor->getImdbId()],
'borderColor' => $eventColors[$monitor->getImdbId()],
'attachment' => $monitor->getPoster(),
];
});

View File

@@ -3,6 +3,7 @@
namespace App\Monitor\Framework\Controller;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User;
use Spatie\IcalendarGenerator\Components\Calendar;
@@ -27,10 +28,14 @@ class CalendarController extends AbstractController
->refreshInterval(10);
$monitors = $monitorRepository->whereAirDateNotNull();
$calendar->event(Map::from($monitors)->map(function ($monitor) {
return new Event($monitor->getTitle())
$calendar->event(Map::from($monitors)->map(function (Monitor $monitor) {
$event = new Event($monitor->getTitle())
->startsAt($monitor->getAirDate())
->fullDay();
if (null !== $monitor->getPoster()) {
$event->attachment($monitor->getPoster());
}
return $event;
})->toArray());
return new Response($calendar->get(), 200, [

View File

@@ -3,11 +3,17 @@
namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Library\Action\Command\GetMediaFromLibraryCommand;
use App\Library\Action\Handler\GetMediaInfoFromLibraryHandler;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Tmdb\TmdbClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -23,7 +29,7 @@ class WebController extends AbstractController
private readonly Environment $renderer,
) {}
#[Route('/monitors', name: 'app_monitors', methods: ['GET'])]
#[Route('/monitors', name: 'app.monitors', methods: ['GET'])]
public function addMonitor()
{
return $this->render('monitor/index.html.twig');
@@ -34,4 +40,29 @@ class WebController extends AbstractController
{
return $this->render('monitor/upcoming-episodes.html.twig');
}
#[Route('/monitors/{id}', name: 'app.monitor.view', methods: ['GET'])]
public function viewMonitor(Monitor $monitor, GetMediaInfoHandler $getMediaInfoHandler, GetMediaInfoFromLibraryHandler $handler)
{
$media = $getMediaInfoHandler->handle(
new GetMediaInfoCommand(
imdbId: $monitor->getImdbId(),
mediaType: 'tvshows',
)
);
$libraryResult = $handler->handle(
new GetMediaFromLibraryCommand(
$this->getUser()->getId(),
$media->media->mediaType,
$media->media->imdbId,
$media->media->title,
)
);
return $this->render('monitor/view.html.twig', [
'monitor' => $monitor,
'results' => $media,
'library' => $libraryResult
]);
}
}

View File

@@ -50,6 +50,9 @@ class Monitor
#[ORM\Column(nullable: true)]
private ?int $searchCount = null;
#[ORM\Column(nullable: true)]
private ?string $poster = null;
#[ORM\Column]
private bool $onlyFuture = true;
@@ -230,6 +233,17 @@ class Monitor
return $this;
}
public function getPoster(): ?string
{
return $this->poster;
}
public function setPoster(?string $poster): ?self
{
$this->poster = $poster;
return $this;
}
public function getParent(): ?self
{
return $this->parent;

View File

@@ -41,4 +41,83 @@ class MonitorRepository extends ServiceEntityRepository
->getQuery();
return $query->getResult();
}
public function getActiveUserMonitors()
{
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
->andWhere('m.parent IS NULL')
->setParameter('statuses', ['New', 'In Progress', 'Active'])
->setParameter('term', '%'.$this->term.'%')
->orderBy('m.id', 'DESC')
->getQuery()
);
}
public function getChildMonitorsByParentId(int $parentId)
{
return $this->asPaginator(
$this->monitorRepository->createQueryBuilder('m')
->andWhere("m.parent = :parentId")
->setParameter('parentId', $parentId)
->orderBy('m.id', 'DESC')
->getQuery()
);
}
public function getCompleteUserMonitors()
{
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status = :status')
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
->setParameter('status', 'Complete')
->setParameter('term', '%'.$this->term.'%')
->orderBy('m.id', 'DESC')
->getQuery()
);
}
public function countUserChildrenByParentId(int $userId, string $imdbId): ?int
{
return $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->andWhere('m.user = :user')
->andWhere('m.imdbId = :imdbId')
->setParameter('user', $userId)
->setParameter('imdbId', $imdbId)
->getQuery()
->getSingleScalarResult()
;
}
public function countUserActiveChildrenByParentId(int $userId, string $imdbId): ?int
{
return $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->andWhere('m.user = :user')
->andWhere('m.imdbId = :imdbId')
->andWhere('m.status IN (:statuses)')
->setParameter('user', $userId)
->setParameter('statuses', ['Active', 'New', 'In Progress'])
->setParameter('imdbId', $imdbId)
->getQuery()
->getSingleScalarResult()
;
}
public function countUserCompleteChildrenByParentId(int $userId, string $imdbId): ?int
{
return $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->andWhere('m.user = :user')
->andWhere('m.imdbId = :imdbId')
->andWhere('m.status IN (:statuses)')
->setParameter('user', $userId)
->setParameter('statuses', ['Complete'])
->setParameter('imdbId', $imdbId)
->getQuery()
->getSingleScalarResult()
;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Monitor;
enum MonitorEvents
{
case MONITOR_ADDED;
case MONITOR_STARTED;
case MONITOR_FINISHED;
case MONITOR_DELETED;
case MONITOR_ERROR;
public function type(): string
{
return match ($this) {
self::MONITOR_ADDED => 'monitor_added',
self::MONITOR_STARTED => 'monitor_started',
self::MONITOR_FINISHED => 'monitor_finished',
self::MONITOR_DELETED => 'monitor_deleted',
self::MONITOR_ERROR => 'monitor_error',
};
}
public function message(): string
{
return match ($this) {
self::MONITOR_ADDED => 'A new monitor has been added.',
self::MONITOR_STARTED => 'A monitor has started.',
self::MONITOR_FINISHED => 'A monitor has finished.',
self::MONITOR_DELETED => 'A monitor has been deleted',
self::MONITOR_ERROR => 'A monitor has encountered an error.',
};
}
}

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;
@@ -19,6 +21,7 @@ use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\RequestEvent;
use Tmdb\Repository\DiscoverRepository;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository;
@@ -30,6 +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';
protected Client $client;
protected MovieRepository $movieRepository;
@@ -38,6 +42,8 @@ class TmdbClient
protected TvEpisodeRepository $tvEpisodeRepository;
protected SearchRepository $searchRepository;
protected DiscoverRepository $discoverRepository;
protected array $mediaTypeMap = [
MediaType::Movie->value => MediaType::Movie->value,
MediaType::TvShow->value => MediaType::TvShow->value,
@@ -48,11 +54,14 @@ class TmdbClient
protected $repos = [];
private ?string $originalLanguage = 'en';
public function __construct(
private readonly SerializerInterface $serializer,
private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey,
#[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
) {
$this->client = new Client(
[
@@ -74,7 +83,7 @@ class TmdbClient
'hydration' => [
'event_listener_handles_hydration' => false,
'only_for_specified_models' => []
]
],
]
);
@@ -107,11 +116,14 @@ class TmdbClient
$this->tvSeasonRepository = new TvSeasonRepository($this->client);
$this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
$this->searchRepository = new SearchRepository($this->client);
$this->discoverRepository = new DiscoverRepository($this->client);
$this->repos = [
MediaType::Movie->value => $this->movieRepository,
MediaType::TvShow->value => $this->tvRepository,
MediaType::TvEpisode->value => $this->tvEpisodeRepository,
];
$this->originalLanguage = $originalLanguage;
}
public function search(string $term): TmdbResult|Map
@@ -129,11 +141,55 @@ 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'];
return $this->parseResult(
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => 'external_ids,credits']),
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]),
MediaType::Movie->value,
$imdbId
);
@@ -142,7 +198,7 @@ class TmdbClient
public function tvshowDetails(string $imdbId): ?TmdbResult
{
$tmdbId = $this->findByImdbId($imdbId)['id'];
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => 'external_ids,credits']);
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]);
$media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
return $data['season_number'] !== 0 &&
@@ -150,6 +206,8 @@ class TmdbClient
$data['episode_count'] > 0;
})->map(function ($data) use ($media) {
return $this->tvSeasonDetails($media['id'], $data['season_number'])['episodes'];
})->rekey(function ($data) use ($media) {
return $data[1]['season_number'];
})->toArray();
return $this->parseResult(
@@ -161,7 +219,7 @@ class TmdbClient
public function tvSeasonDetails(string $tmdbId, int $season): array
{
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => 'external_ids,credits']);
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => static::APPEND_TO_RESPONSE]);
$result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
$data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
$data['poster'] = $data['still_path'];
@@ -172,7 +230,7 @@ class TmdbClient
public function tvEpisodeDetails(string $tmdbId, string $showImdbId, int $season, int $episode): TmdbResult|TmdbEpisodeDto|null
{
$result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => 'external_ids,credits']);
$result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => static::APPEND_TO_RESPONSE]);
return $this->parseResult(
$result,
MediaType::TvEpisode->value,
@@ -189,9 +247,20 @@ 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->movieRepository->getApi()->getPopular();
$results = $this->discoverRepository->getApi()->discoverMovies([
'with_original_language' => $this->originalLanguage,
'append_to_response' => 'external_ids,watch/providers',
]);
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::Movie->value;
return $result;
@@ -204,7 +273,10 @@ class TmdbClient
public function popularTvShows(int $resultCount = 6): Map
{
$results = $this->tvRepository->getApi()->getPopular();
$results = $this->discoverRepository->getApi()->discoverTv([
'with_original_language' => $this->originalLanguage,
'append_to_response' => 'external_ids,watch/providers',
]);
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::TvShow->value;
return $result;

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 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'])
@@ -68,4 +89,10 @@ class Torrentio
return $results;
}
public function getDestinationUrl(string $url)
{
$request = get_headers($url)[8];
return explode(' ', $request)[1];
}
}

View File

@@ -2,11 +2,13 @@
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;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Client\Torrentio;
use App\Torrentio\Exception\TorrentioRateLimitException;
use Carbon\Carbon;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -21,9 +23,12 @@ use Symfony\UX\Turbo\TurboBundle;
final class WebController extends AbstractController
{
const REAL_DEBRID_STREAM_URL = 'https://real-debrid.com/streaming-%s';
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly ConfigResolver $configResolver,
private readonly Broadcaster $broadcaster,
) {}
@@ -37,10 +42,14 @@ final class WebController extends AbstractController
$input->imdbId
);
$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());
});
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);
@@ -63,10 +72,14 @@ final class WebController extends AbstractController
);
try {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->getTvShowOptionsHandler->handle($input->toCommand());
});
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);
@@ -99,4 +112,14 @@ final class WebController extends AbstractController
]
);
}
#[Route('/torrentio/stream/{url}', name: 'app.torrentio.stream')]
public function streamVideo(string $url, Torrentio $torrentio): Response
{
$destinationUrl = $torrentio->getDestinationUrl(\base64_decode($url));
$urlPathParts = explode('/', parse_url($destinationUrl)['path']);
$videoId = $urlPathParts[2];
$url = sprintf(self::REAL_DEBRID_STREAM_URL, $videoId);
return $this->redirect($url);
}
}

View File

@@ -21,29 +21,32 @@ class ResultFactory
string $bingeGroup = "-",
string $imdbId = "-",
) {
$ptn = (object) (new PTN())->parse($title);
return new TorrentioResult(
$title = trim(preg_replace('/\s+/', ' ', $title));
$ptn = (object) new PTN()->parse(self::setFilename($url));
$result = new TorrentioResult(
self::trimTitle($title),
urldecode($url),
self::setUrl($url),
self::setFilename($url),
self::setSize($title),
self::setSeeders($title),
self::setProvider($title),
self::setEpisode($title),
$ptn->season ?? "-",
self::setSeason($ptn),
$bingeGroup,
$ptn->resolution ?? "-",
self::setCodec($ptn->codec ?? "-"),
$ptn->quality ?? "-",
self::setResolution($ptn),
self::setCodec($ptn),
self::setQuality($ptn),
$ptn,
substr(base64_encode($url), strlen($url) - 10),
$ptn->episode ?? "-",
self::setKey($url),
self::setEpisodeNumber($ptn),
self::setLanguages($title),
self::setLanguageFlags($title),
false,
uniqid(),
$imdbId,
);
return $result;
}
public static function setFilename(string $url)
@@ -52,6 +55,11 @@ class ResultFactory
return end($file);
}
public static function setUrl(string $url): string
{
return urldecode($url);
}
public static function setSize(string $title): string
{
$sizeMatch = [];
@@ -112,9 +120,15 @@ class ResultFactory
}
}
public static function setCodec(string $codec): string
public static function setCodec(object $ptn): string
{
return self::$codecMap[strtolower($codec)] ?? $codec;
if (isset($ptn->codec) && array_key_exists($ptn->codec, self::$codecMap)) {
return self::$codecMap[strtolower($ptn->codec)];
} elseif (isset($ptn->codec)) {
return $ptn->codec;
}
return "-";
}
private static function setEpisode(string $title)
@@ -124,6 +138,36 @@ class ResultFactory
return array_key_exists(0, $value) ? strtoupper($value[0]) : "n/a";
}
public static function setSeason(object $ptn): string
{
return $ptn->season ?? "-";
}
public static function setBingeGroup(string $bingeGroup): string
{
return $bingeGroup;
}
public static function setResolution(object $ptn): string
{
return $ptn->resolution ?? "-";
}
public static function setQuality(object $ptn): string
{
return $ptn->quality ?? "-";
}
public static function setKey(string $url): string
{
return substr(base64_encode($url), strlen($url) - 10);
}
public static function setEpisodeNumber(object $ptn): string
{
return $ptn->episode ?? "-";
}
private static function trimTitle(string $title)
{
$emoji = \Emoji\detect_emoji($title);

View File

@@ -17,11 +17,14 @@ final class MonitorList extends AbstractController
use PaginateTrait;
#[LiveProp(writable: true)]
public ?int $parentMonitorId = null;
#[LiveProp(writable: true)]
public string $term = "";
#[LiveProp(writable: true)]
public string $type;
public string $type = "";
#[LiveProp(writable: true)]
public bool $isWidget = true;
@@ -33,7 +36,9 @@ final class MonitorList extends AbstractController
#[LiveAction]
public function getMonitors()
{
if ($this->type === "active") {
if (null !== $this->parentMonitorId) {
return $this->getChildMonitorsByParentId($this->parentMonitorId);
} elseif ($this->type === "active") {
return $this->getActiveUserMonitors();
} elseif ($this->type === "complete") {
return $this->getCompleteUserMonitors();
@@ -48,6 +53,7 @@ final class MonitorList extends AbstractController
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)')
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
->andWhere('m.parent IS NULL')
->setParameter('statuses', ['New', 'In Progress', 'Active'])
->setParameter('term', '%'.$this->term.'%')
->orderBy('m.id', 'DESC')
@@ -67,4 +73,16 @@ final class MonitorList extends AbstractController
->getQuery()
);
}
#[LiveAction]
public function getChildMonitorsByParentId(int $parentId)
{
return $this->asPaginator(
$this->monitorRepository->createQueryBuilder('m')
->andWhere("m.parent = :parentId")
->setParameter('parentId', $parentId)
->orderBy('m.id', 'DESC')
->getQuery()
);
}
}

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

@@ -49,7 +49,6 @@ final class TvEpisodeList
}
$this->reloadCount++;
// dd(new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage));
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
}

View File

@@ -112,4 +112,18 @@ class UtilExtension
return new EpisodeIdDto($season, $episode);
}
#[AsTwigFunction('sentry_enabled')]
public function sentryEnabled(): bool
{
$sentryConfig = $this->config->getSentryConfig();
return $sentryConfig['javascript_url'] !== null &&
$sentryConfig['javascript_url'] !== '';
}
#[AsTwigFilter('base64_encode')]
public function base64_encode(string $data): string
{
return \base64_encode($data);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\User\Framework\Entity;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use App\EventLog\Framework\Entity\EventLog;
use App\Monitor\Framework\Entity\Monitor;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
@@ -56,11 +57,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: Download::class, mappedBy: 'user')]
private Collection $downloads;
/**
* @var Collection<int, EventLog>
*/
#[ORM\OneToMany(targetEntity: EventLog::class, mappedBy: 'user')]
private Collection $eventLogs;
public function __construct()
{
$this->userPreferences = new ArrayCollection();
$this->monitors = new ArrayCollection();
$this->downloads = new ArrayCollection();
$this->eventLogs = new ArrayCollection();
}
public function getId(): ?int
@@ -342,4 +350,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->hasUserPreference('enable_ical_up_ep') &&
(bool) $this->getUserPreference('enable_ical_up_ep')->getPreferenceValue() === true;
}
/**
* @return Collection<int, EventLog>
*/
public function getEventLogs(): Collection
{
return $this->eventLogs;
}
public function addEventLog(EventLog $eventLog): static
{
if (!$this->eventLogs->contains($eventLog)) {
$this->eventLogs->add($eventLog);
$eventLog->setUser($this);
}
return $this;
}
public function removeEventLog(EventLog $eventLog): static
{
if ($this->eventLogs->removeElement($eventLog)) {
// set the owning side to null (unless already changed)
if ($eventLog->getUser() === $this) {
$eventLog->setUser(null);
}
}
return $this;
}
}

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' => [

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