Compare commits

..

1 Commits

Author SHA1 Message Date
48a601f58d wip: ajax notifications w/ php-flasher 2025-04-27 08:21:30 -05:00
197 changed files with 1011 additions and 8529 deletions

12
.env
View File

@@ -15,7 +15,7 @@
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=prod
APP_ENV=dev
APP_SECRET=
###< symfony/framework-bundle ###
@@ -26,18 +26,12 @@ APP_SECRET=
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# 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="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
REDIS_HOST=redis://redis
MOVIES_PATH=/var/download/movies
TVSHOWS_PATH=/var/download/tvshows

7
.env.dev Normal file
View File

@@ -0,0 +1,7 @@
DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
APP_SECRET=70169beadfbc8101c393cbfbba27a313
DOWNLOAD_DIR=./movies
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://dev.caldwell.digital/hub/.well-known/mercure
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"

10
.env.dist Normal file
View File

@@ -0,0 +1,10 @@
APP_SECRET="%%app_secret%%"
DATABASE_URL="%%db_url%%"
DOWNLOAD_DIR=%%download_dir%%
REAL_DEBRID_KEY="%%rd_key%%"
TMDB_API=%%tmdb_api%%
MERCURE_URL=%%mercure_url%%
MERCURE_PUBLIC_URL=%%mercure_public_url%%
MERCURE_JWT_SECRET="%%mercure_jwt_secret%%"
JELLYFIN_URL=%%jellyfin_url%%
JELLYFIN_TOKEN=%%jellyfin_token%%

4
.gitignore vendored
View File

@@ -13,7 +13,3 @@
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###

View File

@@ -1,16 +1 @@
FROM dunglas/frankenphp
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY docker/app/site.caddyfile /etc/frankenphp/Caddyfile
FROM registry.caldwell.digital/library/php:8.4-apache

6
Dockerfile.prod Normal file
View File

@@ -0,0 +1,6 @@
FROM registry.caldwell.digital/library/php:8.4-apache
COPY --chown=www-data:www-data . /var/www
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -1,50 +1,13 @@
# Torsearch
Torsearch is a "media acquisition tool" that works strictly with Real Debrid. Torsearch makes it easy to search for
and download your favorite movies and tv shows. You can think of it like Stremio, but without the streaming. Why the
comparison to Stremio? That's because Torsearch uses the same source for media files that Stremio uses: Torrentio
(hence the name: Torsearch).
After two failed attempts at running a media server, I decided to hang up my hat and give up my dream of a self-hosted
media server. I figured the days of torrenting were mostly over and everybody ranting & raving about their media collections
must be going to Walmart and buying up the bucket of old movies they have. That's until I stumbled across Stremio.
At first, it seemed too good to be true, but I was yearning for something just sketchy enough to try out. What could
go wrong with handing over my card information to an unknown organization across the pond? At the end of the day,
the cost benefit analysis landed in my favor, and about 30 minutes after purchasing my Real Debrid subscription and
setting up Stremio, I was in business.
My mind was blown. I might not have the most "cultured" taste in media, but it had everything I searched for and more! After
watching a few movies, I noticed the "Copy Download Link" button. "What's this lil guy do?" I asked myself. Duh, it
downloads the f*****g movie. And there's the 💡flashing over my head. There's gotta be a way to automate this, I told myself.
After a month of studying Stremio's code and lots of tinkering, I finally figured it out. Torrentio is the magic behind
the scenes. You feed it a Real Debrid API key and an IMDB ID, and it gives you a list of results to download that media.
Easy peasy.
In about an hour I had a proof of concept working. It wasn't pretty, but it wasn't supposed to be. That proof-of-concept
has blossomed into the beautiful Torsearch that I've been using nearly every day since then. The code in this repo
is a complete re-write of the proof-of-concept that started out ugly and ended up even uglier. Knowing the core functionality
required to make it work, I was able to re-write the app with some design patterns in place.
## Pics or didn't happen
![Torsearch Homepage](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_homepage.png)
![TV Show Result](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_severance.png)
![TV Show Episodes](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_severance_episodes.png)
![TV Show Episode Results](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_severance_results.png)
![TV Show Movie Results](https://code.caldwell.digital/home/torsearch/raw/branch/main/docs/img/torsearch_inception_results.png)
## Features
- Search for Movies & TV Shows by their name
- Download directly to your NAS
- Monitor TV Shows for new episodes and automatically download them
- Browse popular media and view its download options
- LDAP or local auth (OIDC coming soon)
## Features on the roadmap
- Requests - allow users to request new media
- OIDC auth
- Prometheus logging
# Caldwell Digital - Symfony Template
Get up and running quickly with this Symfony framework template!
## Getting Started
1. Clone the repo
1. Run `source bash/get_certs.sh` to grab the wildcard certs
2. Set the docker image tag in the `bash/build.sh` file
3. Set the docker image tag in the `deploy.compose.yml` file
4. run `composer install`
## Optional steps
1. Set phing vars
2. Update the project name in `build.xml`
3. Set a custom development hostname in `bash/vhost.conf`

View File

@@ -8,13 +8,3 @@ import './bootstrap.js';
import './styles/app.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
let alert = document.querySelector('.alert');
var observer = new MutationObserver(function(mutations) {
if (document.contains(alert)) {
observer.disconnect();
}
});
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});

View File

@@ -8,16 +8,6 @@
"@symfony/ux-live-component/dist/live.min.css": true
}
}
},
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": true,
"fetch": "eager"
}
}
},
"entrypoints": []

View File

@@ -1,18 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
let timer = setTimeout(() => {
this.element.remove();
},
"3000"
);
this.element.addEventListener('mouseout', () => timer = setTimeout(() => {
this.element.remove();
},
"3000"
));
this.element.addEventListener('mouseover', () => clearTimeout(timer));
}
}

View File

@@ -1,5 +1,5 @@
import { Controller } from '@hotwired/stimulus';
import flasher from '@flasher/flasher'
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
@@ -15,7 +15,7 @@ export default class extends Controller {
}
download() {
fetch('/api/download', {
fetch('/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -26,12 +26,12 @@ export default class extends Controller {
title: this.element.dataset['title'],
filename: this.filenameValue,
mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue,
imdbId: this.imdbIdValue
})
})
.then(res => res.json())
.then(json => {
console.log(json)
})
flasher.success(json.message);
})
}
}

View File

@@ -1,42 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
/*
* 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 = ['download']
async initialize() {
this.component = await getComponent(this.element);
}
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)
}
downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > 5) {
target.classList.add('hidden');
}
}
// 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)
}
}

View File

@@ -1,93 +0,0 @@
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 = ['button', 'options']
static outlets = ['result-filter']
static values = {
tmdbId: String,
imdbId: String,
title: String,
}
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)
}
toggle() {
this.optionsTarget.classList.toggle('hidden');
}
async monitorSeries() {
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvshows',
});
}
async monitorSeason() {
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvseason',
season: this.resultFilterOutlet.activeFilter['season'],
});
}
async monitorEpisode() {
// ToDo: figure out how to set episode
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvepisode',
season: this.resultFilterOutlet.activeFilter['season'],
episode: '',
});
}
async makeMonitor(body) {
const response = await fetch('/api/monitor', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(body)
});
return await response.json();
}
}

View File

@@ -1,24 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static values = {
title: String,
tmdbId: String,
imdbId: String,
mediaType: String,
}
addMovieMonitor() {
console.log(`/monitor/movies/${this.tmdbIdValue}/${this.imdbIdValue}/${encodeURI(this.titleValue)}`)
fetch(`/monitor/movies/${this.tmdbIdValue}/${this.imdbIdValue}/${encodeURI(this.titleValue)}`)
.then(res => res.json())
.then(json => {
console.log(json)
})
}
}

View File

@@ -6,9 +6,6 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = {
title: String,
tmdbId: String,
@@ -18,15 +15,13 @@ export default class extends Controller {
static targets = ['list']
options = []
optionsLoaded = false
async connect() {
await this.setOptions();
}
async setOptions() {
if (false === this.optionsLoaded) {
this.optionsLoaded = true;
if (this.options.length === 0) {
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
.then(res => res.text())
.then(response => {
@@ -42,54 +37,7 @@ export default class extends Controller {
return true;
}
async filter(activeFilter) {
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
listTargetConnected(target) {
// console.log(target);
}
}

View File

@@ -1,25 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
inactiveStyles = "block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-orange-500 hover:bg-opacity-80";
activeStyles = "block rounded-lg bg-orange-500 hover:bg-opacity-70 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-80 px-4 py-2 text-sm font-medium text-gray-50";
connect() {
console.log(window.location.pathname);
this.element.querySelectorAll('a:not(.nav-foot)').forEach(link => {
link.className = this.inactiveStyles;
if (window.location.pathname === (new URL(link.href)).pathname || window.location.pathname.startsWith( (new URL(link.href)).pathname + '/' ) ) {
link.className = this.activeStyles;
}
});
}
setActive() {
}
}

View File

@@ -27,11 +27,10 @@ export default class extends Controller {
'episodes': Array,
}
async connect() {
connect() {
if (this.mediaTypeValue === "tvshows") {
this.activeFilter['season'] = 1;
}
await this.filter();
}
async movieResultsOutletConnected(outlet) {
@@ -50,7 +49,6 @@ export default class extends Controller {
this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset);
})
await this.filter();
}
addLanguages(option, props) {
@@ -61,22 +59,9 @@ export default class extends Controller {
}
});
const preferred = this.languageTarget.dataset.preferred;
if (preferred) {
this.languageTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.languageTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.languageTarget.innerHTML = '<option value="">n/a</option>';
}
this.languageTarget.innerHTML = '<option value="">n/a</option>';
this.languageTarget.innerHTML += this.languages.sort()
.map((language) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === language) {
return;
}
return '<option value="'+language+'">'+language+'</option>';
})
.map((language) => '<option value="'+language+'">'+language+'</option>')
.join();
}
@@ -85,22 +70,9 @@ export default class extends Controller {
this.providers.push(props['provider']);
}
const preferred = this.providerTarget.dataset.preferred;
if (preferred) {
this.providerTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.providerTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.providerTarget.innerHTML = '<option value="">n/a</option>';
}
this.providerTarget.innerHTML = '<option value="">n/a</option>';
this.providerTarget.innerHTML += this.providers.sort()
.map((provider) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === provider) {
return;
}
return '<option value="' + provider + '">' + provider + '</option>'
})
.map((provider) => '<option value="'+provider+'">'+provider+'</option>')
.join();
}
@@ -118,13 +90,79 @@ export default class extends Controller {
if ("movies" === this.mediaTypeValue) {
results = this.movieResultsOutlets;
await results.forEach((list) => list.filter(this.activeFilter));
} else if ("tvshows" === this.mediaTypeValue) {
results = this.tvResultsOutlets;
this.activeFilter.season = this.seasonTarget.value;
await results.forEach((list) => list.filter(this.activeFilter, currentSeason, this.seasonTarget.value));
}
const filterOperation = async (resultList, currentSeason) => {
if ("tvshows" === this.mediaTypeValue && currentSeason !== this.activeFilter['season']) {
if (resultList.seasonValue === this.seasonTarget.value) {
await resultList.setActive();
} else {
resultList.setInActive();
}
}
if (false === resultList.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
resultList.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
for (let [key, value] of Object.entries(this.activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
if ("tvshows" === this.mediaTypeValue) {
resultList.countTarget.innerText = count;
}
});
}
await results.forEach((list) => filterOperation(list, currentSeason));
}
uncheckSelectAllBtn() {

View File

@@ -6,9 +6,6 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = {
title: String,
tmdbId: String,
@@ -29,8 +26,7 @@ export default class extends Controller {
}
async setOptions() {
if (true === this.activeValue && this.optionsLoaded === false) {
this.optionsLoaded = true;
if (true === this.activeValue) {
await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
.then(response => {
@@ -42,18 +38,12 @@ export default class extends Controller {
} else {
this.episodeSelectorTarget.disabled = true;
}
this.optionsLoaded = true;
this.loadingIconOutlet.increaseCount();
});
}
}
//
// async clearCache() {
// await fetch(`/torrentio/tvshows/clear/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
// .then(res => res.text())
// .then(response => {});
// }
async setActive() {
this.activeValue = true;
this.element.classList.remove('hidden');
@@ -62,11 +52,9 @@ export default class extends Controller {
}
}
async setInActive() {
setInActive() {
this.activeValue = false;
// if (true === this.hasEpisodeSelectorTarget()) {
this.episodeSelectorTarget.checked = false;
// }
this.element.classList.add('hidden');
}
@@ -100,69 +88,4 @@ export default class extends Controller {
}
})
}
async filter(activeFilter, currentSeason, newSeason) {
if (currentSeason !== activeFilter['season']) {
if (this.seasonValue === newSeason) {
await this.setActive();
} else {
await this.setInActive();
}
}
if (false === this.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
this.countTarget.innerText = count;
});
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M17.201 2H6.8c-1.458 0-2.737.985-2.795 2.404c-.074 1.785 1.182 2.97 2.5 4.083c1.825 1.54 2.737 2.31 2.832 3.284q.023.229 0 .458c-.095.975-1.007 1.744-2.832 3.284c-1.355 1.143-2.578 2.207-2.5 4.083C4.062 21.016 5.34 22 6.799 22H17.2c1.458 0 2.737-.985 2.796-2.404c.046-1.13-.373-2.254-1.262-3.036c-.405-.357-.826-.698-1.24-1.047c-1.824-1.54-2.736-2.31-2.831-3.284a2.3 2.3 0 0 1 0-.458c.095-.975 1.008-1.744 2.832-3.284c1.34-1.131 2.577-2.229 2.5-4.083C19.939 2.984 18.66 2 17.202 2"/><path d="M9 21.638c0-.442 0-.663.088-.856a1 1 0 0 1 .046-.09c.107-.183.288-.312.65-.571c1.006-.719 1.51-1.078 2.081-1.116q.135-.009.27 0c.572.038 1.075.397 2.08 1.116c.363.259.544.388.651.571q.026.045.046.09c.088.193.088.414.088.856V22H9z"/></g></svg>

Before

Width:  |  Height:  |  Size: 928 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h7v2zm11-4l-1.375-1.45l2.55-2.55H9v-2h8.175l-2.55-2.55L16 7l5 5z"/></svg>

Before

Width:  |  Height:  |  Size: 224 B

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 22a8 8 0 1 1 16 0h-2a6 6 0 0 0-12 0zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6s6 2.685 6 6s-2.685 6-6 6m0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4"/></svg>

Before

Width:  |  Height:  |  Size: 257 B

View File

@@ -1,3 +1,2 @@
echo "> Building ${IMG} for linux/amd64"
docker buildx build --platform linux/amd64 -f Dockerfile.app -t "${IMG}-app" .
docker buildx build --platform linux/amd64 -f Dockerfile.worker -t "${IMG}-worker" .
docker buildx build --platform linux/amd64 -f Dockerfile.prod -t ${IMG} .

View File

@@ -1,3 +1,2 @@
echo "> Building ${IMG} for linux/arm/v8"
docker buildx build --platform linux/arm/v8 -f Dockerfile.app -t "${IMG}-app" .
docker buildx build --platform linux/arm/v8 -f Dockerfile.app -t "${IMG}-worker" .
docker buildx build --platform linux/arm/v8 -f Dockerfile.prod -t ${IMG} .

View File

@@ -1,3 +1,2 @@
echo "> Pushing ${IMG}"
docker push "${IMG}-app"
docker push "${IMG}-worker"
docker push ${IMG}

View File

@@ -2,5 +2,5 @@ dev.caldwell.digital:443
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
reverse_proxy app:80
reverse_proxy php:80

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# Sleep for a second to ensure DB is awake and ready
SLEEP_TIME=$(shuf -i 2-4 -n 1)
echo "> Sleeping for ${SLEEP_TIME} seconds to wait for the database"
sleep $SLEEP_TIME
# Provision database
php /app/bin/console doctrine:migrations:migrate --no-interaction
php /app/bin/console db:seed
exec docker-php-entrypoint "$@"

View File

@@ -1,32 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name localhost;
root /var/www/public;
location /hub/ {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_redirect off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://mercure/;
}
location / {
# try to serve file directly, fallback to index.php
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
}

18
bash/vhost.conf Executable file
View File

@@ -0,0 +1,18 @@
<VirtualHost *:80>
ServerName localhost
DocumentRoot /var/www/public
DirectoryIndex /index.php
<Directory /var/www/public>
AllowOverride None
Order Allow,Deny
Allow from All
FallbackResource /index.php
</Directory>
<Directory /var/www/public/bundles>
FallbackResource disabled
</Directory>
</VirtualHost>

View File

@@ -1,20 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project name="Torsearch" default="build">
<project name="Caldwell Digital Symfony Template" default="build">
<!-- build dev for dev envs -->
<target name="build" depends="setEnv,composer,compileAssets,migrateDb,clearCache" />
<target name="build" depends="setEnv,composer,compileAssets" />
<target name="composer" description="Run composer">
<exec executable="composer">
<arg value="install" />
<arg value="--ignore-platform-reqs" />
</exec>
</target>
<target name="setEnv" description="Set the database configuration">
<copy file="${project.basedir}/.env.properties" tofile="${project.basedir}/.env.local" overwrite="true">
</copy>
</target>
<target name="compileAssets" description="Run composer">
<exec executable="php">
<arg value="bin/console" />
@@ -26,19 +20,22 @@
</exec>
</target>
<target name="migrateDb" description="Migrate the database">
<exec executable="php">
<arg value="bin/console" />
<arg value="--no-interaction" />
<arg value="doctrine:migrations:migrate" />
</exec>
</target>
<target name="clearCache" description="Clear the application cache">
<exec executable="php">
<arg value="bin/console" />
<arg value="cache:pool:clear" />
<arg value="cache.app" />
</exec>
<target name="setEnv" description="Set the database configuration">
<copy file="${project.basedir}/.env.dist" tofile="${project.basedir}/.env.local" overwrite="true">
<filterchain>
<replacetokens begintoken="%%" endtoken="%%">
<token key="app_secret" value="${APP_SECRET}" />
<token key="db_url" value="${DATABASE_URL}" />
<token key="download_dir" value="${DOWNLOAD_DIR}" />
<token key="rd_key" value="${REAL_DEBRID_KEY}" />
<token key="tmdb_api" value="${TMDB_API}" />
<token key="mercure_url" value="${MERCURE_URL}" />
<token key="mercure_public_url" value="${MERCURE_PUBLIC_URL}" />
<token key="mercure_jwt_secret" value="${MERCURE_JWT_SECRET}" />
<token key="jellyfin_url" value="${JELLYFIN_URL}" />
<token key="jellyfin_token" value="${JELLYFIN_TOKEN}" />
</replacetokens>
</filterchain>
</copy>
</target>
</project>

View File

@@ -12,39 +12,17 @@ services:
- $PWD/bash/caddy:/etc/caddy
- $PWD/bash/certs:/etc/ssl
app:
php:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
- mercure_data:/data
- mercure_config:/config
tty: true
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on:
database:
condition: service_healthy
- ./:/var/www
worker:
build: .
restart: unless-stopped
volumes:
- $PWD:/app
tty: true
command: php /app/bin/console messenger:consume async -vv
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
database:
image: mariadb:10.11.2
@@ -57,21 +35,11 @@ services:
MYSQL_USERNAME: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s
timeout: 5s
retries: 10
adminer:
image: adminer
ports:
- "8081:8080"
volumes:
mysql:
mercure_data:
mercure_config:
redis_data:

View File

@@ -8,41 +8,29 @@
"ext-ctype": "*",
"ext-iconv": "*",
"1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-fixtures-bundle": "^4.1",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4",
"league/pipeline": "^1.1",
"nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-flasher/flasher-symfony": "^2.1",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/filesystem": "7.2.*",
"symfony/finder": "7.2.*",
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/ldap": "7.2.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/scheduler": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.2.*",
"symfony/ux-icons": "^2.24",
"symfony/ux-live-component": "^2.24",
"symfony/ux-turbo": "^2.24",
"symfony/ux-twig-component": "^2.24",
"symfony/yaml": "7.2.*",
"symfonycasts/tailwind-bundle": "^0.10.0",
@@ -90,7 +78,7 @@
"post-update-cmd": [
"@auto-scripts"
],
"sym": "docker compose exec app ./bin/console"
"sym": "docker compose exec php ./bin/console"
},
"conflict": {
"symfony/symfony": "*"
@@ -102,9 +90,6 @@
}
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
"symfony/maker-bundle": "^1.62"
}
}

2025
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,8 +14,5 @@ return [
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Flasher\Symfony\FlasherSymfonyBundle::class => ['all' => true],
];

View File

@@ -1,57 +0,0 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_ldap
form_login_ldap:
login_path: app_login
check_path: app_login
enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -1,55 +0,0 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_local
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -8,8 +8,8 @@ framework:
# Other options include:
# Redis
app: cache.adapter.redis
default_redis_provider: '%app.cache.redis.host%'
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu

View File

@@ -18,24 +18,18 @@ doctrine:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
# App:
# type: attribute
# is_bundle: false
# dir: '%kernel.project_dir%/src/Entity'
# prefix: 'App\Entity'
# alias: App
Download:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Download/Framework/Entity'
prefix: 'App\Download\Framework\Entity'
alias: Download
User:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/User/Framework/Entity'
prefix: 'App\User\Framework\Entity'
alias: User
Monitor:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Monitor/Framework/Entity'
prefix: 'App\Monitor\Framework\Entity'
alias: Download
controller_resolver:
auto_mapping: false

View File

@@ -0,0 +1,48 @@
flasher:
# Default notification library (e.g., 'flasher', 'toastr', 'noty', 'notyf', 'sweetalert')
default: flasher
# Path to the main PHPFlasher JavaScript file
main_script: '/vendor/flasher/flasher.min.js'
# List of CSS files to style your notifications
styles:
- '/vendor/flasher/flasher.min.css'
# Set global options for all notifications (optional)
# options:
# # Time in milliseconds before the notification disappears
# timeout: 5000
# # Where the notification appears on the screen
# position: 'top-right'
# Automatically inject JavaScript and CSS assets into your HTML pages
inject_assets: true
# Enable message translation using Symfony's translation service
translate: true
# URL patterns to exclude from asset injection and flash_bag conversion
excluded_paths:
- '/^\/_profiler/'
- '/^\/_fragment/'
# Map Symfony flash message keys to notification types
flash_bag:
success: ['success']
error: ['error', 'danger']
warning: ['warning', 'alarm']
info: ['info', 'notice', 'alert']
# Set criteria to filter which notifications are displayed (optional)
# filter:
# # Maximum number of notifications to show at once
# limit: 5
# Define notification presets to simplify notification creation (optional)
# presets:
# # Example preset:
# entity_saved:
# type: 'success'
# title: 'Entity saved'
# message: 'Entity saved successfully'

View File

@@ -2,16 +2,8 @@
framework:
secret: '%env(APP_SECRET)%'
serializer:
default_context:
enable_max_depth: true
trusted_proxies: 'private_ranges'
# trust *all* "X-Forwarded-*" headers
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true

View File

@@ -1,8 +0,0 @@
mercure:
hubs:
default:
url: '%app.mercure.url%'
public_url: '%app.mercure.public_url%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'

View File

@@ -1,19 +1,11 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
failure_transport: failed
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 1
multiplier: 1
async: '%env(MESSENGER_TRANSPORT_DSN)%'
failed: 'doctrine://default?queue_name=failed'
default_bus: messenger.bus.default
@@ -25,11 +17,6 @@ framework:
# Route your messages to the transports
# 'App\Message\YourMessage': async
'App\Download\Action\Command\DownloadMediaCommand': async
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async
'App\Monitor\Action\Command\MonitorMovieCommand': async
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': async
# when@test:
# framework:

View File

@@ -5,27 +5,13 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_local
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
provider: users_in_memory
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
@@ -36,10 +22,8 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:

View File

@@ -1,7 +0,0 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
providers:

View File

@@ -1,11 +0,0 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true
when@test:
framework:
profiler: { collect: false }

View File

@@ -6,26 +6,3 @@ controllersIndex:
defaults:
schemes: [ 'https' ]
controllersUser:
resource:
path: ../src/User/Framework/Controller
namespace: App\User\Framework\Controller
type: attribute
defaults:
schemes: ['https']
controllersDownload:
resource:
path: ../src/Download/Framework/Controller
namespace: App\Download\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersMonitor:
resource:
path: ../src/Monitor/Framework/Controller
namespace: App\Monitor\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

@@ -1,8 +0,0 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler

View File

@@ -1,61 +0,0 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_local:
entity:
class: App\User\Framework\Entity\User
property: email
app_ldap:
id: App\User\Framework\Security\LdapUserProvider
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: app_ldap
entry_point: form_login_ldap
form_login_ldap:
login_path: app_login
check_path: app_login
enable_csrf: true
service: Symfony\Component\Ldap\Ldap
dn_string: '%env(LDAP_DN_STRING)%'
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -4,21 +4,6 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
# Media
media.default_movies_dir: movies
media.default_tvshows_dir: tvshows
media.movies_path: '%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%'
# Mercure
app.mercure.url: 'http://app/.well-known/mercure'
app.mercure.public_url: '%env(APP_URL)%/.well-known/mercure'
# Cache
app.cache.adapter: '%env(default:app.cache.adapter.default:CACHE_ADAPTER)%'
app.cache.redis.host: '%env(default:app.cache.redis.host.default:REDIS_HOST)%'
app.cache.adapter.default: 'filesystem'
app.cache.redis.host.default: 'redis://redis'
services:
# default configuration for services in *this* file
@@ -38,39 +23,3 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader"
# Session
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'
# LDAP
App\User\Framework\Security\LdapUserProvider:
arguments:
$registerLdapUserHandler: '@App\User\Action\Handler\RegisterLdapUserHandler'
$userRepository: '@App\User\Framework\Repository\UserRepository'
$ldap: '@Symfony\Component\Ldap\LdapInterface'
$baseDn: '%env(LDAP_BASE_DN)%'
$searchDn: '%env(LDAP_BIND_USER)%'
$searchPassword: '%env(LDAP_BIND_PASS)%'
$defaultRoles: ['ROLE_USER']
$uidKey: '%env(LDAP_UID_KEY)%'
# $passwordAttribute: '%env(LDAP_PASSWORD_ATTRIBUTE)%'
Symfony\Component\Ldap\LdapInterface: '@Symfony\Component\Ldap\Ldap'
Symfony\Component\Ldap\Ldap:
arguments: [ '@Symfony\Component\Ldap\Adapter\ExtLdap\Adapter' ]
tags:
- ldap
Symfony\Component\Ldap\Adapter\ExtLdap\Adapter:
arguments:
- host: '%env(LDAP_HOST)%'
port: '%env(LDAP_PORT)%'
encryption: '%env(LDAP_ENCRYPTION)%'
options:
protocol_version: 3
referrals: false

View File

@@ -1,55 +1,15 @@
services:
app:
image: registry.caldwell.digital/home/torsearch-app:${TAG}
php:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
ports:
- '8001:80'
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
- "8001:80"
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}
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
deploy:
replicas: 4
depends_on:
- app
scheduler:
image: registry.caldwell.digital/home/torsearch-scheduler:${TAG}
volumes:
- ./downloads:/var/download
restart: always
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:
replicas: 2

View File

@@ -2,7 +2,6 @@ assets
bash
bin
config
docker
migrations
public
src

View File

@@ -1,21 +0,0 @@
FROM dunglas/frankenphp
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app
COPY --chmod=775 ./bash/entrypoint.sh /usr/local/bin/docker-entrypoint
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile
ENTRYPOINT [ "/usr/local/bin/docker-entrypoint" ]
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]

View File

@@ -1,16 +0,0 @@
FROM dunglas/frankenphp
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "schedule_monitor" ]

View File

@@ -1,18 +0,0 @@
FROM dunglas/frankenphp:php8.4-alpine
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0

View File

@@ -1,66 +0,0 @@
{
{$CADDY_GLOBAL_OPTIONS}
frankenphp {
{$FRANKENPHP_CONFIG}
worker {
file ./public/index.php
num 20
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
{$FRANKENPHP_WORKER_CONFIG}
}
}
}
{$CADDY_EXTRA_CONFIG}
{$SERVER_NAME:localhost} {
log {
{$CADDY_SERVER_LOG_OPTIONS}
# Redact the authorization query parameter that can be set by Mercure
format filter {
request>uri query {
replace authorization REDACTED
}
}
}
root /app/public
encode zstd br gzip
mercure {
# Publisher JWT key
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# Subscriber JWT key
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
# Allow anonymous subscribers (double-check that it's what you want)
anonymous
# Enable the subscription API (double-check that it's what you want)
subscriptions
# Custmo cors
cors_origins *
# Extra directives
{$MERCURE_EXTRA_DIRECTIVES}
}
vulcain
{$CADDY_SERVER_EXTRA_DIRECTIVES}
# Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
header ?Permissions-Policy "browsing-topics=()"
@phpRoute {
not path /.well-known/mercure*
not file {path}
}
rewrite @phpRoute index.php
@frontController path index.php
php @frontController
file_server {
hide *.php
}
}

View File

@@ -1,9 +0,0 @@
[program:messenger-consume]
command=php /var/www/bin/console messenger:consume async --time-limit=3600
user=nobody
numprocs=2
startsecs=0
autostart=true
autorestart=true
startretries=10
process_name=%(program_name)s_%(process_num)02d

View File

@@ -1,57 +0,0 @@
# App must be served over HTTPS (requirement of Mercure)
# Either serve behind an SSL terminating reverse proxy
# or pass your certificates into the 'app' container.
# Please omit any trailing slashes. The APP_URL is
# used to generate the Mercure URL behind the scenes.
APP_URL="https://torsearch.idocode.io"
APP_SECRET="70169beadfbc8101c393cbfbba27a313"
APP_ENV=prod
# Use the DATABASE_URL below to use the MariaDB container
# provided in the example.compose.yml file, or remove this
# line and fill in the details of your own MySQL/MariaDB server
DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
# Fill in your MySQL/MariaDB connection details
#DATABASE_URL="mysql://<mysql user>:<mysql pass>@<mysql host>:3306/<mysql db name>?serverVersion=10.6.19.2-MariaDB&charset=utf8mb4"
# Enter your Real Debrid API key
# This key is never saved anywhere
# else and is passed to Torrentio
# to retrieve download options
#REAL_DEBRID_KEY=""
# Enter you 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=
REAL_DEBRID_KEY="QYYBR7OSQ4VEFKWASDEZ2B4VO67KHUJY6IWOT7HHA7ATXO7QCYDQ"
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# 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"
# LDAP Config: To use LDAP, enter the below fields
# and run 'php bin/console config:set auth.method ldap'
LDAP_HOST=
LDAP_PORT=
LDAP_ENCRYPTION=
LDAP_BASE_DN=
LDAP_BIND_USER=
LDAP_BIND_PASS=
LDAP_DN_STRING=
LDAP_UID_KEY="uid"
# LDAP group that identifies an Admin
# Users with this LDAP group will automatically
# get the admin role in this system.
LDAP_ADMIN_ROLE_DN=""
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_USERNAME_ATTRIBUTE=uid
LDAP_NAME_ATTRIBUTE=displayname

View File

@@ -1,108 +0,0 @@
services:
# The "entrypoint" into the application. This reverse proxy
# proxies traffic back to their respective services. If not
# running behind a reverse proxy inject your SSL certificates
# into this container.
# This container runs the actual web app in a php:8.4-fpm
# base container.
app:
image: code.caldwell.digital/home/torsearch-app:latest
ports:
- '8006:80'
env_file:
- .env
depends_on:
database:
condition: service_healthy
# Downloads happen in this container. Replicate this
# container to run multiple downloads simultaneously.
# Map your "movies" folder to /var/download/movies
# Map your "TV shows" folder to /var/download/tvshows
# If your folders are on another machine, use an NFS volume.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
worker:
image: code.caldwell.digital/home/torsearch-worker:latest
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
command: -vvv
env_file:
- .env
restart: always
depends_on:
app:
condition: service_healthy
# This container handles the monitoring for new media. When new
# monitors are added, jobs are periodically dispatched to this
# container, and the desired media is searched for and downloaded.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest
volumes:
- ./downloads:/var/download
env_file:
- .env
restart: always
depends_on:
app:
condition: service_healthy
# This container facilitates viewing the progress of downloads
# in realtime. It also handles sending alerts and notifications.
# The MERCURE_PUBLISHER_JWT key & MERCURE_SUBSCRIBER_JWT_KEY should
# match the MERCURE_JWT_SECRET environment variable.
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3001:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
database:
image: mariadb:10.11.2
volumes:
- mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
# **Optional**
# Provides a simple method of viewing the database
adminer:
image: adminer
ports:
- "8081:8080"
volumes:
mysql:
mercure_config:
mercure_data:
redis_data:

View File

@@ -1,115 +0,0 @@
services:
# The "entrypoint" into the application. This reverse proxy
# proxies traffic back to their respective services. If not
# running behind a reverse proxy inject your SSL certificates
# into this container.
# This container runs the actual web app in a php:8.4-fpm
# base container.
app:
image: code.caldwell.digital/home/torsearch-app:latest
ports:
- '8006:80'
configs:
- env_file
deploy:
replicas: 2
depends_on:
- database
# Downloads happen in this container. Replicate this
# container to run multiple downloads simultaneously.
# Map your "movies" folder to /var/download/movies
# Map your "TV shows" folder to /var/download/tvshows
# If your folders are on another machine, use an NFS volume.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
worker:
image: code.caldwell.digital/home/torsearch-worker:latest
configs:
- source: env_file
target: /app/bin/.env.local
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
deploy:
replicas: 4
depends_on:
- app
# This container handles the monitoring for new media. When new
# monitors are added, jobs are periodically dispatched to this
# container, and the desired media is searched for and downloaded.
# This container runs a Symfony worker process.
# See: https://symfony.com/doc/current/messenger.html
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest
configs:
- env_file
volumes:
- ./downloads:/var/download
restart: always
depends_on:
- app
# This container facilitates viewing the progress of downloads
# in realtime. It also handles sending alerts and notifications.
# The MERCURE_PUBLISHER_JWT key & MERCURE_SUBSCRIBER_JWT_KEY should
# match the MERCURE_JWT_SECRET environment variable.
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3001:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
database:
image: mariadb:10.11.2
volumes:
- mysql:/var/lib/mysql
environment:
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
healthcheck:
test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
# **Optional**
# Provides a simple method of viewing the database
adminer:
image: adminer
ports:
- "8081:8080"
volumes:
mysql:
mercure_config:
mercure_data:
redis_data:
configs:
env_file:
file: $PWD/.env

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

View File

@@ -25,7 +25,7 @@ return [
'@symfony/ux-live-component' => [
'path' => './vendor/symfony/ux-live-component/assets/dist/live_controller.js',
],
'@hotwired/turbo' => [
'version' => '7.3.0',
'@flasher/flasher' => [
'version' => '2.1.5',
],
];

View File

@@ -1,46 +0,0 @@
<?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 Version20250428133608 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE preference (id VARCHAR(255) NOT NULL, name VARCHAR(255) DEFAULT NULL, description VARCHAR(255) DEFAULT NULL, enabled TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE preference_option (id INT AUTO_INCREMENT NOT NULL, preference_id VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, value VARCHAR(255) DEFAULT NULL, enabled TINYINT(1) NOT NULL, INDEX IDX_607C52FD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE preference_option ADD CONSTRAINT FK_607C52FD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE preference_option DROP FOREIGN KEY FK_607C52FD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference_option
SQL);
}
}

View File

@@ -1,83 +0,0 @@
<?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 Version20250428140450 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL COMMENT '(DC2Type:json)', password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE episode DROP FOREIGN KEY FK_DDAA1CDAACB7A4A
SQL);
$this->addSql(<<<'SQL'
DROP TABLE processed_messages
SQL);
$this->addSql(<<<'SQL'
DROP TABLE episode
SQL);
$this->addSql(<<<'SQL'
DROP TABLE series
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE messenger_messages CHANGE id id BIGINT AUTO_INCREMENT NOT NULL
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE processed_messages (id INT AUTO_INCREMENT NOT NULL, run_id INT NOT NULL, attempt SMALLINT NOT NULL, message_type VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, description VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, dispatched_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', received_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', finished_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', wait_time BIGINT NOT NULL, handle_time BIGINT NOT NULL, memory_usage INT NOT NULL, transport VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tags VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, failure_type VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, failure_message LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, results JSON DEFAULT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE episode (id INT AUTO_INCREMENT NOT NULL, series_id_id INT DEFAULT NULL, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, tvdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, year VARCHAR(5) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, poster VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, season VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, episode VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, episode_code VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, download_directory VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, INDEX IDX_DDAA1CDAACB7A4A (series_id_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE series (id INT AUTO_INCREMENT NOT NULL, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tvdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, year VARCHAR(5) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, poster VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, directory VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, number_seasons INT DEFAULT NULL, number_episodes INT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE episode ADD CONSTRAINT FK_DDAA1CDAACB7A4A FOREIGN KEY (series_id_id) REFERENCES series (id)
SQL);
$this->addSql(<<<'SQL'
DROP TABLE user
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE messenger_messages CHANGE id id INT AUTO_INCREMENT NOT NULL
SQL);
}
}

View File

@@ -1,35 +0,0 @@
<?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 Version20250429020903 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user ADD name VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user DROP name
SQL);
}
}

View File

@@ -1,47 +0,0 @@
<?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 Version20250429032311 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE user_preference (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, preference_id VARCHAR(255) NOT NULL, preference_value VARCHAR(255) DEFAULT NULL, INDEX IDX_FA0E76BFA76ED395 (user_id), INDEX IDX_FA0E76BFD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference ADD CONSTRAINT FK_FA0E76BFA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference ADD CONSTRAINT FK_FA0E76BFD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user_preference DROP FOREIGN KEY FK_FA0E76BFA76ED395
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference DROP FOREIGN KEY FK_FA0E76BFD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE user_preference
SQL);
}
}

View File

@@ -1,35 +0,0 @@
<?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 Version20250430231033 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE sessions
SQL);
}
}

View File

@@ -1,41 +0,0 @@
<?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 Version20250503034641 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE movie_monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) DEFAULT NULL, imdb_id VARCHAR(255) NOT NULL, tmdb_id VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_C183DBABA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor ADD CONSTRAINT FK_C183DBABA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor DROP FOREIGN KEY FK_C183DBABA76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE movie_monitor
SQL);
}
}

View File

@@ -1,53 +0,0 @@
<?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 Version20250505211458 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) DEFAULT NULL, imdb_id VARCHAR(255) NOT NULL, tmdb_id VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', monitor_type VARCHAR(255) NOT NULL, season INT DEFAULT NULL, episode INT DEFAULT NULL, INDEX IDX_E1159985A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD CONSTRAINT FK_E1159985A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor DROP FOREIGN KEY FK_C183DBABA76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE movie_monitor
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE movie_monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tmdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, status VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_C183DBABA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor ADD CONSTRAINT FK_C183DBABA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP FOREIGN KEY FK_E1159985A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE monitor
SQL);
}
}

View File

@@ -1,35 +0,0 @@
<?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 Version20250510185814 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user ADD username VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user DROP username
SQL);
}
}

View File

@@ -1,47 +0,0 @@
<?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 Version20250511050008 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 ADD user_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download ADD CONSTRAINT FK_781A8270A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_781A8270A76ED395 ON download (user_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE download DROP FOREIGN KEY FK_781A8270A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_781A8270A76ED395 ON download
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download DROP user_id
SQL);
}
}

View File

@@ -1,35 +0,0 @@
<?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 Version20250519193350 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 preference ADD type VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE preference DROP type
SQL);
}
}

View File

@@ -1,8 +0,0 @@
parameters:
level: 6
paths:
- bin/
- config/
- public/
- src/
- tests/

2
public/vendor/flasher/flasher.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
public/vendor/flasher/flasher.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4
public/vendor/flasher/manifest.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"/vendor/flasher/flasher.min.js": "/vendor/flasher/flasher.min.js?id=9a255a6680873c0d5fc3d394a2ba3195",
"/vendor/flasher/flasher.min.css": "/vendor/flasher/flasher.min.css?id=7a96e40c68626198d5128ad2fb5d77e0"
}

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'config:set',
description: 'Add a short description for your command',
)]
class ConfigSetCommand extends Command
{
public function __construct()
{
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('key', InputArgument::REQUIRED, 'Config key')
->addArgument('value', InputArgument::REQUIRED, 'Config value')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$key = $input->getArgument('key');
$handlers = [
'auth.method' => 'setAuthMethod',
];
$handler = $handlers[$key];
$this->$handler($input, $io);
$io->success('Success: "' . $input->getArgument('key') . '" set to "' . $input->getArgument('value') . '"');
return Command::SUCCESS;
}
private function setAuthMethod(InputInterface $input, SymfonyStyle $io)
{
$config = [
'local' => 'config/dist/local.security.yaml',
'ldap' => 'config/dist/ldap.security.yaml',
];
$authMethod = $input->getArgument('value');
$io->text('> Setting auth method to: ' . $authMethod);
copy($config[$authMethod], 'config/packages/security.yaml');
}
}

View File

@@ -1,171 +0,0 @@
<?php
namespace App\Command;
use App\User\Framework\Repository\PreferenceOptionRepository;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'db:seed',
description: 'Seed the database with required data.',
)]
class SeedDatabaseCommand extends Command
{
private PreferencesRepository $preferenceRepository;
private PreferenceOptionRepository $preferenceOptionRepository;
public function __construct(
PreferencesRepository $preferenceRepository,
PreferenceOptionRepository $preferenceOptionRepository,
) {
parent::__construct();
$this->preferenceRepository = $preferenceRepository;
$this->preferenceOptionRepository = $preferenceOptionRepository;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->seedPreferences($io);
$this->seedPreferenceOptions($io);
return Command::SUCCESS;
}
private function seedPreferences(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Seeding preferences...');
$preferences = $this->getPreferences();
foreach ($preferences as $preference) {
if ($this->preferenceRepository->find($preference['id'])) {
continue;
}
$this->preferenceRepository->getEntityManager()->persist((new \App\User\Framework\Entity\Preference())
->setId($preference['id'])
->setName($preference['name'])
->setDescription($preference['description'])
->setEnabled($preference['enabled'])
->setType($preference['type'])
);
}
$this->preferenceRepository->getEntityManager()->flush();
}
private function getPreferences(): array
{
return [
[
'id' => 'codec',
'name' => 'Codec',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'resolution',
'name' => 'Resolution',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'language',
'name' => 'Language',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'provider',
'name' => 'Provider',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'movie_folder',
'name' => 'Create new folder for Movies',
'description' => 'When downloading a movie, store it in a new folder in your base \'movies\' folder. (e.g.: .../movies/Inception/Inception.2160p.h265.mkv)',
'enabled' => true,
'type' => 'download'
],
];
}
private function seedPreferenceOptions(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Seeding preference options...');
$options = $this->getPreferenceOptions();
foreach ($options as $option) {
if ($this->preferenceOptionRepository->findBy([
'preference' => $option['preference_id'],
'name' => $option['name'],
'value' => $option['value'],
'enabled' => $option['enabled'],
])) {
continue;
}
$this->preferenceOptionRepository->getEntityManager()->persist(
(new \App\User\Framework\Entity\PreferenceOption())
->setPreference($this->preferenceRepository->find($option['preference_id']))
->setName($option['name'])
->setValue($option['value'])
->setEnabled($option['enabled'])
);
}
$this->preferenceOptionRepository->getEntityManager()->flush();
}
private function getPreferenceOptions(): array
{
return [
[
'preference_id' => 'resolution',
'name' => '720p',
'value' => '720p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '1080p',
'value' => '1080p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '2160p',
'value' => '2160p',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => '-',
'value' => '-',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h264',
'value' => 'h264',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h265/HEVC',
'value' => 'h265',
'enabled' => true
]
];
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Command;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'startup:status',
description: 'Add a short description for your command',
)]
class StartupStatusCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$ready = true;
$messengerTableExists = $this->doesMessengerTableExist();
if (false === $messengerTableExists) {
$ready = false;
}
return (true === $ready) ? Command::SUCCESS : Command::FAILURE;
}
private function doesMessengerTableExist()
{
return $this->entityManager->getConnection()
->createSchemaManager()
->tablesExist('messenger_messages');
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
final class AlertController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')] private readonly Environment $renderer,
private readonly HubInterface $hub,
) {}
#[Route('/alert', name: 'app_alert')]
public function index(): Response
{
$update = new Update(
'alerts',
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => 1,
'title' => 'Added to queue',
'message' => 'This is a testy test!',
])
);
$this->hub->publish($update);
return $this->json([
'Success' => 'Published'
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class DownloadController extends AbstractController
{
public function __construct(
private MessageBusInterface $bus,
) {}
#[Route('/download', name: 'app_download', methods: ['POST'])]
public function download(
DownloadMediaInput $input,
): Response {
try {
$this->bus->dispatch($input->toCommand());
} catch (\Throwable $exception) {
return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
}
}

View File

@@ -5,7 +5,6 @@ namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository;
use App\Tmdb\Tmdb;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -17,15 +16,12 @@ final class IndexController extends AbstractController
) {}
#[Route('/', name: 'app_index')]
public function index(Request $request): Response
public function index(): Response
{
$request->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(),
'active_downloads' => $this->downloadRepository->getActivePaginated(),
'recent_downloads' => $this->downloadRepository->latest(5),
'popular_movies' => $this->tmdb->popularMovies(1, 6),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]);
}
}

View File

@@ -6,12 +6,8 @@ use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
final class SearchController extends AbstractController
@@ -19,7 +15,6 @@ final class SearchController extends AbstractController
public function __construct(
private SearchHandler $searchHandler,
private GetMediaInfoHandler $getMediaInfoHandler,
private MessageBusInterface $bus,
) {}
#[Route('/search', name: 'app_search', methods: ['GET'])]
@@ -35,10 +30,9 @@ final class SearchController extends AbstractController
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $input,
GetMediaInfoInput $getDownloadOptionsInput,
): Response {
$result = $this->getMediaInfoHandler->handle($input->toCommand());
$this->warmDownloadOptionCache($result->media);
$result = $this->getMediaInfoHandler->handle($getDownloadOptionsInput->toCommand());
return $this->render('search/result.html.twig', [
'results' => $result,
@@ -52,32 +46,4 @@ final class SearchController extends AbstractController
]
]);
}
private function warmDownloadOptionCache(TmdbResult $result)
{
if ($result->mediaType === 'tvshows') {
// dispatches a job to get the download options
// for each episode and load them in cache
foreach ($result->episodes as $season => $episodes) {
// Only do the first 2 seasons, so we reduce
// getting rate-limited by Torrentio
if ($season > 2) {
return;
}
foreach ($episodes as $episode) {
$this->bus->dispatch(new GetTvShowOptionsCommand(
tmdbId: $result->tmdbId,
imdbId: $result->imdbId,
season: $season,
episode: $episode['episode_number'],
));
}
}
} elseif ($result->mediaType === 'movies') {
$this->bus->dispatch(new GetMovieOptionsCommand(
$result->tmdbId,
$result->imdbId,
));
}
}
}

View File

@@ -6,90 +6,34 @@ use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class TorrentioController extends AbstractController
{
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly HubInterface $hub,
private readonly \Twig\Environment $renderer,
) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
public function movieOptions(GetMovieOptionsInput $input): Response
{
$cacheId = sprintf(
"page.torrentio.movies.%s.%s",
$input->tmdbId,
$input->imdbId
);
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
});
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
public function tvShowOptions(GetTvShowOptionsInput $input): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
$cache->delete($cacheId);
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderer->render('Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Torrentio cache Cleared.',
])
));
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
}
}

View File

@@ -15,7 +15,5 @@ class DownloadMediaCommand implements CommandInterface
public string $filename,
public string $mediaType,
public string $imdbId,
public int $userId,
public ?int $downloadId = null,
) {}
}

View File

@@ -6,7 +6,6 @@ use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface;
use App\User\Framework\Repository\UserRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -18,24 +17,18 @@ readonly class DownloadMediaHandler implements HandlerInterface
public function __construct(
private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository,
private UserRepository $userRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
if (null === $command->downloadId) {
$download = $this->downloadRepository->insert(
$this->userRepository->find($command->userId),
$command->url,
$command->title,
$command->filename,
$command->imdbId,
$command->mediaType,
""
);
} else {
$download = $this->downloadRepository->find($command->downloadId);
}
$download = $this->downloadRepository->insert(
$command->url,
$command->title,
$command->filename,
$command->imdbId,
$command->mediaType,
""
);
try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');

View File

@@ -25,10 +25,6 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')]
public string $imdbId,
public ?int $userId = null,
public ?int $downloadId = null,
) {}
public function toCommand(): CommandInterface
@@ -39,8 +35,6 @@ class DownloadMediaInput implements InputInterface
$this->filename,
$this->mediaType,
$this->imdbId,
$this->userId,
$this->downloadId,
);
}
}

View File

@@ -10,11 +10,11 @@ use App\Message\DownloadTvShowMessage;
interface DownloaderInterface
{
/**
* @param string $mediaType
* @param string $baseDir
* @param string $title
* @param string $url
* @return void
* Downloads the requested file.
*/
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void;
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void;
}

View File

@@ -3,7 +3,6 @@
namespace App\Download\Downloader;
use App\Download\Framework\Entity\Download;
use App\Monitor\Service\MediaFiles;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
@@ -12,26 +11,25 @@ class ProcessDownloader implements DownloaderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
) {}
/**
* @inheritDoc
*/
public function download(string $mediaType, string $title, string $url, ?int $downloadId): void
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void
{
/** @var Download $downloadEntity */
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
$downloadEntity->setProgress(0);
$this->entityManager->flush();
$downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences();
$path = $this->getDownloadPath($mediaType, $title, $downloadPreferences);
$process = (new Process([
'wget',
$process = new Process([
'/bin/sh',
'/var/www/bash/app/wget_download.sh',
$baseDir,
$title,
$url
]))->setWorkingDirectory($path);
]);
$process->setTimeout(1800); // 30 min
$process->setIdleTimeout(600); // 10 min
@@ -63,20 +61,4 @@ 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");
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Download\Framework\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly HubInterface $hub,
) {}
#[Route('/api/download', name: 'api_download', methods: ['POST'])]
public function download(
Request $request,
DownloadMediaInput $input,
): Response {
$download = $this->downloadRepository->insert(
$this->getUser(),
$input->url,
$input->title,
$input->filename,
$input->imdbId,
$input->mediaType,
"",
);
$this->downloadRepository->getEntityManager()->persist($download);
$this->downloadRepository->getEntityManager()->flush();
$input->downloadId = $download->getId();
$input->userId = $this->getUser()->getId();
try {
$this->bus->dispatch($input->toCommand());
} catch (\Throwable $exception) {
return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
$this->hub->publish(new Update(
$request->getSession()->get('mercure_alert_topic'),
$this->renderView('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => '"' . $input->title . '" added to Queue',
])
));
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Download\Framework\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class WebController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
private readonly HubInterface $hub,
) {}
#[Route('/downloads', name: 'app_downloads', methods: ['GET'])]
public function download(): Response {
return $this->render('downloads/index.html.twig');
}
}

View File

@@ -3,12 +3,11 @@
namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
#[Broadcast(template: 'broadcast/Download.stream.html.twig')]
#[Broadcast]
class Download
{
#[ORM\Id]
@@ -40,9 +39,6 @@ class Download
#[ORM\Column(length: 255, nullable: true)]
private ?string $batchId = null;
#[ORM\ManyToOne(inversedBy: 'downloads')]
private ?User $user = null;
public function getId(): ?int
{
return $this->id;
@@ -150,16 +146,4 @@ class Download
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Download\Framework\MessageHandler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Handler\DownloadMediaHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(handles: DownloadMediaCommand::class)]
class DownloadMediaMessageHandler
{
public function __construct(
private DownloadMediaHandler $downloadMediaHandler,
) {}
public function __invoke(DownloadMediaCommand $command)
{
$this->downloadMediaHandler->handle($command);
}
}

View File

@@ -3,11 +3,11 @@
namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\Download;
use App\User\Framework\Entity\User;
use App\Util\Paginator;
use App\ValueObject\DownloadRequest;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
use Knp\Component\Pager\Paginator;
use Knp\Component\Pager\PaginatorInterface;
/**
* @extends ServiceEntityRepository<Download>
@@ -16,43 +16,41 @@ class DownloadRepository extends ServiceEntityRepository
{
private ManagerRegistry $managerRegistry;
private Paginator $paginator;
public function __construct(ManagerRegistry $registry, ManagerRegistry $managerRegistry, Paginator $paginator)
public function __construct(ManagerRegistry $registry, ManagerRegistry $managerRegistry)
{
parent::__construct($registry, Download::class);
$this->managerRegistry = $managerRegistry;
$this->paginator = $paginator;
}
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10)
public function getCompletePaginated(int $pageNumber = 1, int $perPage = 10)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete'])
->setParameter('user', $user)
->setFirstResult($firstResult)
->setMaxResults($perPage)
->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage);
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
}
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5)
public function getActivePaginated(int $pageNumber = 1, int $perPage = 5)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user')
->orderBy('d.id', 'ASC')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('user', $user)
->setFirstResult($firstResult)
->setMaxResults($perPage)
->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage);
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
}
public function insert(
UserInterface $user,
string $url,
string $title,
string $filename,
@@ -61,16 +59,13 @@ class DownloadRepository extends ServiceEntityRepository
string $batchId,
string $status = 'New'
): Download {
/** @var User $user */
$download = (new Download())
->setUser($user)
->setUrl($url)
->setTitle($title)
->setFilename($filename)
->setImdbId($imdbId)
->setMediaType($mediaType)
->setBatchId($batchId)
->setProgress(0)
->setStatus($status);
$this->getEntityManager()->persist($download);
@@ -79,6 +74,22 @@ class DownloadRepository extends ServiceEntityRepository
return $download;
}
public function insertFromDownloadRequest(DownloadRequest $request): Download
{
$download = (new Download())
->setUrl($request->downloadUrl)
->setTitle($request->seriesName)
->setFilename($request->filename)
->setImdbId($request->imdbCode)
->setMediaType($request->mediaType)
->setStatus('New');
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function updateStatus(int $id, string $status): Download
{
$download = $this->find($id);
@@ -94,6 +105,18 @@ class DownloadRepository extends ServiceEntityRepository
$this->getEntityManager()->flush();
}
public function getPendingByBatchId(string $batchId): ?array
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.batchId = :batchId')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('batchId', $batchId)
->getQuery();
return $query->getResult();
}
public function latest(int $limit = 1)
{
return $this->createQueryBuilder('d')

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class AddMonitorCommand implements CommandInterface
{
public function __construct(
public string $userId,
public string $title,
public string $imdbId,
public string $tmdbId,
public string $monitorType,
public ?int $season,
public ?int $episode,
) {}
}

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