Compare commits

..

69 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
103 changed files with 3134 additions and 547 deletions

View File

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

13
.env
View File

@@ -58,3 +58,16 @@ OIDC_BYPASS_FORM_LOGIN=false
NOTIFICATION_TRANSPORT= NOTIFICATION_TRANSPORT=
NTFY_DSN= 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.xml
/.phpunit.cache/ /.phpunit.cache/
###< phpunit/phpunit ### ###< 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 comparison to Stremio? That's because Torsearch uses the same source for media files that Stremio uses: Torrentio
(hence the name: Torsearch). (hence the name: Torsearch).
After two failed attempts at running a media server, I decided to hang up my hat and give up my dream of a self-hosted
media server. I figured the days of torrenting were mostly over and everybody ranting & raving about their media collections
must be going to Walmart and buying up the bucket of old movies they have. That's until I stumbled across Stremio.
At first, it seemed too good to be true, but I was yearning for something just sketchy enough to try out. What could
go wrong with handing over my card information to an unknown organization across the pond? At the end of the day,
the cost benefit analysis landed in my favor, and about 30 minutes after purchasing my Real Debrid subscription and
setting up Stremio, I was in business.
My mind was blown. I might not have the most "cultured" taste in media, but it had everything I searched for and more! After
watching a few movies, I noticed the "Copy Download Link" button. "What's this lil guy do?" I asked myself. Duh, it
downloads the f*****g movie. And there's the 💡flashing over my head. There's gotta be a way to automate this, I told myself.
After a month of studying Stremio's code and lots of tinkering, I finally figured it out. Torrentio is the magic behind
the scenes. You feed it a Real Debrid API key and an IMDB ID, and it gives you a list of results to download that media.
Easy peasy.
In about an hour I had a proof of concept working. It wasn't pretty, but it wasn't supposed to be. That proof-of-concept
has blossomed into the beautiful Torsearch that I've been using nearly every day since then. The code in this repo
is a complete re-write of the proof-of-concept that started out ugly and ended up even uglier. Knowing the core functionality
required to make it work, I was able to re-write the app with some design patterns in place.
## Pics or didn't happen ## Pics or didn't happen
![Torsearch Homepage](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_homepage.png) ![Torsearch Homepage](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_homepage.png)
![TV Show Result](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_severance.png) ![TV Show 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 - Search for Movies & TV Shows by their name
- Download directly to your NAS - Download directly to your NAS
- Monitor TV Shows for new episodes and automatically download them - Monitor TV Shows for new episodes and automatically download them
- Browse popular media and view its download options - Discover new media
- LDAP or local auth (OIDC coming soon) - OIDC
## Features on the roadmap
- Requests - allow users to request new media
- OIDC auth
- Prometheus logging
## Getting Started ## Getting Started
1. Clone the repo For all pieces to work, you will need to serve the application over HTTPS. Running behind an SSL terminating
reverse proxy is the recommended approach.
1. Create a `compose.yml` file
```yaml
services:
### The app contains the application and web server ###
app:
image: code.caldwell.digital/home/torsearch-app:${TAG}
ports:
- "${WEB_PORT}:80"
env_file: .env
depends_on:
database:
condition: service_healthy
volumes:
- ${LOCAL_MOVIES_DIR}:/var/download/movies
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
- mercure_data:/data
- mercure_config:/config
### The worker handles downloads and async jobs ###
worker:
image: code.caldwell.digital/home/torsearch-worker:${TAG}
volumes:
- ${LOCAL_MOVIES_DIR}:/var/download/movies
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
env_file: .env
depends_on:
- app
### The scheduler processes monitored media ###
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:${TAG}
volumes:
- ${LOCAL_MOVIES_DIR}:/var/download/movies
- ${LOCAL_TVSHOWS_DIR}:/var/download/tvshows
env_file: .env
depends_on:
- app
#!! If using your own database, this can be omitted !!#
database:
image: mariadb:10.11.2
volumes:
- mysql:/var/lib/mysql
env_file: .env
healthcheck:
test: ["CMD", "mysqladmin", "ping"]
interval: 5s
timeout: 5s
retries: 10
#!! If using your own redis, this can be omitted !!#
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
volumes:
mysql:
mercure_config:
mercure_data:
redis_data:
```
2. Create a `.env` file
```dotenv
###################
# Torsearch #
###################
# The version of Torsearch to run. Docker will this tag.
TAG=latest
# The port for which the web server (app container) will
# serve the application on the host.
WEB_PORT=8004
# The host directories where your media is stored.
# If you would like to use a docker driver for a network
# share, update the compose.yml file to reflect that.
# These are passed into the compose file as bind mounts.
LOCAL_MOVIES_DIR="./movies"
LOCAL_TVSHOWS_DIR="./tvshows"
# Set the timezone you're in. This helps render monitored items correctly on the calendar
TZ=America/Chicago
###################
# Symfony #
###################
# The external URL of the application where it can be reached by a browser. Should start with https://
APP_URL="<enter url>"
# Requried by Symfony Framework. Feel free to change.
APP_SECRET="70169beadfbc8101c393cbfbba27a313"
# Change to 'dev' to show logs in the browser.
APP_ENV=prod
###################
# Mercure #
###################
# Mercure is a Caddy module built into the webserver
# that facilitates the usage of websockets to transmit
# real time data (download progress, etc.)
# TBH, I've only run into issues when changing these. If
# you have problems after changing them, just revert them.
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
###################
# Database #
###################
# Use the DATABASE_URL below to use the MariaDB container
# provided in the example.compose.yml file, or remove this
# line and fill in the details of your own MySQL/MariaDB server
MYSQL_USER=app
MYSQL_PASSWORD="P@ssword123"
MYSQL_DATABASE=app
MYSQL_HOST=database
MYSQL_RANDOM_ROOT_PASSWORD=true
DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@${MYSQL_HOST}:3306/${MYSQL_DATABASE}?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
###################
# Real Debrid #
###################
# Enter your Real Debrid API key which is passed to Torrentio to retrieve download options
REAL_DEBRID_KEY="<enter real debrid api key>"
###################
# Redis #
###################
# Use your own Redis instance or use the below value to use the
# container included in the example compose.yml file.
REDIS_HOST="redis://redis"
###################
# Auth #
###################
# Options: form_login, oidc, or ldap (experimental)
# Fill the rest of the configuration based on your choice here
# No additional config is required for form_login
AUTH_METHOD=form_login
### OIDC ###
# To use OIDC, set the AUTH_METHOD to oidc and fill in the following properties
#OIDC_WELL_KNOWN_URL=
#OIDC_CLIENT_ID=
#OIDC_CLIENT_SECRET=
# Allows you to skip the login page and directly rely on your IdP for auth.
#OIDC_BYPASS_FORM_LOGIN=
```
3. Enter the `APP_URL` in the .env file
4. Enter the `REAL_DEBRID_KEY` in the .env file
5. Enter a new `WEB_PORT` if the default doesn't work for you
4. Run `docker compose up -d`
4. Visit the app in the browser
5. Create a user
6. Visit the Preferences page to set your filter. This filter is used whenever you don't choose a specific file to
download (e.g. downloading via Monitor or clicking the "Download Season", "Download Selected", or "Download Episode" buttons).
7. Start downloading media!
## Having issues?
Submit an issue in the repo, and I'll try to address it as soon as possible. I do have a full-time job and family, so my
time is limited, but I'll do my best!
## Notes
This is my first contribution to open-source, the community that's given me so much over the years!
This project has been my personal hobby project for the last 1.5 years. I've written and re-written it several times.
It's been my testing ground for trying new things, so if the code looks like shit, my bad. I'm a PHP developer by day and
tinkerer by night - this was my first go with Symfony/Twig components, tailwind, the Symfony RICH bundle, and a lot more.
At some point, I'll put together a contribution guide, so others can hack on it too.
No AI was used for development (only to generate a list of countries with their flag emojis). If the code is bad, it's my fault.
# Disclaimer
Torsearch does not host any media; it only combines API results from multiple sources to make browsing them easier.
Torsearch is not affiliated with Real Debrid, Torrentio, or TMDB.

View File

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

View File

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

View File

@@ -14,5 +14,6 @@ export default class extends Controller {
"3000" "3000"
)); ));
this.element.addEventListener('mouseover', () => clearTimeout(timer)); 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) { 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 # torsearch-app is built from this base
export APP_FRANKENPHP_TAG=php8.4 export APP_FRANKENPHP_TAG=php8.4
docker buildx build --platform=linux/amd64 -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:${APP_FRANKENPHP_TAG}
docker push code.caldwell.digital/home/torsearch-base:latest docker push code.caldwell.digital/home/torsearch-base:latest
# torsearch-worker & torsearch-scheduler are built from this base # torsearch-worker & torsearch-scheduler are built from this base
export WORKER_FRANKENPHP_TAG=php8.4-alpine export WORKER_FRANKENPHP_TAG=php8.4-alpine
docker buildx build --platform=linux/amd64 -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:${WORKER_FRANKENPHP_TAG}
docker push code.caldwell.digital/home/torsearch-base-worker:latest 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: services:
caddy: caddy:
image: caddy:2.9.1 image: caddy:2.9.1
@@ -33,12 +35,9 @@ services:
worker: worker:
# build: build:
# dockerfile: docker/Dockerfile.base.worker dockerfile: docker/Dockerfile.worker
# context: . context: .
# args:
# FRANKENPHP_TAG: php8.4-alpine
image: torsearch-alpine
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
@@ -46,22 +45,20 @@ services:
tty: true tty: true
environment: environment:
TZ: America/Chicago TZ: America/Chicago
# command: php /app/bin/console messenger:consume async --time-limit=3600 -vv
scheduler: scheduler:
build: build:
dockerfile: docker/Dockerfile.base.worker dockerfile: docker/Dockerfile.scheduler
context: . context: .
args:
FRANKENPHP_TAG: php8.4-alpine
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
- $PWD/var/download:/var/download
tty: true
environment: environment:
TZ: America/Chicago TZ: America/Chicago
command: php /app/bin/console messenger:consume scheduler_monitor -vv WORKER_MONITOR: 2
tty: true
redis: redis:
@@ -83,9 +80,9 @@ services:
environment: environment:
TZ: America/Chicago TZ: America/Chicago
MYSQL_DATABASE: app MYSQL_DATABASE: app
MYSQL_USERNAME: app MYSQL_USER: app
MYSQL_PASSWORD: password MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password MYSQL_RANDOM_ROOT_PASSWORD: true
healthcheck: healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s interval: 5s

View File

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

605
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c133ccd27ac6a41256bdc69129c16546", "content-hash": "952def2c32d975032ac0061e5aa37319",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -2552,6 +2552,66 @@
], ],
"time": "2025-03-27T12:30:47+00:00" "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", "name": "lcobucci/jwt",
"version": "5.5.0", "version": "5.5.0",
@@ -2681,6 +2741,109 @@
}, },
"time": "2025-02-06T08:48:15+00:00" "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", "name": "nesbot/carbon",
"version": "3.10.0", "version": "3.10.0",
@@ -4757,6 +4920,196 @@
], ],
"time": "2023-12-12T12:06:11+00:00" "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", "name": "spatie/icalendar-generator",
"version": "3.0.0", "version": "3.0.0",
@@ -7637,6 +7990,169 @@
], ],
"time": "2025-02-19T08:51:26+00:00" "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", "name": "symfony/notifier",
"version": "v7.3.0", "version": "v7.3.0",
@@ -8777,6 +9293,89 @@
], ],
"time": "2025-04-04T13:12:05+00:00" "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", "name": "symfony/routing",
"version": "v7.3.0", "version": "v7.3.0",
@@ -13398,7 +13997,7 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": [], "stability-flags": {},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
@@ -13406,7 +14005,7 @@
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*" "ext-iconv": "*"
}, },
"platform-dev": [], "platform-dev": {},
"platform-overrides": { "platform-overrides": {
"php": "8.4" "php": "8.4"
}, },

View File

@@ -23,4 +23,6 @@ return [
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true], SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true], Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true],
SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::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

@@ -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: twig:
globals: globals:
version: '%app.version%' version: '%app.version%'
sentry_javascript_url: '%sentry.javascript_url%'
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
date: date:
format: 'm/d/Y' format: 'm/d/Y'

View File

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

View File

@@ -48,6 +48,13 @@ parameters:
notification.transport: '%env(NOTIFICATION_TRANSPORT)%' notification.transport: '%env(NOTIFICATION_TRANSPORT)%'
notification.ntfy.dsn: '%env(NTFY_DSN)%' 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: services:
# default configuration for services in *this* file # default configuration for services in *this* file
_defaults: _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" ARG APP_VERSION="0.0.0-dev"
ENV APP_VERSION="${APP_VERSION}" ENV APP_VERSION="${APP_VERSION}"
ARG TMDB_API=""
ENV TMDB_API="${TMDB_API}"
COPY . /app COPY . /app
COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

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" RUN ln -s /usr/bin/php84 /usr/bin/php
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev" RUN mkdir -p /etc/supervisor/conf.d
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \ # We start supervisord and supervisord starts
pdo_mysql \ # respective service in the configuration file.
gd \ ENTRYPOINT ["/app/bin/console", "init:worker"]
intl \
zip \ # Message transports can be enabled by passing them as command options.
opcache # 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" # Default to latest, but should pass in a version
ENV APP_VERSION="${APP_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,40 +1,27 @@
### ###
# This version of Torsearch runs the scheduler, downloader, and worker # This version of Torsearch can run the scheduler, downloader, and
# in this one container. Each process is managerd by supervisord # worker in one container. Each process is managerd by supervisord
# and can be configured via environment variables and more # and can be configured via environment variables, and more than
# than one of these containers cans till be run. # one of these containers can still be run.
### ###
# Default to latest, but should pass in a version
ARG APP_VERSION="latest" ARG APP_VERSION="latest"
FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS base_image # 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
# Start with our base worker image
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
FROM alpine:3.22 AS build_stage # Set the APP_VERSION in the image
ENV APP_VERSION=${APP_VERSION}
COPY --from=base_image --chown=1000:1000 /app /app ARG TMDB_API=""
ENV TMDB_API="${TMDB_API}"
RUN apk add --no-cache \ # Copy the actual application code from the previously built app
curl \ COPY --chown=1000:1000 --from=app_image /app /app
nginx \
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 \
supervisor
RUN ln -s /usr/bin/php84 /usr/bin/php # To retain backwards compatibility, default to async & download transports
CMD [ "--async", "--download" ]
COPY docker/worker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# We start supervisord and supervisord starts
# respective service in the configuration file.
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

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

@@ -3,36 +3,3 @@ nodaemon=true
logfile=/dev/null logfile=/dev/null
logfile_maxbytes=0 logfile_maxbytes=0
pidfile=/run/supervisord.pid pidfile=/run/supervisord.pid
[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
[program:torsearch-worker]
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
[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

@@ -1,58 +1,92 @@
# App must be served over HTTPS (requirement of Mercure) ###################
# Either serve behind an SSL terminating reverse proxy # Torsearch #
# or pass your certificates into the 'app' container. ###################
# Please omit any trailing slashes. The APP_URL is # The version of Torsearch to run. Docker will this tag.
# used to generate the Mercure URL behind the scenes. TAG=latest
APP_URL="https://dev.caldwell.digital"
# 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" APP_SECRET="70169beadfbc8101c393cbfbba27a313"
# Change to 'dev' to show logs in the browser.
APP_ENV=prod APP_ENV=prod
###################
# Mercure #
###################
# Mercure is a Caddy module built into the webserver # Mercure is a Caddy module built into the webserver
# that facilitates the usage of websockets to transmit # that facilitates the usage of websockets to transmit
# real time data (download progress, etc.) # real time data (download progress, etc.)
# TBH, I've only run into issues when changing these. If
# you have problems after changing them, just revert them.
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
###################
# Database #
###################
# Use the DATABASE_URL below to use the MariaDB container # Use the DATABASE_URL below to use the MariaDB container
# provided in the example.compose.yml file, or remove this # provided in the example.compose.yml file, or remove this
# line and fill in the details of your own MySQL/MariaDB server # line and fill in the details of your own MySQL/MariaDB server
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 # Real Debrid #
# else and is passed to Torrentio ###################
# to retrieve download options # Enter your Real Debrid API key is passed to Torrentio to retrieve download options
REAL_DEBRID_KEY="" 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 # Redis #
# in the example compose.yml file. ###################
# 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" 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 AUTH_METHOD=form_login
# OIDC ### OIDC ###
OIDC_WELL_KNOWN_URL= #OIDC_WELL_KNOWN_URL=
OIDC_CLIENT_ID= #OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET= #OIDC_CLIENT_SECRET=
# Allows you to skip the login page and directly # Allows you to skip the login page and directly rely on your IdP for auth.
# rely on your IdP for auth. #OIDC_BYPASS_FORM_LOGIN=
OIDC_BYPASS_FORM_LOGIN=
### LDAP (*** Experimental! ***) ###
# LDAP Config: To use LDAP, enter the below fields # LDAP Config: To use LDAP, enter the below fields and run 'php bin/console config:set auth.method ldap'
# and run 'php bin/console config:set auth.method ldap'
# (LDAP is still in progress and not ready for use) # (LDAP is still in progress and not ready for use)
#LDAP_HOST= #LDAP_HOST=
#LDAP_PORT= #LDAP_PORT=

View File

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

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

View File

@@ -4,6 +4,7 @@ namespace App\Base;
use App\Base\Dto\AppVersionDto; use App\Base\Dto\AppVersionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class ConfigResolver final class ConfigResolver
@@ -14,6 +15,7 @@ final class ConfigResolver
public function __construct( public function __construct(
private readonly DenormalizerInterface $denormalizer, private readonly DenormalizerInterface $denormalizer,
private readonly RequestStack $requestStack,
#[Autowire(param: 'app.url')] #[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null, private readonly ?string $appUrl = null,
@@ -53,6 +55,18 @@ final class ConfigResolver
#[Autowire(param: 'notification.ntfy.dsn')] #[Autowire(param: 'notification.ntfy.dsn')]
private ?string $notificationNtfyDsn = null, 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 public function validate(): bool
@@ -101,6 +115,11 @@ final class ConfigResolver
return $this->authOidcBypassFormLogin; return $this->authOidcBypassFormLogin;
} }
public function isTorrentioCacheEnabled(): bool
{
return $this->torrentioCacheResults;
}
public function getAppVersion(): AppVersionDto public function getAppVersion(): AppVersionDto
{ {
$matches = []; $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')] #[Route('/test')]
public function monitorTvShow(MonitorTvShowHandler $handler): Response public function monitorTvShow(MonitorTvShowHandler $handler): Response
{ {
// $handler->handle(new MonitorTvShowCommand(82)); throw new \Exception('Test');
return $this->render('index/test.html.twig', []); return $this->render('index/test.html.twig', []);
} }
} }

View File

@@ -24,10 +24,10 @@ readonly class Broadcaster
private LoggerInterface $logger, 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 { try {
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic'); $userAlertTopic = $mercureAlertTopic ?? $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update( $update = new Update(
$userAlertTopic, $userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [ $this->renderer->render('broadcast/Alert.stream.html.twig', [
@@ -39,7 +39,7 @@ readonly class Broadcaster
); );
$this->hub->publish($update); $this->hub->publish($update);
} catch (\Throwable $exception) { } 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'])) { if (true === $sendPush && in_array($this->notificationTransport, ['ntfy'])) {

View File

@@ -168,6 +168,37 @@ class MediaFiles
return false; 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) public function movieExists(string $title)
{ {
$filepath = $this->moviesPath . DIRECTORY_SEPARATOR . $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 public function paginate($query, int $page = 1, int $limit = 5): Paginator
{ {
$paginator = new OrmPaginator($query); $paginator = new OrmPaginator($query);
$paginator $paginator
->getQuery() ->getQuery()
->setFirstResult($limit * ($page - 1)) ->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 string $imdbId,
public int $userId, public int $userId,
public ?int $downloadId = null, public ?int $downloadId = null,
public ?string $mercureAlertTopic = null,
) {} ) {}
} }

View File

@@ -2,9 +2,12 @@
namespace App\Download\Action\Handler; 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\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult; use App\Download\Action\Result\DownloadMediaResult;
use App\Download\DownloadEvents; use App\Download\DownloadEvents;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface; use App\Download\Downloader\DownloaderInterface;
use App\EventLog\Action\Command\AddEventLogCommand; use App\EventLog\Action\Command\AddEventLogCommand;
@@ -21,8 +24,8 @@ readonly class DownloadMediaHandler implements HandlerInterface
public function __construct( public function __construct(
private MessageBusInterface $bus, private MessageBusInterface $bus,
private DownloaderInterface $downloader, private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
private UserRepository $userRepository, private UserRepository $userRepository, private Broadcaster $broadcaster,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
@@ -49,6 +52,16 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download = $this->downloadRepository->find($command->downloadId); $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 { try {
if ($download->getStatus() !== 'Paused') { if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress'); $this->downloadRepository->updateStatus($download->getId(), 'In Progress');
@@ -77,4 +90,31 @@ readonly class DownloadMediaHandler implements HandlerInterface
)); ));
return new DownloadMediaResult(200, "Success."); 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\DownloadMediaResult;
use App\Download\Action\Result\DownloadSeasonResult; use App\Download\Action\Result\DownloadSeasonResult;
use App\Download\DownloadOptionEvaluator; use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Tmdb\TmdbClient; use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
@@ -26,13 +28,13 @@ use Symfony\Component\Messenger\MessageBusInterface;
readonly class DownloadSeasonHandler implements HandlerInterface readonly class DownloadSeasonHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private MediaFiles $mediaFiles, private MediaFiles $mediaFiles,
private LoggerInterface $logger, private LoggerInterface $logger,
private TmdbClient $tmdb, private TmdbClient $tmdb,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private DownloadOptionEvaluator $downloadOptionEvaluator, private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler, private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private UserRepository $userRepository, private UserRepository $userRepository, private DownloadRepository $downloadRepository,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
@@ -46,14 +48,14 @@ readonly class DownloadSeasonHandler implements HandlerInterface
$downloadCommands = []; $downloadCommands = [];
foreach ($episodesInSeason as $episode) { 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( $results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand( new GetTvShowOptionsCommand(
$series->tmdbId, $series->tmdbId,
$command->imdbId, $command->imdbId,
$command->season, $command->season,
$episode['episode_number'] $episode->episodeNumber
) )
); );
@@ -67,14 +69,17 @@ readonly class DownloadSeasonHandler implements HandlerInterface
if (null !== $result) { if (null !== $result) {
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching 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( $downloadCommand = new DownloadMediaCommand(
$result->url, $download->getUrl(),
$series->title, $download->getTitle(),
$result->filename, $download->getFilename(),
'tvshows', $download->getMediaType(),
$command->imdbId, $download->getImdbId(),
$command->userId, $download->getUser()->getId(),
$download->getId()
); );
$this->bus->dispatch($downloadCommand); $this->bus->dispatch($downloadCommand);
$downloadCommands[] = $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 $download = new Download();
$downloadedEpisodes = $this->mediaFiles $download->setUrl($url);
->getEpisodes($title) $download->setTitle($title);
->map(fn($episode) => (object) (new PTN())->parse($episode)) $download->setFilename($filename);
->filter(fn ($episode) => $download->setImdbId($command->imdbId);
property_exists($episode, 'episode') $download->setMediaType(MediaType::TvShow->value);
&& property_exists($episode, 'season') $download->setEpisodeId($this->getEpisodeNumber($command->season, $episodeNumber));
&& null !== $episode->episode $download->setUser($this->userRepository->find($command->userId));
&& null !== $episode->season $this->downloadRepository->getEntityManager()->persist($download);
) $this->downloadRepository->getEntityManager()->flush();
->rekey(fn($episode) => $episode->episode); return $download;
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle()); }
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> */ /** @implements InputInterface<DownloadMediaInput> */
class DownloadMediaInput implements InputInterface class DownloadMediaInput implements InputInterface
{ {
public ?string $mercureAlertTopic = null;
public function __construct( public function __construct(
#[SourceRequest('url')] #[SourceRequest('url')]
public string $url, public string $url,
@@ -44,6 +46,7 @@ class DownloadMediaInput implements InputInterface
$this->imdbId, $this->imdbId,
$this->userId, $this->userId,
$this->downloadId, $this->downloadId,
$this->mercureAlertTopic,
); );
} }
} }

View File

@@ -21,7 +21,12 @@ class DownloadOptionEvaluator
return false; 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; return false;
} }
@@ -47,15 +52,15 @@ class DownloadOptionEvaluator
$valid = false; $valid = false;
} }
if (null !== $filter->codec && in_array($result->codec, $filter->codec)) { if (null !== $filter->codec && !in_array($result->codec, $filter->codec)) {
$valid = false; $valid = false;
} }
if (null !== $filter->quality && in_array($result->quality, $filter->quality)) { if (null !== $filter->quality && !in_array($result->quality, $filter->quality)) {
$valid = false; $valid = false;
} }
if (null !== $filter->provider && in_array($result->provider, $filter->provider)) { if (null !== $filter->provider && !in_array($result->provider, $filter->provider)) {
$valid = false; $valid = false;
} }
@@ -79,4 +84,19 @@ class DownloadOptionEvaluator
return false; return false;
} }
public function validateDownloadUrl(string $downloadUrl)
{
$badFileLocations = [
'https://torrentio.strem.fun/videos/failed_infringement_v2.mp4' => 'Removed for Copyright Infringement.',
'https://torrentio.strem.fun/videos/downloading_v2.mp4' => 'Your torrent is downloading to your debrid provider.'
];
$headers = get_headers($downloadUrl, true);
if (array_key_exists($headers['Location'], $badFileLocations)) {
return false;
}
return true;
}
} }

View File

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

View File

@@ -98,6 +98,14 @@ class DownloadRepository extends ServiceEntityRepository
return $download; 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) public function delete(int $id)
{ {
$entity = $this->find($id); $entity = $this->find($id);
@@ -115,4 +123,16 @@ class DownloadRepository extends ServiceEntityRepository
->getQuery() ->getQuery()
->getResult(); ->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,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

@@ -8,6 +8,7 @@ use App\Monitor\Action\Result\AddMonitorResult;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\MonitorEvents; use App\Monitor\MonitorEvents;
use App\Tmdb\TmdbClient;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable; use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
@@ -22,13 +23,17 @@ readonly class AddMonitorHandler implements HandlerInterface
private MessageBusInterface $bus, private MessageBusInterface $bus,
private MonitorRepository $movieMonitorRepository, private MonitorRepository $movieMonitorRepository,
private UserRepository $userRepository, private UserRepository $userRepository,
private TmdbClient $tmdb,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$user = $this->userRepository->find($command->userId); $user = $this->userRepository->find($command->userId);
$poster = $this->getPoster($command->imdbId);
$monitor = (new Monitor()) $monitor = (new Monitor())
->setUser($user) ->setUser($user)
->setPoster($poster)
->setTmdbId($command->tmdbId) ->setTmdbId($command->tmdbId)
->setImdbId($command->imdbId) ->setImdbId($command->imdbId)
->setTitle($command->title) ->setTitle($command->title)
@@ -56,4 +61,10 @@ readonly class AddMonitorHandler implements HandlerInterface
] ]
); );
} }
private function getPoster(string $imdbId): ?string
{
$data = $this->tmdb->tvShowDetails($imdbId);
return $data->poster;
}
} }

View File

@@ -9,6 +9,7 @@ use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand; use App\EventLog\Action\Command\AddEventLogCommand;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult; use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\MonitorEvents; use App\Monitor\MonitorEvents;
use App\Tmdb\TmdbClient; use App\Tmdb\TmdbClient;
@@ -43,6 +44,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
try { try {
$monitor = $this->monitorRepository->find($command->movieMonitorId); $monitor = $this->monitorRepository->find($command->movieMonitorId);
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode()); $this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
$this->refreshData($monitor);
$this->bus->dispatch(new AddEventLogCommand( $this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(), $monitor->getUser(),
@@ -151,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'); $this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
$monitor = $this->monitorRepository->find($command->monitorId); $monitor = $this->monitorRepository->find($command->monitorId);
$this->refreshData($monitor);
// Check current episodes // Check current episodes
$downloadedEpisodes = $this->mediaFiles $downloadedEpisodes = $this->mediaFiles
@@ -157,4 +158,15 @@ readonly class MonitorTvShowHandler implements HandlerInterface
'status' => ['New', 'Active', 'In Progress'] 'status' => ['New', 'Active', 'In Progress']
]) !== null; ]) !== 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, 'allDay' => true,
'backgroundColor' => $eventColors[$monitor->getImdbId()], 'backgroundColor' => $eventColors[$monitor->getImdbId()],
'borderColor' => $eventColors[$monitor->getImdbId()], 'borderColor' => $eventColors[$monitor->getImdbId()],
'attachment' => $monitor->getPoster(),
]; ];
}); });

View File

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

View File

@@ -3,11 +3,17 @@
namespace App\Monitor\Framework\Controller; namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput; 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\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler; use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput; use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput; use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; 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\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -23,7 +29,7 @@ class WebController extends AbstractController
private readonly Environment $renderer, private readonly Environment $renderer,
) {} ) {}
#[Route('/monitors', name: 'app_monitors', methods: ['GET'])] #[Route('/monitors', name: 'app.monitors', methods: ['GET'])]
public function addMonitor() public function addMonitor()
{ {
return $this->render('monitor/index.html.twig'); return $this->render('monitor/index.html.twig');
@@ -34,4 +40,29 @@ class WebController extends AbstractController
{ {
return $this->render('monitor/upcoming-episodes.html.twig'); 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)] #[ORM\Column(nullable: true)]
private ?int $searchCount = null; private ?int $searchCount = null;
#[ORM\Column(nullable: true)]
private ?string $poster = null;
#[ORM\Column] #[ORM\Column]
private bool $onlyFuture = true; private bool $onlyFuture = true;
@@ -230,6 +233,17 @@ class Monitor
return $this; return $this;
} }
public function getPoster(): ?string
{
return $this->poster;
}
public function setPoster(?string $poster): ?self
{
$this->poster = $poster;
return $this;
}
public function getParent(): ?self public function getParent(): ?self
{ {
return $this->parent; return $this->parent;

View File

@@ -41,4 +41,83 @@ class MonitorRepository extends ServiceEntityRepository
->getQuery(); ->getQuery();
return $query->getResult(); 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,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; namespace App\Tmdb\Framework\Controller;
use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher; use App\Base\Util\ImdbMatcher;
use App\Library\Action\Result\LibrarySearchResult;
use App\Tmdb\TmdbClient; use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbMovieGenre;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use App\Tmdb\TmdbTvShowGenre;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboBundle;
class ApiController extends AbstractController class ApiController extends AbstractController
{ {
@@ -47,4 +52,50 @@ class ApiController extends AbstractController
'results' => $results, '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\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto; use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto; use App\Tmdb\Dto\GenreDto;
use App\Tmdb\Dto\WatchProviderDto;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
@@ -28,6 +29,7 @@ class TmdbResultDenormalizer implements DenormalizerInterface
$result->directors = $this->getDirectors($data); $result->directors = $this->getDirectors($data);
$result->producers = $this->getProducers($data); $result->producers = $this->getProducers($data);
$result->creators = $this->getCreators($data); $result->creators = $this->getCreators($data);
$result->watchProviders = $this->getWatchProviders($data);
return $result; return $result;
} }
@@ -87,6 +89,17 @@ class TmdbResultDenormalizer implements DenormalizerInterface
->toArray(); ->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( public function supportsDenormalization(
mixed $data, mixed $data,
string $type, string $type,

View File

@@ -6,9 +6,11 @@ use Aimeos\Map;
use App\Base\Enum\MediaType; use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher; use App\Base\Util\ImdbMatcher;
use App\Tmdb\Dto\TmdbEpisodeDto; use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\Dto\WatchProviderDto;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Tmdb\Api\Find; use Tmdb\Api\Find;
use Tmdb\Client; use Tmdb\Client;
@@ -19,6 +21,7 @@ use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener; use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener; use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\RequestEvent; use Tmdb\Event\RequestEvent;
use Tmdb\Repository\DiscoverRepository;
use Tmdb\Repository\MovieRepository; use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository; use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository; use Tmdb\Repository\TvEpisodeRepository;
@@ -30,6 +33,7 @@ use Tmdb\Token\Api\BearerToken;
class TmdbClient class TmdbClient
{ {
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500"; const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
const APPEND_TO_RESPONSE = 'external_ids,credits';
protected Client $client; protected Client $client;
protected MovieRepository $movieRepository; protected MovieRepository $movieRepository;
@@ -38,6 +42,8 @@ class TmdbClient
protected TvEpisodeRepository $tvEpisodeRepository; protected TvEpisodeRepository $tvEpisodeRepository;
protected SearchRepository $searchRepository; protected SearchRepository $searchRepository;
protected DiscoverRepository $discoverRepository;
protected array $mediaTypeMap = [ protected array $mediaTypeMap = [
MediaType::Movie->value => MediaType::Movie->value, MediaType::Movie->value => MediaType::Movie->value,
MediaType::TvShow->value => MediaType::TvShow->value, MediaType::TvShow->value => MediaType::TvShow->value,
@@ -48,11 +54,14 @@ class TmdbClient
protected $repos = []; protected $repos = [];
private ?string $originalLanguage = 'en';
public function __construct( public function __construct(
private readonly SerializerInterface $serializer, private readonly SerializerInterface $serializer,
private readonly CacheItemPoolInterface $cache, private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey, #[Autowire(env: 'TMDB_API')] string $apiKey,
#[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
) { ) {
$this->client = new Client( $this->client = new Client(
[ [
@@ -74,7 +83,7 @@ class TmdbClient
'hydration' => [ 'hydration' => [
'event_listener_handles_hydration' => false, 'event_listener_handles_hydration' => false,
'only_for_specified_models' => [] 'only_for_specified_models' => []
] ],
] ]
); );
@@ -107,11 +116,14 @@ class TmdbClient
$this->tvSeasonRepository = new TvSeasonRepository($this->client); $this->tvSeasonRepository = new TvSeasonRepository($this->client);
$this->tvEpisodeRepository = new TvEpisodeRepository($this->client); $this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
$this->searchRepository = new SearchRepository($this->client); $this->searchRepository = new SearchRepository($this->client);
$this->discoverRepository = new DiscoverRepository($this->client);
$this->repos = [ $this->repos = [
MediaType::Movie->value => $this->movieRepository, MediaType::Movie->value => $this->movieRepository,
MediaType::TvShow->value => $this->tvRepository, MediaType::TvShow->value => $this->tvRepository,
MediaType::TvEpisode->value => $this->tvEpisodeRepository, MediaType::TvEpisode->value => $this->tvEpisodeRepository,
]; ];
$this->originalLanguage = $originalLanguage;
} }
public function search(string $term): TmdbResult|Map public function search(string $term): TmdbResult|Map
@@ -129,11 +141,55 @@ class TmdbClient
return $this->parseListOfResults($results); 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 public function movieDetails(string $imdbId): ?TmdbResult
{ {
$tmdbId = $this->findByImdbId($imdbId)['id']; $tmdbId = $this->findByImdbId($imdbId)['id'];
return $this->parseResult( 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, MediaType::Movie->value,
$imdbId $imdbId
); );
@@ -142,7 +198,7 @@ class TmdbClient
public function tvshowDetails(string $imdbId): ?TmdbResult public function tvshowDetails(string $imdbId): ?TmdbResult
{ {
$tmdbId = $this->findByImdbId($imdbId)['id']; $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) { $media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
return $data['season_number'] !== 0 && return $data['season_number'] !== 0 &&
@@ -163,7 +219,7 @@ class TmdbClient
public function tvSeasonDetails(string $tmdbId, int $season): array 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) { $result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
$data['still_path'] = self::POSTER_IMG_PATH . $data['still_path']; $data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
$data['poster'] = $data['still_path']; $data['poster'] = $data['still_path'];
@@ -174,7 +230,7 @@ class TmdbClient
public function tvEpisodeDetails(string $tmdbId, string $showImdbId, int $season, int $episode): TmdbResult|TmdbEpisodeDto|null 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( return $this->parseResult(
$result, $result,
MediaType::TvEpisode->value, MediaType::TvEpisode->value,
@@ -191,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 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) { $results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::Movie->value; $result['media_type'] = MediaType::Movie->value;
return $result; return $result;
@@ -206,7 +273,10 @@ class TmdbClient
public function popularTvShows(int $resultCount = 6): Map 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) { $results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::TvShow->value; $result['media_type'] = MediaType::TvShow->value;
return $result; 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\CrewMemberDto;
use App\Tmdb\Dto\GenreDto; use App\Tmdb\Dto\GenreDto;
use App\Tmdb\Dto\TmdbEpisodeDto; use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\Dto\WatchProviderDto;
use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\SerializedPath; use Symfony\Component\Serializer\Attribute\SerializedPath;
@@ -57,5 +58,6 @@ class TmdbResult
public ?int $runtime = null, public ?int $runtime = null,
public ?int $numberSeasons = null, public ?int $numberSeasons = null,
public ?int $latestSeason = 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; namespace App\Torrentio\Client;
use Aimeos\Map;
use App\Download\Framework\Repository\DownloadRepository;
use App\Torrentio\Result\ResultFactory; use App\Torrentio\Result\ResultFactory;
use App\Torrentio\Exception\TorrentioRateLimitException; use App\Torrentio\Exception\TorrentioRateLimitException;
use Psr\Log\LoggerInterface;
class Torrentio class Torrentio
{ {
private array $badDownloadUrls = [];
public function __construct( 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 public function search(string $imdbCode, string $type, bool $parseResults = true): array
{ {
@@ -47,6 +58,16 @@ class Torrentio
continue; 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 ( if (
array_key_exists('behaviorHints', $stream) && array_key_exists('behaviorHints', $stream) &&
array_key_exists('bingeGroup', $stream['behaviorHints']) array_key_exists('bingeGroup', $stream['behaviorHints'])
@@ -68,4 +89,10 @@ class Torrentio
return $results; 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; namespace App\Torrentio\Framework\Controller;
use App\Base\ConfigResolver;
use App\Base\Service\Broadcaster; use App\Base\Service\Broadcaster;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler; use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput; use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput; use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Client\Torrentio;
use App\Torrentio\Exception\TorrentioRateLimitException; use App\Torrentio\Exception\TorrentioRateLimitException;
use Carbon\Carbon; use Carbon\Carbon;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
@@ -21,9 +23,12 @@ use Symfony\UX\Turbo\TurboBundle;
final class WebController extends AbstractController final class WebController extends AbstractController
{ {
const REAL_DEBRID_STREAM_URL = 'https://real-debrid.com/streaming-%s';
public function __construct( public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler, private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler, private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly ConfigResolver $configResolver,
private readonly Broadcaster $broadcaster, private readonly Broadcaster $broadcaster,
) {} ) {}
@@ -37,10 +42,14 @@ final class WebController extends AbstractController
$input->imdbId $input->imdbId
); );
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) { if (true === $this->configResolver->isTorrentioCacheEnabled()) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
return $this->getMovieOptionsHandler->handle($input->toCommand()); $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')) { if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request); return $this->sendFragmentResponse($results, $request);
@@ -63,10 +72,14 @@ final class WebController extends AbstractController
); );
try { try {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) { if (true === $this->configResolver->isTorrentioCacheEnabled()) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
return $this->getTvShowOptionsHandler->handle($input->toCommand()); $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')) { if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request); 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

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

@@ -112,4 +112,18 @@ class UtilExtension
return new EpisodeIdDto($season, $episode); 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

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

View File

@@ -101,6 +101,18 @@
"bin/phpunit" "bin/phpunit"
] ]
}, },
"sentry/sentry-symfony": {
"version": "5.6",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "5.0",
"ref": "b6cb4b34429dadecd7187852123be19d628fa37a"
},
"files": [
"config/packages/sentry.yaml"
]
},
"spomky-labs/pwa-bundle": { "spomky-labs/pwa-bundle": {
"version": "1.2.5" "version": "1.2.5"
}, },
@@ -232,6 +244,18 @@
"config/packages/messenger.yaml" "config/packages/messenger.yaml"
] ]
}, },
"symfony/monolog-bundle": {
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "f5f5f3e4c23f5349796b7de587f19c51e7104299"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/notifier": { "symfony/notifier": {
"version": "7.3", "version": "7.3",
"recipe": { "recipe": {

View File

@@ -12,6 +12,7 @@
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
<script src="https://bugs.caldwell.digital/js-sdk-loader/8dddf7fb26fbec602ad212173a942450.min.js" crossorigin="anonymous"></script>
{% endblock %} {% endblock %}
</head> </head>
<body class="bg-cyan-950 flex flex-col h-full"> <body class="bg-cyan-950 flex flex-col h-full">

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
{{ pwa() }} {{ pwa() }}
<title>{% block title %}Welcome!{% endblock %}</title> <title>{% block title %}Torsearch{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %} {% block stylesheets %}
<link rel="stylesheet" href="{{ asset('styles/app.css') }}"> <link rel="stylesheet" href="{{ asset('styles/app.css') }}">
@@ -12,6 +12,10 @@
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
{% if sentry_enabled() %}
<script src="{{ sentry_javascript_url }}" crossorigin="anonymous"></script>
{% endif %}
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script> <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
{% endblock %} {% endblock %}
</head> </head>
@@ -22,9 +26,9 @@
</div> </div>
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll"> <div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
<twig:Header /> <twig:Header />
<div class="flex justify-between items-center"> <div class="flex flex-col md:flex-row justify-between md:items-center">
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2> <h2 class="px-4 mt-4 mb-4 md:mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<div class="flex mt-4 gap-2 items-center grow-0 md:px-4"> <div class="flex mx-4 mb-2 md:mt-4 gap-2 items-center grow-0 md:px-4">
{% block action_buttons %}{% endblock %} {% block action_buttons %}{% endblock %}
</div> </div>
</div> </div>

View File

@@ -1,17 +1,15 @@
{% extends 'bare.html.twig' %} {% extends 'bare.html.twig' %}
{% block body %} {% block body %}
<h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">{{ }}</h2> <h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">500</h2>
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade"> <div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[540px] border-orange-500 border-2 text-gray-50 animate-fade">
<div class="flex flex-col m-0 text-center"> <div class="flex flex-col m-0 text-center">
<h3 class="text-2xl text-bold text-center text-gray-50">Oh crap!</h3> <h3 class="text-2xl text-bold text-center text-gray-50">Oh crap!</h3>
<small>(or is it?)</small>
</div> </div>
<p class="mb-2">There are many things I'm capable of, but this ain't one of 'em!</p> <p class="mb-2">There are many things I'm capable of, but this ain't one of 'em!</p>
<pre class="bg-gray-800 text-white p-4 rounded-md overflow-x-auto"> <p class="text-sm mb-1">
<code class="language-plaintext"> <code>{{ exception.file }}</code>
{{ exception.message }} </p>
</code> <pre class="bg-gray-800 text-white p-4 rounded-md overflow-x-auto"><code class="language-plaintext">{{ exception.message|trim }}</code></pre>
</pre>
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@@ -17,10 +17,17 @@
class="px-6 py-3 text-start"> class="px-6 py-3 text-start">
ID ID
</th> </th>
{% if null != parentMonitorId %}
<th scope="col" <th scope="col"
class="hidden md:table-cell px-6 py-3 text-start"> class="hidden md:table-cell px-6 py-3 text-start">
Search Count Search Count
</th> </th>
{% else %}
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start">
Episodes
</th>
{% endif %}
<th scope="col" <th scope="col"
class="hidden md:table-cell px-6 py-3 text-start"> class="hidden md:table-cell px-6 py-3 text-start">
Created at Created at
@@ -48,7 +55,7 @@
{% if this.isWidget and this.monitors.items|length > 5 %} {% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all"> <tr id="monitor_view_all">
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black"> <td colspan="100%" class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black">
<a href="{{ path('app_monitors') }}">View All Monitors</a> <a href="{{ path('app.monitors') }}">View All Monitors</a>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}

View File

@@ -1,16 +1,18 @@
<tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900" <tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer"
monitor-id="{{ monitor.id }}" monitor-id="{{ monitor.id }}"
parent-id="{{ monitor.parent.id ?? null }}"
imdb-id="{{ monitor.imdbId }}" imdb-id="{{ monitor.imdbId }}"
media-title="{{ monitor.title }}" media-title="{{ monitor.title }}"
season="{{ monitor.season }}" season="{{ monitor.season }}"
episode="{{ monitor.episode }}" episode="{{ monitor.episode ?? '-' }}"
status="{{ monitor.status }}" status="{{ monitor.status }}"
search-count="{{ monitor.searchCount }}" search-count="{{ monitor.searchCount }}"
media-type="{{ monitor.monitorType|monitor_type }}" media-type="{{ monitor.monitorType|monitor_type }}"
episode-id="{{ monitor|monitor_media_id }}" episode-id="{{ monitor|monitor_media_id }}"
created-at="{{ monitor.createdAt|date('m/d/Y g:i a') }}" created-at="{{ monitor.createdAt|date('m/d/Y g:i a') }}"
last-search="{{ monitor.lastSearch|date('m/d/Y g:i a') }}" last-search="{{ monitor.lastSearch|date('m/d/Y g:i a') }}"
downloaded-at="{{ monitor.downloadedAt|date('m/d/Y g:i a') }}" downloaded-at="{{null != monitor.downloadedAt ? monitor.downloadedAt|date('m/d/Y g:i a') : '-' }}"
air-date="{{ null != monitor.airDate ? monitor.airDate|date('m/d/Y g:i a') : '-' }}"
> >
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 truncate"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}" <a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
@@ -26,9 +28,17 @@
<td class="px-6 py-4 whitespace-nowrap text-sm"> <td class="px-6 py-4 whitespace-nowrap text-sm">
{{ monitor|monitor_media_id }} {{ monitor|monitor_media_id }}
</td> </td>
{# Monitor is a CHILD monitor #}
{% if null != monitor.parent %}
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm"> <td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{{ monitor.searchCount }} {{ monitor.searchCount }}
</td> </td>
{% else %}
{# Monitor is a PARENT monitor #}
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{{ monitor.children|length }}
</td>
{% endif %}
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm"> <td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{{ monitor.createdAt|date('m/d/Y h:i a') }} {{ monitor.createdAt|date('m/d/Y h:i a') }}
</td> </td>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<dialog{{ attributes }} is="preview-content-dialog" class="py-3 px-4 w-full md:w-[50rem] rounded-md dark:bg-gray-950/80 dark:border-2 dark:border-orange-500 dark:text-white backdrop-filter backdrop-blur-3xl"> <dialog{{ attributes }} is="preview-content-dialog" class="py-3 px-4 w-full md:w-[{{ mdWidth|default('50rem') }}] rounded-md dark:bg-gray-950/80 dark:border-2 dark:border-orange-500 dark:text-white backdrop-filter backdrop-blur-3xl">
<div class="flex flex-row justify-end"> <div class="flex flex-row justify-end">
<twig:ux:icon name="ic:twotone-cancel" width="16.75px" height="16.75px" class="modal-close rounded-full align-middle text-red-600 hover:text-red-700" /> <twig:ux:icon name="ic:twotone-cancel" width="16.75px" height="16.75px" class="modal-close rounded-full align-middle text-red-600 hover:text-red-700" />
</div> </div>

View File

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

View File

@@ -0,0 +1,10 @@
[program:torsearch-worker]
command=/app/bin/console messenger:consume async -vv --time-limit 3600
numprocs={{ replicas|default(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={{ replicas|default(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={{ replicas|default(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

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,12 @@
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script> <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
<div class="p-4"> <div class="p-4">
<div class="mb-4 text-white">
<a href="{{ path('app.monitors') }}" class="btn btn-primary inline-flex items-center gap-2">
<twig:ux:icon name="fluent-mdl2:back" width="14" /> Back to Monitors
</a>
</div>
<twig:Card title="Upcoming episodes of shows your monitoring"> <twig:Card title="Upcoming episodes of shows your monitoring">
<a href="{{ path('app.monitors.ical', {email: app.user.email}) }}" title="Subscribe to the 'Upcoming Episodes' calendar via iCal. Click to export the events to a .ics file or copy the link and use it to subscribe in a calendar app that supports iCal/ics calendars." class="mb-2 self-end dark:text-white decoration-underline"> <a href="{{ path('app.monitors.ical', {email: app.user.email}) }}" title="Subscribe to the 'Upcoming Episodes' calendar via iCal. Click to export the events to a .ics file or copy the link and use it to subscribe in a calendar app that supports iCal/ics calendars." class="mb-2 self-end dark:text-white decoration-underline">
<twig:ux:icon name="lets-icons:calendar-add-light" width="24" class="text-orange-500" /> <twig:ux:icon name="lets-icons:calendar-add-light" width="24" class="text-orange-500" />
@@ -34,6 +40,7 @@
} }
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
const modal = document.getElementById('previewModal'); const modal = document.getElementById('previewModal');
modal.setAttribute('mdWidth', '25rem');
let data = await fetch('/api/monitor/upcoming-episodes'); let data = await fetch('/api/monitor/upcoming-episodes');
data = (await data.json())['data']; data = (await data.json())['data'];
@@ -47,7 +54,12 @@
eventClick: function (data) { eventClick: function (data) {
modal.display({ modal.display({
heading: data.event.title, heading: data.event.title,
content: `<p>${data.event.startStr}</p>` content: `
<div class="flex flex-col gap-4 justify-center items-center">
<img src="${data.event.extendedProps.attachment}" class="w-[90%] rounded-md" />
<p>${data.event.startStr}</p>
</div>
`
}) })
} }
}); });

View File

@@ -0,0 +1,151 @@
{% extends 'base.html.twig' %}
{% block title %}{{ monitor.title }} &mdash; Monitors &mdash; Torsearch{% endblock %}
{% block h2 %}Monitors{% endblock %}
{% block body %}
<div class="px-4 py-2">
<div class="mb-4 text-white">
<a href="{{ path('app.monitors') }}" class="btn btn-primary inline-flex items-center gap-2">
<twig:ux:icon name="fluent-mdl2:back" width="14" /> Back to Monitors
</a>
</div>
<twig:Card title="Viewing your monitors for {{ monitor.title }}">
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6">
{% if results.media.poster != null %}
<img class="w-full md:w-[12.5rem] rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-full md:w-[12.5rem] h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="24" name="hugeicons:loading-01" />
</div>
{% endif %}
<div class="w-full flex flex-col">
<div class="mb-4 flex flex-row gap-2 justify-between">
<h3 class="text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} ({{ results.media.year }})
</h3>
</div>
<p class="text-gray-50 mb-4">
{{ results.media.description }}
</p>
<div class="text-gray-50 mb-2">
<div id="people" class="mb-1">
{% if results.media.stars != null %}
<strong>Starring</strong>: {{ results.media.stars|join(', ') }} <br />
{% endif %}
{% if results.media.directors != null %}
<strong>Directors</strong>: {{ results.media.directors|join(', ') }} <br />
{% endif %}
{% if results.media.producers != null %}
<strong>Producers</strong>: {{ results.media.producers|join(', ') }} <br />
{% endif %}
{% if results.media.creators != null %}
<strong>Creators</strong>: {{ results.media.creators|join(', ') }} <br />
{% endif %}
</div>
<div id="dates" class="mb-1">
{% if results.media.premiereDate %}
<strong>Premiered</strong>: {{ results.media.premiereDate|date('n/j/Y', 'UTC') }} <br />
{% endif %}
</div>
{% if results.media.genres != null %}
<div id="genres" class="text-gray-50 my-4">
{% for genre in results.media.genres %}
<small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small>
{% endfor %}
</div>
{% endif %}
</div>
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-col gap-4">
<div class="flex flex-col grow text-white">
<strong class="mb-1">In Your Library</strong>
<div class="flex flex-col md:flex-row border-t-orange-500 text-xs gap-4">
<div class="flex flex-col">
<span class="text-sm mb-1">Seasons</span>
<div class="flex flex-row border p-2 border-orange-500 rounded-lg text-xs items-center">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white">
<span>{{ library.seasonCount }}</span> full
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white">
<span>{{ library.partialSeasonCount }}</span> partial
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white">
<span>{{ library.missingSeasonCount }}</span> missing
</span>
</div>
</div>
<div class="flex flex-col">
<span class="text-sm mb-1">Episodes</span>
<div class="flex flex-row border p-2 border-orange-500 rounded-lg text-xs items-center">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-cyan-500 rounded-lg text-white">
<span>{{ library.episodeCount }}</span> existing
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-cyan-500 rounded-lg text-white">
<span>{{ library.missingEpisodeCount }}</span> missing
</span>
</div>
</div>
<div class="flex flex-col">
<span class="text-sm mb-1">Monitors</span>
<div class="flex flex-row border p-2 border-orange-500 rounded-lg text-xs items-center">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white">
<span>{{ library.monitorCount }}</span> total
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white">
<span>{{ library.activeMonitorCount }}</span> active
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-red-500 rounded-lg text-white">
<span>{{ library.completeMonitorCount }}</span> complete
</span>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span class="results-count-number" id="movie_results_count">-</span> results
</span>
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
title: results.media.title,
block: 'media_exists_badge',
target: "meb_" ~ results.media.imdbId
}) }}">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing
</span>
</twig:Turbo:Frame>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.premiereDate|date('n/j/Y', 'UTC') }}
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white" title="This movie has a runtime of {{ results.media.runtime }} minutes.">
{{ results.media.runtime }} minutes
</span>
</div>
{% endif %}
</div>
</div>
<twig:MonitorList :parentMonitorId="monitor.id" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>
{% endblock %}

View File

@@ -76,48 +76,57 @@
{% if results.media.genres != null %} {% if results.media.genres != null %}
<div id="genres" class="text-gray-50 my-4"> <div id="genres" class="text-gray-50 my-4">
{% for genre in results.media.genres %} {% for genre in results.media.genres %}
<small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small> <a href="{{ url('app.discover.browse_genre', {mediaType: results.media.mediaType, genreId: genre.id}) }}" class="px-2 py-1 border border-orange-500 rounded-full text-sm">{{ genre }}</a>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if results.media.mediaType == "tvshows" %} <div class="flex flex-col gap-2">
<div class="flex flex-row justify-start items-end grow text-xs"> {% if results.media.mediaType == "tvshows" %}
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-orange-500 rounded-lg text-white"> <div class="flex flex-row justify-start items-end grow text-xs">
<span>{{ results.media.numberSeasons }}</span> season(s) <span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-orange-500 rounded-lg text-white">
</span> <span>{{ results.media.numberSeasons }}</span> season(s)
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title='"{{ results.media.title }}" first aired on {{ results.media.premiereDate|date(null, 'UTC') }}.'> </span>
{{ results.media.premiereDate|date(null, 'UTC') }} <span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title='"{{ results.media.title }}" first aired on {{ results.media.premiereDate|date(null, 'UTC') }}.'>
</span> {{ results.media.premiereDate|date(null, 'UTC') }}
</div> </span>
{% endif %} </div>
{% endif %}
{% if "movies" == results.media.mediaType %} {% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow text-xs"> <div class="flex flex-row justify-start items-end grow text-xs">
<span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white"> <span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span class="results-count-number" id="movie_results_count">-</span> results <span class="results-count-number" id="movie_results_count">-</span> results
</span> </span>
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', { <twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
title: results.media.title, title: results.media.title,
block: 'media_exists_badge', block: 'media_exists_badge',
target: "meb_" ~ results.media.imdbId target: "meb_" ~ results.media.imdbId
}) }}">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing
</span>
</twig:Turbo:Frame>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.premiereDate|date('n/j/Y', 'UTC') }}
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white" title="This movie has a runtime of {{ results.media.runtime }} minutes.">
{{ results.media.runtime }} minutes
</span>
</div>
{% endif %}
<twig:Turbo:Frame id="watch_providers_frame" src="{{ path('api.tmdb.watch_providers', {
mediaType: results.media.mediaType,
tmdbId: results.media.tmdbId,
block: 'watch_providers',
target: 'watch_providers_frame'
}) }}"> }) }}">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing
</span>
</twig:Turbo:Frame> </twig:Turbo:Frame>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.premiereDate|date('n/j/Y', 'UTC') }}
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white" title="This movie has a runtime of {{ results.media.runtime }} minutes.">
{{ results.media.runtime }} minutes
</span>
</div> </div>
{% endif %}
</div> </div>
</div> </div>

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