Compare commits

...

66 Commits

Author SHA1 Message Date
Brock H Caldwell
e39cb6e9bd WIP: working move/tv show download 2026-03-21 23:22:41 -05:00
Brock H Caldwell
9e2c5410ba Merge branch 'dev-filename-convention'
All checks were successful
CI / build-test (push) Successful in 2m57s
2026-03-18 09:29:17 -05:00
Brock H Caldwell
4087543e78 fix: monitors check for episodes in folders w/ & w/o the year appended (backwards compatible) 2026-03-18 09:29:05 -05:00
Brock H Caldwell
d358ef8de6 fix: adds build on push to main
All checks were successful
CI / build-test (push) Successful in 2m55s
2026-03-17 23:19:23 -05:00
Brock H Caldwell
79d9d61592 fix: includes year in media directory name 2026-03-17 23:16:11 -05:00
Brock H Caldwell
4d0a198510 fix(ci): moves build_base_image to workflow_dispatch 2026-03-15 19:58:45 -05:00
Brock H Caldwell
5da1dde24d chore: adds enum for monitor types
Some checks failed
CI / build-base-app (push) Failing after 43s
CI / build-base-worker-supervisord (push) Successful in 43s
2026-03-15 19:57:00 -05:00
Brock H Caldwell
6ed327f78e task(ci): update base image
Some checks failed
CI / build-base-worker-supervisord (push) Failing after 37s
CI / build-base-app (push) Failing after 38s
2026-03-11 08:37:33 -05:00
Brock H Caldwell
1827c1df71 task(ci): update base image
Some checks failed
CI / build-base-app (push) Failing after 30s
CI / build-base-worker (push) Successful in 34s
CI / build-base-worker-supervisord (push) Successful in 2m1s
2026-03-11 08:26:08 -05:00
Brock H Caldwell
0c5fd2544b task(ci): update base image
Some checks failed
CI / build-base-worker (push) Successful in 35s
CI / build-base-app (push) Successful in 35s
CI / build-base-worker-supervisord (push) Failing after 37s
2026-03-11 08:22:03 -05:00
Brock H Caldwell
a6d5e2b026 task(ci): update base image 2026-03-10 23:58:47 -05:00
Brock H Caldwell
688ea98922 task(ci): update base image 2026-03-10 23:57:11 -05:00
Brock H Caldwell
d55a9cfdd5 task(ci): update base image 2026-03-10 23:55:52 -05:00
Brock H Caldwell
e9021c22fa task(ci): update base image
Some checks failed
CI / build-base-worker (push) Successful in 53s
CI / build-base-worker-supervisord (push) Failing after 2m4s
CI / build-base-app (push) Successful in 2m19s
2026-03-10 23:51:32 -05:00
Brock H Caldwell
939660a715 task(ci): build base images
Some checks failed
CI / build-base-worker (push) Failing after 32s
CI / build-base-app (push) Successful in 40s
CI / build-base-worker-supervisord (push) Failing after 2m19s
2026-03-10 23:48:12 -05:00
Brock H Caldwell
fb3e7b20ff task(ci): build base images
Some checks failed
CI / build-base-worker (push) Failing after 35s
CI / build-base-worker-supervisord (push) Successful in 50s
CI / build-base-app (push) Successful in 3m1s
2026-03-10 23:09:20 -05:00
Brock H Caldwell
699dbaabc3 fix: build script, installs php exts to worker image
All checks were successful
CI / build-test (push) Successful in 3m8s
2026-03-10 20:22:53 -05:00
Brock H Caldwell
484ac40d99 fix: broken mercure 2026-03-10 20:21:27 -05:00
Brock H Caldwell
b8a22e63c9 WIP: CI docker
All checks were successful
CI / build-test (push) Successful in 3m15s
2026-03-09 15:20:41 -05:00
Brock H Caldwell
e489f73f7c fix(ci): adds --ignore-platform-reqs to composer
All checks were successful
CI / build-test (push) Successful in 2m54s
2026-03-09 13:59:25 -05:00
Brock H Caldwell
554b7774f6 Update docker
All checks were successful
CI / build-test (push) Successful in 3m10s
2026-03-09 13:14:27 -05:00
Brock H Caldwell
86ea9d5b38 Update ci
All checks were successful
CI / build-test (push) Successful in 3m52s
2026-03-09 12:45:40 -05:00
Brock H Caldwell
a61da34f2a Update ci
All checks were successful
CI / build-test (push) Successful in 3m6s
2026-03-09 12:41:33 -05:00
Brock H Caldwell
225754dfe5 Update ci
All checks were successful
CI / build-test (push) Successful in 3m25s
2026-03-09 12:01:56 -05:00
Brock H Caldwell
6e358fe1eb Update ci
All checks were successful
CI / build-test (push) Successful in 3m7s
2026-03-09 11:51:26 -05:00
Brock H Caldwell
a2da2698e3 Update ci
Some checks failed
CI / build-test (push) Failing after 1m57s
2026-03-09 11:41:59 -05:00
Brock H Caldwell
ee017d7ae6 Update ci
All checks were successful
CI / build-test (push) Successful in 14m37s
2026-03-08 23:37:49 -05:00
Brock H Caldwell
b0b5211e88 Update ci
All checks were successful
CI / build-test (push) Successful in 1m46s
2026-03-08 23:35:06 -05:00
Brock H Caldwell
c6f0220889 Update ci
All checks were successful
CI / build-test (push) Successful in 1m52s
2026-03-08 23:31:14 -05:00
Brock H Caldwell
b1cd1cf0bf Update ci
All checks were successful
CI / build-test (push) Successful in 1m53s
2026-03-08 23:28:05 -05:00
Brock H Caldwell
dab6504e71 Update ci
All checks were successful
CI / build-test (push) Successful in 1m44s
2026-03-08 23:17:44 -05:00
Brock H Caldwell
e361e998b3 Update ci
Some checks failed
CI / build-test (push) Failing after 6s
2026-03-08 23:16:40 -05:00
Brock H Caldwell
bc73625121 Update ci
Some checks failed
CI / build-test (push) Failing after 7s
2026-03-08 23:15:43 -05:00
Brock H Caldwell
a2439f3619 Update ci
All checks were successful
CI / build-test (push) Successful in 2m3s
2026-03-08 22:46:39 -05:00
Brock H Caldwell
edfb1b92cb Update ci
All checks were successful
CI / build-test (push) Successful in 1m59s
2026-03-08 22:34:57 -05:00
Brock H Caldwell
1097e4c313 Update ci
Some checks failed
CI / build-test (push) Has been cancelled
2026-03-08 22:28:30 -05:00
Brock H Caldwell
fe10a8d4b0 Update ci
Some checks failed
CI / build-test (push) Has been cancelled
2026-03-08 22:28:02 -05:00
Brock H Caldwell
96da4ce1b0 Update ci
Some checks failed
CI / build-test (push) Has been cancelled
2026-03-08 22:27:26 -05:00
Brock H Caldwell
6aad92a0c6 Update ci
Some checks failed
CI / build-test (push) Has been cancelled
2026-03-08 22:25:33 -05:00
Brock H Caldwell
7fc7db0577 Update ci
Some checks failed
CI / build-test (push) Has been cancelled
2026-03-08 22:12:53 -05:00
Brock H Caldwell
e5ed2d9556 Update ci
Some checks failed
CI / build-test (push) Has been cancelled
2026-03-08 22:10:23 -05:00
Brock H Caldwell
fbe4e736c8 Update ci
Some checks failed
CI / build-test (push) Has been cancelled
2026-03-08 22:08:52 -05:00
Brock H Caldwell
bc635d5b76 Update ci
All checks were successful
CI / build-test (push) Successful in 15m13s
2026-03-08 21:01:20 -05:00
Brock H Caldwell
c3a9c69c91 Update ci
Some checks failed
CI / build-test (push) Failing after 2m48s
2026-03-08 20:57:46 -05:00
Brock H Caldwell
b043bb9cc9 Update ci
Some checks failed
CI / build-test (push) Failing after 1m57s
2026-03-08 20:51:23 -05:00
Brock H Caldwell
d9afa9b558 Update ci
Some checks failed
CI / build-test (push) Failing after 2m34s
2026-03-08 20:45:52 -05:00
Brock H Caldwell
4f0b873bed Update ci
Some checks failed
CI / build-test (push) Failing after 1m52s
2026-03-08 20:43:04 -05:00
Brock H Caldwell
fb067d5396 Update ci
All checks were successful
CI / build-test (push) Successful in 1m59s
2026-03-08 20:37:44 -05:00
Brock H Caldwell
dbaf7c4880 Update ci
All checks were successful
CI / build-test (push) Successful in 1m50s
2026-03-08 20:29:36 -05:00
Brock H Caldwell
4750c53b58 Merge branch 'main' of https://code.caldwell.digital/home/torsearch
Some checks failed
CI / build-test (push) Failing after 1m38s
2026-03-08 20:25:14 -05:00
Brock H Caldwell
aa0ce72d35 fix: errors preventing builds 2026-03-08 20:24:59 -05:00
6360e6495f Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 1m18s
2026-03-08 23:39:50 +00:00
Brock H Caldwell
ed2f797ac2 Merge branch 'main' of https://code.caldwell.digital/home/torsearch
Some checks failed
CI / build-test (push) Failing after 1m23s
2026-03-08 18:37:03 -05:00
Brock H Caldwell
91f91c20fa fix: pins doctrine to prod, dev, & test envs 2026-03-08 18:36:03 -05:00
2f7d276781 Update .env
Some checks failed
CI / build-test (push) Failing after 1m29s
2026-03-08 23:01:28 +00:00
e22306225b Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 1m23s
2026-03-08 21:26:22 +00:00
6a860a4d75 Update .gitea/workflows/sonarqube_scans.yml
All checks were successful
CI / build-test (push) Successful in 1m43s
2026-03-08 21:21:52 +00:00
5ff89b905f Update .gitea/workflows/sonarqube_scans.yml
All checks were successful
CI / build-test (push) Successful in 1m13s
2026-03-08 21:18:23 +00:00
49b017de3d Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 1m18s
2026-03-08 21:11:13 +00:00
937b673be6 Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 5m41s
2026-03-08 21:01:49 +00:00
3e04d0a82d Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 56s
2026-03-08 20:51:11 +00:00
706e8e9892 Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 4m50s
2026-03-08 20:08:46 +00:00
154292530a Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 43s
2026-03-08 02:52:29 +00:00
82c3f7bb78 Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 1m19s
2026-03-08 02:49:42 +00:00
e7f8f278ee Update .gitea/workflows/sonarqube_scans.yml
Some checks failed
CI / build-test (push) Failing after 1m40s
2026-03-08 00:42:40 +00:00
Brock H Caldwell
c4b3fb215c feat: status indicators in header for tmdb & torrentio
Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after -1m38s
2026-03-07 11:11:33 -06:00
33 changed files with 660 additions and 209 deletions

5
.env
View File

@@ -13,7 +13,7 @@
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
APP_URL=
###> symfony/framework-bundle ###
APP_ENV=prod
APP_SECRET=
@@ -27,9 +27,12 @@ APP_SECRET=
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
DATABASE_URL=
###< doctrine/doctrine-bundle ###
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!"
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages

View File

@@ -0,0 +1,59 @@
name: CI
on:
workflow_dispatch:
jobs:
build-base-app:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
- name: Login to registry
uses: docker/login-action@v2
with:
registry: "${{ vars.REGISTRY_URL }}"
username: "${{ vars.REGISTRY_USER }}"
password: "${{ vars.REGISTRY_PASS }}"
- name: Build and push
uses: docker/build-push-action@v5
env:
APP_FRANKENPHP_TAG: php8.4
with:
context: .
push: true
file: docker/Dockerfile.base.worker
platforms: linux/amd64
build-args: |
APP_FRANKENPHP_TAG=${{ env.APP_FRANKENPHP_TAG }}
tags: |
code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG}
code.caldwell.digital/home/torsearch-base:latest
build-base-worker-supervisord:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
- name: Login to registry
uses: docker/login-action@v2
with:
registry: "${{ vars.REGISTRY_URL }}"
username: "${{ vars.REGISTRY_USER }}"
password: "${{ vars.REGISTRY_PASS }}"
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
file: docker/Dockerfile.base.worker
platforms: linux/amd64
build-args: |
ALPINE_VERSION=3.22
tags: |
code.caldwell.digital/home/torsearch-base-worker-supervisord:latest

104
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,104 @@
name: CI
on:
push:
branches:
- "main"
tags:
- "v*"
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install phing
run: composer global require phing/phing
- name: Run composer
run: composer install --no-dev --no-scripts --ignore-platform-reqs -o
- name: Build tailwind
run: APP_ENV=build php bin/console tailwind:build
- name: Install frontend assets
run: APP_ENV=build php bin/console importmap:install
- name: Compile assets
run: APP_ENV=build php bin/console asset-map:compile
- name: Login to registry
uses: docker/login-action@v2
with:
registry: "${{ vars.REGISTRY_URL }}"
username: "${{ vars.REGISTRY_USER }}"
password: "${{ vars.REGISTRY_PASS }}"
- name: Create torsearch-app docker image
run: |
tag="${{ gitea.REF_NAME }}"
tmdb_api="${{ vars.TMDB_API }}"
version=${tag:1}
docker build --pull -f docker/Dockerfile.app \
-t code.caldwell.digital/home/torsearch-app:${version} \
-t code.caldwell.digital/home/torsearch-app:latest \
--build-arg "APP_VERSION=${version}" \
--build-arg "TMDB_API=${tmdb_api}" \
.
docker push code.caldwell.digital/home/torsearch-app:${version}
docker push code.caldwell.digital/home/torsearch-app:latest
- name: Create torsearch-worker docker image
run: |
tag="${{ gitea.REF_NAME }}"
tmdb_api="${{ vars.TMDB_API }}"
version=${tag:1}
docker build --pull -f docker/Dockerfile.worker \
-t code.caldwell.digital/home/torsearch-worker:${version} \
-t code.caldwell.digital/home/torsearch-worker:latest \
--build-arg "APP_VERSION=${version}" \
--build-arg "TMDB_API=${tmdb_api}" \
.
docker push code.caldwell.digital/home/torsearch-worker:${version}
docker push code.caldwell.digital/home/torsearch-worker:latest
- name: Create torsearch-scheduler docker image
run: |
tag="${{ gitea.REF_NAME }}"
tmdb_api="${{ vars.TMDB_API }}"
version=${tag:1}
docker build --pull -f docker/Dockerfile.scheduler \
-t code.caldwell.digital/home/torsearch-scheduler:${version} \
-t code.caldwell.digital/home/torsearch-scheduler:latest \
--build-arg "APP_VERSION=${version}" \
--build-arg "TMDB_API=${tmdb_api}" \
.
docker push code.caldwell.digital/home/torsearch-scheduler:${version}
docker push code.caldwell.digital/home/torsearch-scheduler:latest
- name: Create artifact
run: |
file="torsearch-${{ gitea.REF_NAME }}.tar.gz"
touch $file
tar -cvzf $file --exclude=$file .
- name: Create release
uses: akkuman/gitea-release-action@v1
with:
files: |-
"torsearch-${{ gitea.REF_NAME }}.tar.gz"
# - name: Upload artifact
# uses: actions/upload-artifact@v3
# with:
# name: "torsearch-${{ gitea.REF_NAME }}.tar.gz"
# path: "torsearch-${{ gitea.REF_NAME }}.tar.gz"
# compression_level: 9

View File

@@ -1,24 +0,0 @@
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
name: SonarQube Scan
jobs:
sonarqube:
name: SonarQube Trigger
runs-on: ubuntu-latest
steps:
- name: Checking out
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube Scan
uses: https://code.caldwell.digital/tools/sonarqube-action@v0.0.3
with:
host: "https://qube.caldwell.digital"
login: ${{ secrets.SONARQUBE_TOKEN }}
projectName: "torsearch"
projectBaseDir: "./src"

2
assets/bootstrap.js vendored
View File

@@ -5,6 +5,7 @@ import DownloadOptionTr from './components/download-option-tr.js';
import DownloadListRow from './components/download-list-row.js';
import MonitorListRow from './components/monitor-list-row.js';
import MovieContainer from "./components/movie-container.js";
import StatusCheckerSpan from "./components/status-checker-span.js";
import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover';
@@ -24,3 +25,4 @@ customElements.define('movie-container', MovieContainer);
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});
customElements.define('download-list-row', DownloadListRow, {extends: 'tr'});
customElements.define('monitor-list-row', MonitorListRow, {extends: 'tr'});
customElements.define('status-checker-span', StatusCheckerSpan, {extends: 'span'});

View File

@@ -102,6 +102,14 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
}
download() {
const preferencesForm = document.querySelector('[name="user_media_preferences_form"]');
const preferences = {
resolution: preferencesForm.querySelector('[id="user_media_preferences_form_resolution"]').value,
codec: preferencesForm.querySelector('[id="user_media_preferences_form_codec"]').value,
language: preferencesForm.querySelector('[id="user_media_preferences_form_language"]').value,
quality: preferencesForm.querySelector('[id="user_media_preferences_form_quality"]').value,
provider: preferencesForm.querySelector('[id="user_media_preferences_form_provider"]').value,
}
fetch('/api/download', {
method: 'POST',
headers: {
@@ -109,12 +117,11 @@ export default class DownloadOptionTr extends HTMLTableRowElement {
'Accept': 'application/json',
},
body: JSON.stringify({
url: this.url,
title: this.mediaTitle,
filename: this.filename,
mediaType: this.mediaType,
imdbId: this.imdbId,
episodeId: this.episodeId
season: this.season,
episode: this.episode,
filter: preferences,
})
})
.then(res => res.json())

View File

@@ -0,0 +1,40 @@
export default class PreviewContentDialog extends HTMLSpanElement {
#url;
#service;
#status;
#statusTexts = {
200: 'up',
204: 'up'
}
#statusColors = {
200: 'bg-green-500',
204: 'bg-green-500'
}
constructor() {
super();
this.#url = this.getAttribute('url');
this.#service = this.getAttribute('service');
(async () => await this.checkStatus())();
setInterval(async () => await this.checkStatus(), 1000 * 60 * 10);
}
async checkStatus() {
const response = await fetch(this.#url);
this.#status = response.status;
this.setAttribute('title', this.getTitle());
this.classList.remove('bg-red-500', 'bg-green-500');
this.classList.add(this.getColor());
}
getTitle() {
return `${this.#service} is ${this.#statusTexts[this.#status] ?? 'down'}`
}
getColor() {
return this.#statusColors[this.#status] ?? 'bg-red-500';
}
}

View File

@@ -16,6 +16,7 @@ export default class extends Controller {
}
download() {
console.log(new FormData(document.querySelector('[name="user_media_preferences_form"]')).values());
fetch('/api/download', {
method: 'POST',
headers: {
@@ -28,7 +29,8 @@ export default class extends Controller {
filename: this.filenameValue,
mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue,
episodeId: this.episodeIdValue
episodeId: this.episodeIdValue,
filter: new FormData(document.querySelector('[name="user_media_preferences_form"]')).values()
})
})
.then(res => res.json())

View File

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

View File

@@ -27,8 +27,8 @@ services:
tty: true
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_PUBLISHER_JWT_KEY}"
MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_SUBSCRIBER_JWT_KEY}"
depends_on:
database:
condition: service_healthy

View File

@@ -1,40 +1,43 @@
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
when@prod: &prod
sentry:
register_error_listener: true # Disables the ErrorListener to avoid duplicated log in sentry
register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK
options:
release: 'torsearch@%app.version%'
enable_logs: true
traces_sample_rate: 1
profiles_sample_rate: 1
attach_stacktrace: true
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
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
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
when@dev: *prod

View File

@@ -66,6 +66,7 @@ services:
App\:
resource: '../src/'
exclude:
- '../src/Library/Dto'
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'

View File

@@ -16,11 +16,13 @@ RUN apk add --no-cache \
php84-fileinfo \
php84-fpm \
php84-gd \
php84-intl \
php84-mbstring \
php84-mysqli \
php84-opcache \
php84-openssl \
php84-pdo_mysql \
php84-session \
php84-tokenizer \
php84-xml \
supervisor

View File

@@ -15,6 +15,7 @@ FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS app_image
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
# Set the APP_VERSION in the image
ARG APP_VERSION="latest"
ENV APP_VERSION=${APP_VERSION}
ARG TMDB_API=""

View File

@@ -15,6 +15,7 @@ FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS app_image
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
# Set the APP_VERSION in the image
ARG APP_VERSION="latest"
ENV APP_VERSION=${APP_VERSION}
ARG TMDB_API=""

View File

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

View File

@@ -113,9 +113,15 @@ class MediaFiles
return Map::from($results);
}
public function createMovieDirectory(string $path): string
public function createMovieDirectory(string $title, string|int $year, string $imdbId): string
{
$path = $this->moviesPath . DIRECTORY_SEPARATOR . $path;
$path = sprintf(
'%s' . DIRECTORY_SEPARATOR . '%s (%s) [imdbid-%s]',
$this->moviesPath,
$title,
$year,
$imdbId
);
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
@@ -124,9 +130,16 @@ class MediaFiles
return $path;
}
public function createTvShowDirectory(string $path): string
public function createTvShowDirectory(string $title, string|int $year, string|int $season, string|int $episode, string $imdbId): string
{
$path = $this->tvShowsPath . DIRECTORY_SEPARATOR . $path;
$path = sprintf(
'%s' . DIRECTORY_SEPARATOR . '%s (%s) [imdbid-%s]' . DIRECTORY_SEPARATOR . 'Season %s',
$this->tvShowsPath,
$title,
$year,
$imdbId,
str_pad($season, 2, '0', STR_PAD_LEFT),
);
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);

View File

@@ -10,13 +10,14 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class DownloadMediaCommand implements CommandInterface
{
public function __construct(
public string $url,
public string $title,
public string $filename,
public string $mediaType,
public string $imdbId,
public int $userId,
public ?int $downloadId = null,
public string $mediaType,
public int|string|null $season = null,
public int|string|null $episode = null,
public string|null $url = null,
public array|null $filter = null,
public int|null $downloadId = null,
public int|null $userId = null,
public ?string $mercureAlertTopic = null,
) {}
}

View File

@@ -4,13 +4,26 @@ namespace App\Download\Action\Handler;
use App\Base\Enum\MediaType;
use App\Base\Service\Broadcaster;
use App\Base\Service\MediaFiles;
use App\Base\Util\EpisodeId;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\DownloadEvents;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Library\Dto\MediaFileDto;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Result\GetMediaInfoResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Result\TorrentioResult;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Repository\UserRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
@@ -18,19 +31,63 @@ use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */
readonly class DownloadMediaHandler implements HandlerInterface
{
public function __construct(
private MessageBusInterface $bus,
private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository, private Broadcaster $broadcaster,
private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private GetMovieOptionsHandler $getMovieOptionsHandler,
private GetMediaInfoHandler $getMediaInfoHandler,
private MessageBusInterface $bus,
private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository,
private Broadcaster $broadcaster,
private MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$user = $this->userRepository->find($command->userId);
/** @var \App\Download\Framework\Entity\Download $download */
$download = $this->downloadRepository->find($command->downloadId);
$media = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand(
$command->imdbId,
$command->mediaType,
$command->season,
$command->episode,
));
$downloadOptions = match ($command->mediaType) {
MediaType::Movie->value => $this->getMovieOptionsHandler->handle(
new GetMovieOptionsCommand(
$media->media->tmdbId,
$media->media->imdbId
)
),
MediaType::TvShow->value => $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$media->media->tmdbId,
$media->media->imdbId,
$command->season,
$command->episode
)
)
};
$filter = $command->filter !== null
? UserPreferencesFactory::createFromArray($command->filter)
: UserPreferencesFactory::createFromUser($user);
$matchingOption = $this->downloadOptionEvaluator->evaluateOptions($downloadOptions->results, $filter);
$download->setUrl($matchingOption->url);
$download->setTitle($media->media->title);
$download->setFileName(
$this->getFilename(MediaType::from($command->mediaType), $media, $matchingOption)
);
$this->bus->dispatch(new AddEventLogCommand(
$user,
DownloadEvents::DOWNLOAD_STARTED->type(),
@@ -38,20 +95,6 @@ readonly class DownloadMediaHandler implements HandlerInterface
(array) $command
));
if (null === $command->downloadId) {
$download = $this->downloadRepository->insert(
$user,
$command->url,
$command->title,
$command->filename,
$command->imdbId,
$command->mediaType,
""
);
} else {
$download = $this->downloadRepository->find($command->downloadId);
}
try {
$this->validateDownloadUrl($download->getUrl());
} catch (\Throwable $exception) {
@@ -69,8 +112,8 @@ readonly class DownloadMediaHandler implements HandlerInterface
$this->downloader->download(
$command->mediaType,
$command->title,
$command->url,
$download->getUrl(),
$this->getFilepath(MediaType::from($command->mediaType), $media, $matchingOption),
$download->getId()
);
@@ -91,6 +134,54 @@ readonly class DownloadMediaHandler implements HandlerInterface
return new DownloadMediaResult(200, "Success.");
}
private function getFilepath(MediaType $mediaType, GetMediaInfoResult $media, TorrentioResult $option): ?string
{
return match ($mediaType) {
MediaType::Movie => $this->mediaFiles->createMovieDirectory(
$media->media->title,
$media->media->year,
$media->media->imdbId,
),
MediaType::TvShow => $this->mediaFiles->createTvShowDirectory(
$media->media->title,
$media->media->year,
$media->season,
$media->episode,
$media->media->imdbId,
)
};
}
private function getFilename(MediaType $mediaType, GetMediaInfoResult $media, TorrentioResult $option): ?string
{
$fileType = $option->ptn->container;
return (match ($mediaType) {
MediaType::Movie => function () use ($media, $fileType) {
$template = "%s (%s) [imdbid-%s].%s";
return sprintf(
$template,
$media->media->title,
$media->media->year,
$media->media->imdbId,
$fileType
);
},
MediaType::TvShow => function () use ($media, $fileType) {
$template = "%s %s.%s";
$episodeId = EpisodeId::fromSeasonEpisodeNumbers(
$media->season,
$media->episode,
);
return sprintf(
$template,
$media->media->title,
$episodeId,
$fileType
);
}
})();
}
public function validateDownloadUrl(string $downloadUrl)
{
$badFileSizes = [

View File

@@ -3,9 +3,12 @@
namespace App\Download\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand;
use OneToMany\RichBundle\Attribute\PropertyIgnored;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/** @implements InputInterface<DownloadMediaInput> */
class DownloadMediaInput implements InputInterface
@@ -13,39 +16,60 @@ class DownloadMediaInput implements InputInterface
public ?string $mercureAlertTopic = null;
public function __construct(
#[SourceRequest('url')]
public string $url,
#[SourceRequest('title')]
public string $title,
#[SourceRequest('filename')]
public string $filename,
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('mediaType')]
public string $mediaType,
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('season', nullify: true)]
public int|string|null $season = null,
#[SourceRequest('episodeId', nullify: true)]
public ?string $episodeId = null,
#[SourceRequest('episode', nullify: true)]
public int|string|null $episode = null,
public ?int $userId = null,
#[SourceRequest('url', nullify: true)]
public string|null $url = null,
public ?int $downloadId = null,
#[SourceRequest('filter', nullify: true)]
public array|null $filter = null,
#[PropertyIgnored]
public int|null $downloadId = null,
#[PropertyIgnored]
public int|null $userId = null,
) {}
public function setUserId(int $userId): static
{
$this->userId = $userId;
return $this;
}
public function setDownloadId(int $downloadId): static
{
$this->downloadId = $downloadId;
return $this;
}
public function setMercureAlertTopic(string $mercureAlertTopic): static
{
$this->mercureAlertTopic = $mercureAlertTopic;
return $this;
}
public function toCommand(): CommandInterface
{
return new DownloadMediaCommand(
$this->url,
$this->title,
$this->filename,
$this->mediaType,
$this->imdbId,
$this->userId,
$this->mediaType,
$this->season,
$this->episode,
$this->url,
$this->filter,
$this->downloadId,
$this->userId,
$this->mercureAlertTopic,
);
}

View File

@@ -26,13 +26,15 @@ class DownloadOptionEvaluator
// return false;
//}
if (false === $this->validateDownloadUrl($result->url)) {
return false;
}
// if (false === $this->validateDownloadUrl($result->url)) {
// return false;
// }
return true;
});
// dd($filter);
if ($matches->count() > 0) {
return Map::from($matches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}

View File

@@ -16,5 +16,5 @@ interface DownloaderInterface
* @return void
* Downloads the requested file.
*/
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void;
public function download(string $mediaType, string $url, string $downloadPath, ?int $downloadId): void;
}

View File

@@ -7,6 +7,8 @@ use App\Base\Service\MediaFiles;
use App\Download\DownloadEvents;
use App\Download\Framework\Entity\Download;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -22,7 +24,6 @@ class ProcessDownloader implements DownloaderInterface
public function __construct(
private MessageBusInterface $bus,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private CacheInterface $cache,
private readonly Broadcaster $broadcaster,
) {}
@@ -30,15 +31,12 @@ class ProcessDownloader implements DownloaderInterface
/**
* @inheritDoc
*/
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void
public function download(string $mediaType, string $url, string $downloadPath, ?int $downloadId): void
{
/** @var Download $downloadEntity */
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
$this->entityManager->flush();
$downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences();
$path = $this->getDownloadPath($mediaType, $title, $downloadPreferences);
$processArgs = ['wget', '-O', $downloadEntity->getFilename(), $url];
if ($downloadEntity->getStatus() === 'Paused') {
@@ -48,13 +46,9 @@ class ProcessDownloader implements DownloaderInterface
$downloadEntity->setProgress(0);
}
fwrite(STDOUT, implode(" ", $processArgs));
$process = (new Process($processArgs))->setWorkingDirectory($path);
$process = (new Process($processArgs))->setWorkingDirectory($downloadPath);
$process->setTimeout(1800); // 30 min
$process->setIdleTimeout(600); // 10 min
$process->start();
try {
@@ -93,7 +87,7 @@ class ProcessDownloader implements DownloaderInterface
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
$this->bus->dispatch(new AddEventLogCommand(
$downloadEntity->getUser()->getId(),
$downloadEntity->getUser(),
DownloadEvents::DOWNLOAD_ERROR->type(),
DownloadEvents::DOWNLOAD_ERROR->message() . ': ' . $exception->getMessage(),
(array) $downloadEntity
@@ -103,22 +97,6 @@ class ProcessDownloader implements DownloaderInterface
$this->entityManager->flush();
}
public function getDownloadPath(string $mediaType, string $title, array $downloadPreferences): string
{
if ($mediaType === 'movies') {
if ((bool) $downloadPreferences['movie_folder']->getPreferenceValue() === true) {
return $this->mediaFiles->createMovieDirectory($title);
}
return $this->mediaFiles->getMoviesPath();
}
if ($mediaType === 'tvshows') {
return $this->mediaFiles->createTvShowDirectory($title);
}
throw new \Exception("There is no download path for media type: $mediaType");
}
private function alertComplete(Download $download): void
{
if ("tvshows" === $download->getMediaType()) {
@@ -129,4 +107,4 @@ class ProcessDownloader implements DownloaderInterface
$this->broadcaster->alert('Success', $message, sendPush: true);
}
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Download\Downloader;
use App\Message\DownloadMessage;
use App\Message\DownloadMovieMessage;
use App\Message\DownloadTvShowMessage;
class WgetDownloader implements DownloaderInterface
{
/**
* @inheritDoc
* SSHs into the NAS and performs the download.
* This way retains the fast DL speed on the NAS.
*/
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void
{
// SSHs into the NAS, cds into movies dir, makes new dir based on filename, cds into that dir, downloads movie
system(sprintf(
'sh /var/www/bash/app/wget_download.sh "%s" "%s" "%s"',
$baseDir,
$title,
$url
));
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Download\Framework\Controller;
use App\Base\Service\Broadcaster;
use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Handler\DownloadMediaHandler;
use App\Download\Action\Handler\PauseDownloadHandler;
use App\Download\Action\Handler\ResumeDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput;
@@ -12,6 +13,7 @@ use App\Download\Action\Input\DownloadSeasonInput;
use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\DownloadEvents;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -31,19 +33,19 @@ class ApiController extends AbstractController
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
public function download(
DownloadMediaInput $input,
DownloadMediaHandler $handler,
): Response {
$download = $this->downloadRepository->insert(
$download = $this->downloadRepository->insertNew(
$this->getUser(),
$input->url,
$input->title,
$input->filename,
$input->imdbId,
$input->mediaType,
$input->episodeId,
$input->season,
$input->episode,
);
$input->downloadId = $download->getId();
$input->userId = $this->getUser()->getId();
$input->mercureAlertTopic = $this->requestStack->getSession()->get('mercure_alert_topic');
$input->setDownloadId($download->getId());
$input->setUserId($this->getUser()->getId());
$input->setMercureAlertTopic($this->requestStack->getSession()->get('mercure_alert_topic'));
$input->toCommand();
$this->bus->dispatch(new AddEventLogCommand(
$this->getUser(),
@@ -60,7 +62,7 @@ class ApiController extends AbstractController
$this->broadcaster->alert(
title: 'Success',
message: "$input->title added to Queue."
message: "Added to Queue."
);
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
@@ -121,4 +123,22 @@ class ApiController extends AbstractController
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
}
#[Route('/api/download', name: 'api_get_downloads', methods: ['GET'])]
public function getDownloads(DownloadRepository $repository): Response
{
$downloads = $repository->findBy(['user' => $this->getUser()]);
return $this->json(['status' => 200, 'message' => 'Success', 'data' => ['downloads' => $downloads]]);
}
#[Route('/api/download/{id}', name: 'api_get_download', methods: ['GET'])]
public function getDownload(Download $download): Response
{
if ($download->getUser() === $this->getUser()) {
return $this->json(['status' => 200, 'message' => 'Success', 'data' => ['download' => $download]]);
}
return $this->json(['status' => 404, 'message' => 'Success'], 404);
}
}

View File

@@ -30,7 +30,7 @@ class Download
#[ORM\Column(length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 1024)]
#[ORM\Column(length: 1024, nullable: true)]
private ?string $url = null;
#[ORM\Column(length: 1024, nullable: true)]

View File

@@ -2,6 +2,7 @@
namespace App\Download\Framework\Repository;
use App\Base\Util\EpisodeId;
use App\Base\Util\Paginator;
use App\Download\Framework\Entity\Download;
use App\User\Framework\Entity\User;
@@ -56,6 +57,34 @@ class DownloadRepository extends ServiceEntityRepository
return $this->paginator->paginate($query, $pageNumber, $perPage);
}
public function insertNew(
UserInterface $user,
string $imdbId,
string $mediaType,
int|null $season = null,
int|null $episode = null,
string $status = 'New'
): Download {
/** @var User $user */
$download = (new Download())
->setUser($user)
->setImdbId($imdbId)
->setMediaType($mediaType)
->setProgress(0)
->setStatus($status);
if (null !== $season && null !== $episode) {
$download->setEpisodeId(
EpisodeId::fromSeasonEpisodeNumbers($season, $episode)
);
}
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function insert(
UserInterface $user,
string $url,

View File

@@ -11,6 +11,7 @@ use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult;
use Carbon\Carbon;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -36,18 +37,14 @@ readonly class MonitorTvShowHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
$monitor = $this->monitorRepository->find($command->monitorId);
$this->refreshData($monitor);
$showTmdbData = $this->tmdb->tvshowDetails($monitor->getImdbId());
$this->refreshData($monitor, $showTmdbData);
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object)(new PTN())->parse($episode))
->filter(fn($episode) => property_exists($episode, 'episode')
&& property_exists($episode, 'season')
&& null !== $episode->episode
&& null !== $episode->season
);
$downloadedEpisodes = $this->getDownloadedEpisodes($monitor->getTitle(), $showTmdbData);
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
@@ -130,6 +127,33 @@ readonly class MonitorTvShowHandler implements HandlerInterface
);
}
private function getDownloadedEpisodes(string $title, TmdbResult $showTmdbData): Map
{
// Episodes in folder w/o the year
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($title)
->map(fn($episode) => (object) new PTN()->parse($episode))
->filter(fn($episode) =>
property_exists($episode, 'episode')
&& property_exists($episode, 'season')
&& null !== $episode->episode
&& null !== $episode->season
);
return $downloadedEpisodes->concat(
// Episodes in folder w/ the year
$this->mediaFiles
->getEpisodes(sprintf("%s (%s)", $title, $showTmdbData->year))
->map(fn($episode) => (object) new PTN()->parse($episode))
->filter(fn($episode) =>
property_exists($episode, 'episode')
&& property_exists($episode, 'season')
&& null !== $episode->episode
&& null !== $episode->season
)
);
}
private function episodeReleasedAfterMonitorCreated(
string|DateTimeImmutable $monitorStartDate,
TmdbEpisodeDto $episodeInShow
@@ -159,11 +183,11 @@ readonly class MonitorTvShowHandler implements HandlerInterface
]) !== null;
}
private function refreshData(Monitor $monitor)
private function refreshData(Monitor $monitor, TmdbResult $showTmdbData)
{
if (null === $monitor->getPoster()) {
$this->logger->info('> [MonitorTvShowHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
$poster = $this->tmdb->tvshowDetails($monitor->getImdbId())->poster;
$poster = $showTmdbData->poster;
if (null !== $poster && "" !== $poster) {
$monitor->setPoster($poster);
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Monitor;
enum MonitorTypes: string
{
case TV_EPISODE = "tvepisode";
case TV_SEASON = "tvseason";
case TV_SERIES = "tvseries";
}

View File

@@ -8,6 +8,7 @@ use App\Base\Util\ImdbMatcher;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\Dto\WatchProviderDto;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpClient\HttpClient;
@@ -60,6 +61,7 @@ class TmdbClient
private readonly SerializerInterface $serializer,
private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly LoggerInterface $logger,
#[Autowire(env: 'TMDB_API')] string $apiKey,
#[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
) {
@@ -293,7 +295,12 @@ class TmdbClient
!in_array($mediaType, [MediaType::Movie->value, MediaType::TvShow->value])) {
return [];
}
return $this->repos[$mediaType]->getApi()->getExternalIds($tmdbId);
try {
return $this->repos[$mediaType]->getApi()->getExternalIds($tmdbId);
} catch (\Throwable $throwable) {
$this->logger->warning("[TmdbClient] Error getting external ids for $tmdbId: " . $throwable->getMessage());
return [];
}
}
private function findByImdbId(string $imdbId): array

View File

@@ -8,6 +8,17 @@ use Symfony\Component\Security\Core\User\UserInterface;
class UserPreferencesFactory
{
public static function createFromArray(array $data): UserPreferences
{
return new UserPreferences(
resolution: static::getArrayValue($data, 'resolution'),
codec: static::getArrayValue($data, 'codec'),
language: static::getArrayValue($data, 'language'),
provider: static::getArrayValue($data, 'provider'),
quality: static::getArrayValue($data, 'quality'),
);
}
/** @param User $user */
public static function createFromUser(UserInterface $user): UserPreferences
{
@@ -30,4 +41,21 @@ class UserPreferencesFactory
$value = explode(',', $value);
return $value;
}
private static function getArrayValue(array $data, string $key): array|null
{
if (!array_key_exists($key, $data)) {
return null;
}
if ("" === $data[$key]) {
return null;
}
if (true === is_string($data[$key])) {
return [$data[$key]];
}
return $data[$key];
}
}

View File

@@ -18,9 +18,13 @@ module.exports = {
"bg-green-400",
"bg-purple-400",
"bg-orange-400",
"bg-red-500",
"bg-green-500",
"bg-blue-600",
"bg-rose-600",
"bg-black/20",
"text-red-500",
"text-green-500",
"alert-success",
"alert-warning",
"font-bold",

View File

@@ -6,6 +6,20 @@
<div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block">
<ul class="ml-4 flex items-end md:items-center md:gap-6 text-sm">
<li>
<div class="flex flex-row justify-center items-start gap-2 p-2 w-10 mt-1 h-6 rounded-lg border border-orange-500 text-orange-500">
<status-checker-span
class="h-2 w-2 rounded-full text-green-600 bg-green-600"
url="https://torrentio.strem.fun"
service="Torrentio">
</status-checker-span>
<status-checker-span
class="h-2 w-2 rounded-full text-green-600 bg-green-600"
url="https://api.themoviedb.org/3"
service="TMDB">
</status-checker-span>
</div>
</li>
<li>
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false" title="View upcoming episodes of the shows you're subscribed to.">
<twig:ux:icon name="solar:calendar-linear" width="25px" class="text-orange-500" />