Compare commits

..

78 Commits

Author SHA1 Message Date
8aba35fee1 fix: scopes downloads and monitors to users 2025-05-11 16:27:53 -05:00
6817bd8c80 wip: scopes downloads to usrs 2025-05-11 00:12:55 -05:00
854177a121 feat: command to set auth method 2025-05-10 23:53:46 -05:00
ddb71b3bb0 chore: cleaning 2025-05-10 20:05:57 -05:00
35a3e48ac9 fix: mostly working ldap 2025-05-10 20:03:17 -05:00
6e55195e6f wip-feat: authenticates with LDAP 2025-05-10 08:48:12 -05:00
e325687af5 chore: style updates 2025-05-09 21:30:43 -05:00
4506306377 fix: styles on monitoring and search buttons 2025-05-09 16:25:35 -05:00
4287b52bd4 fix: a few bugs after moving code to own directory 2025-05-09 16:03:01 -05:00
3724bcbb16 fix: moves monitor logic into own directory 2025-05-09 15:03:42 -05:00
6c2cd7510f fix: calls clearCache phing target 2025-05-09 12:33:32 -05:00
98bf8d2880 fix: uses default image in episode results if image is missing, reduces cache life to next hour, clears cache during build 2025-05-09 12:30:56 -05:00
20ade478b1 feat: adds episode air date to results 2025-05-08 23:47:08 -05:00
4eed5fef78 feat: deploys monitor container 2025-05-08 22:57:51 -05:00
5ff9842eaa wip-feat: adds functionality to Monitor button 2025-05-08 22:48:25 -05:00
b93da3df1d fix: MonitorDispatcher runs evey 10 mins 2025-05-07 22:41:19 -05:00
fe0ab2ef5a fix: missing status check in query 2025-05-07 22:40:19 -05:00
25ff3e726d wip-feat: working tv season/episode monitor 2025-05-07 22:13:38 -05:00
527adb73c1 wip-feat: dispatches monitor commands for episodes, seasons, & shows 2025-05-06 00:00:45 -05:00
9166b4bbc8 feat: movie monitoring 2025-05-03 23:55:31 -05:00
5688b3a0df feat: button to add movie monitor 2025-05-03 11:53:23 -05:00
babcb00440 feat: movie download monitor 2025-05-03 09:34:40 -05:00
993b34d668 patch: login/register styles 2025-05-01 23:09:18 -05:00
73b3e5179a patch: active/inactive styles on navbar 2025-05-01 23:01:15 -05:00
d3176baff2 patch: fixes missing null check 2025-05-01 22:35:54 -05:00
cc77cccf0b patch: copies .env.properties instead of passing each phing var 2025-05-01 22:04:24 -05:00
b0c10a028a patch: caches torrentio movie results 2025-05-01 21:58:02 -05:00
12bf90a2b4 patch: adds full page caching to TMBD & torrentio results 2025-05-01 21:46:14 -05:00
687b5ed873 Merge branch 'main' into dev-redis 2025-05-01 20:41:26 -05:00
e5f0f358b7 fix: adds redis phing var 2025-05-01 20:21:30 -05:00
fd84648100 patch: sets default download progress to 0, orders active downloads ASC 2025-05-01 16:37:08 -05:00
f3285ba60c patch: fixes extra ajax call on movie options page 2025-05-01 16:35:12 -05:00
4f6f8f43f1 wip: redis integration 2025-05-01 16:34:30 -05:00
b23d8a2ba3 fix: prefills provider preference on filter 2025-04-30 22:23:42 -05:00
bfd5f53d67 fix: multiple options being pre-selected after filter change 2025-04-30 22:03:19 -05:00
f10168a1a7 fix: language filter 2025-04-30 21:39:41 -05:00
8970ca0f8f fix: deploys replica app container 2025-04-30 18:51:09 -05:00
994bd775ea fix: stores session in DB 2025-04-30 18:13:19 -05:00
d0e7941809 fix: chanaged wrong service last commit 2025-04-30 17:31:17 -05:00
a28f0d9369 fix: only deploys 1 replica webapp until a better session handling method is implemented 2025-04-30 17:15:41 -05:00
964cdca151 fix: adds migrateDb to build.xml 2025-04-30 16:00:20 -05:00
b59069551a fix: download options filter uses user preferences 2025-04-30 15:53:10 -05:00
3971cf3260 wip: filter twig component pre-select options 2025-04-29 23:40:27 -05:00
8a1a89f17d wip-feat: populates filter from api options 2025-04-29 22:10:47 -05:00
c3eaf109e3 feat: stores user's media preferences 2025-04-29 16:17:40 -05:00
0225bead60 fix: displays user's name & email in left footer 2025-04-28 21:49:54 -05:00
1b1feaebec wip-feat: user, login/logout, authentication/authorization 2025-04-28 21:45:12 -05:00
7045116b56 wip: adds preference & preference_option tables 2025-04-28 08:50:51 -05:00
883442225f fix: broken search button 2025-04-27 22:11:09 -05:00
c664e9fbca feat: presents popular tv shows on landing page 2025-04-27 21:36:45 -05:00
5d257e4404 fix: removes duplicate Dockerfile instruction 2025-04-27 21:15:01 -05:00
a6dc4f0b03 fix: removes default apache vhost 2025-04-27 16:33:21 -05:00
3c6e41af94 wip-feat: adds mercure to deployment 2025-04-27 16:32:34 -05:00
3e081df01c fix: adds download record at time of download 2025-04-27 16:30:08 -05:00
3384720c09 wip-feat: mercure download progress 2025-04-27 11:04:40 -05:00
c32ff2e464 wip-feat: mercure alerts 2025-04-27 09:55:55 -05:00
6138c94d7a fix: missing app secret 2025-04-27 00:24:55 -05:00
a1a38cb74c fix-feat: download selected button 2025-04-26 22:30:44 -05:00
e9ccb5ad2b fix: select all button 2025-04-26 21:38:17 -05:00
9d350a572d wip-feat: pre-selects option for each episode 2025-04-25 22:01:13 -05:00
cd271b568b feat: converts active download list to live component with polling 2025-04-25 21:50:51 -05:00
6b88483635 fix: links popular movies to their download options 2025-04-25 16:50:33 -05:00
0120ddcedd feat: shows popular movies 2025-04-25 16:42:57 -05:00
7270fa2936 chore: adds doc blocks to Search module 2025-04-25 15:23:04 -05:00
6a2567bf98 wip-feat: lists active & recent downloads on landing page 2025-04-25 13:30:15 -05:00
c12a33de86 fix: http urls 2025-04-23 22:48:15 -05:00
7d84e13a40 fix: maps volume correctly 2025-04-23 22:20:23 -05:00
5c5937d01f fix: rolling updates & lowers verbosity of worker process 2025-04-23 22:06:44 -05:00
35718958ee fix: uses correct title for directory name 2025-04-23 20:48:01 -05:00
d8e8c7f0f0 fix: DownloadRepository 2025-04-23 18:19:06 -05:00
27164a8680 fix: missing tmdb key 2025-04-23 17:10:35 -05:00
a5c02464df fix: adds download workers 2025-04-23 17:03:14 -05:00
35db1ad6fd fix: updates build.xml & .env.dist 2025-04-23 17:02:15 -05:00
f9fad08a30 fix: adds tailwind:build step to phing 2025-04-23 16:50:50 -05:00
f23048e813 fix: adds wget_download.sh, fixes images tag 2025-04-23 16:24:46 -05:00
6dc6fbd449 fix-feat: ajax download call 2025-04-23 16:17:03 -05:00
5402680abf fix: creates separate Symfony Message Handler for download 2025-04-23 15:38:39 -05:00
a5c827b48f wip-feat: adds download message queue logic 2025-04-23 14:36:44 -05:00
174 changed files with 9484 additions and 866 deletions

17
.env
View File

@@ -18,3 +18,20 @@
APP_ENV=dev
APP_SECRET=
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# 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"
###< doctrine/doctrine-bundle ###
###> 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 ###

View File

@@ -5,3 +5,4 @@ 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!"
MONITOR_FREQUENCY="* * * * *"

View File

@@ -1 +1,31 @@
APP_ENV=%%app_env%%
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%%
REDIS_HOST="%%redis_host%%"
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="cn=admins,cn=groups,cn=accounts,dc=caldwell,dc=local"
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_USERNAME_ATTRIBUTE=uid
LDAP_NAME_ATTRIBUTE=displayname

4
.gitignore vendored
View File

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

View File

@@ -1 +1,10 @@
FROM registry.caldwell.digital/library/php:8.4-apache
RUN apt-get update && \
apt-get install libldap2-dev -y && \
rm -rf /var/lib/apt/lists/* && \
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \
docker-php-ext-install ldap
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

@@ -1,6 +1,11 @@
FROM registry.caldwell.digital/library/php:8.4-apache
RUN apt-get update && \
apt-get install libldap2-dev -y && \
rm -rf /var/lib/apt/lists/* && \
docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu/ && \
docker-php-ext-install ldap
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

@@ -8,3 +8,13 @@ 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

@@ -1,4 +1,24 @@
{
"controllers": [],
"controllers": {
"@symfony/ux-live-component": {
"live": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"@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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,37 @@
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 = {
url: String,
title: String,
filename: String,
mediaType: String,
imdbId: String,
}
download() {
fetch('/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
url: this.urlValue,
title: this.element.dataset['title'],
filename: this.filenameValue,
mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue,
})
})
.then(res => res.json())
.then(json => {
console.log(json)
})
}
}

View File

@@ -0,0 +1,93 @@
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

@@ -0,0 +1,24 @@
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,25 +6,33 @@ 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,
imdbId: String
};
static targets = ['list']
options = []
optionsLoaded = false
async connect() {
await this.setOptions();
}
async setOptions() {
if (this.options.length === 0) {
await fetch(`/torrentio/movies/${this.imdbIdValue}`)
if (false === this.optionsLoaded) {
this.optionsLoaded = true;
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
.then(res => res.text())
.then(response => {
this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
});
}
}
@@ -34,7 +42,54 @@ export default class extends Controller {
return true;
}
listTargetConnected(target) {
// console.log(target);
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;
}
});
}
}

View File

@@ -0,0 +1,25 @@
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

@@ -21,15 +21,17 @@ export default class extends Controller {
}
static outlets = ['movie-results', 'tv-results']
static targets = ['resolution', 'codec', 'language', 'provider', 'season']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected']
static values = {
'media-type': String,
'episodes': Array,
}
connect() {
async connect() {
if (this.mediaTypeValue === "tvshows") {
this.activeFilter['season'] = 1;}
this.activeFilter['season'] = 1;
}
await this.filter();
}
async movieResultsOutletConnected(outlet) {
@@ -48,6 +50,7 @@ export default class extends Controller {
this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset);
})
await this.filter();
}
addLanguages(option, props) {
@@ -58,9 +61,22 @@ export default class extends Controller {
}
});
this.languageTarget.innerHTML = '<option value="">n/a</option>';
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 += this.languages.sort()
.map((language) => '<option value="'+language+'">'+language+'</option>')
.map((language) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === language) {
return;
}
return '<option value="'+language+'">'+language+'</option>';
})
.join();
}
@@ -69,9 +85,22 @@ export default class extends Controller {
this.providers.push(props['provider']);
}
this.providerTarget.innerHTML = '<option value="">n/a</option>';
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 += this.providers.sort()
.map((provider) => '<option value="'+provider+'">'+provider+'</option>')
.map((provider) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === provider) {
return;
}
return '<option value="' + provider + '">' + provider + '</option>'
})
.join();
}
@@ -89,77 +118,33 @@ 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();
}
uncheckSelectAllBtn() {
this.selectAllTarget.checked = false;
}
selectAllEpisodes() {
this.tvResultsOutlets.forEach((episode) => {
if (episode.isActive()) {
episode.selectEpisodeForDownload()
}
});
}
if (false === resultList.isActive()) {
return;
downloadSelectedEpisodes() {
this.tvResultsOutlets.forEach(episode => {
if (episode.isActive() && episode.isSelected()) {
episode.download();
}
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.selectInput.checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
if ("tvshows" === this.mediaTypeValue) {
resultList.countTarget.innerText = count;
}
});
}
await results.forEach((list) => filterOperation(list, currentSeason));
});
this.selectAllTarget.checked = false;
}
}

View File

@@ -6,7 +6,11 @@ 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,
imdbId: String,
season: String,
@@ -14,7 +18,7 @@ export default class extends Controller {
active: Boolean,
};
static targets = ['list', 'count']
static targets = ['list', 'count', 'episodeSelector']
static outlets = ['loading-icon']
options = []
@@ -25,18 +29,31 @@ export default class extends Controller {
}
async setOptions() {
if (true === this.activeValue) {
if (true === this.activeValue && this.optionsLoaded === false) {
this.optionsLoaded = true;
await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
.then(response => {
this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
this.optionsLoaded = true;
if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.episodeSelectorTarget.disabled = 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');
@@ -45,8 +62,11 @@ export default class extends Controller {
}
}
setInActive() {
async setInActive() {
this.activeValue = false;
// if (true === this.hasEpisodeSelectorTarget()) {
this.episodeSelectorTarget.checked = false;
// }
this.element.classList.add('hidden');
}
@@ -54,7 +74,95 @@ export default class extends Controller {
return this.activeValue;
}
isSelected() {
return this.episodeSelectorTarget.checked;
}
selectEpisodeForDownload() {
if (true === this.isActive() && this.episodeSelectorTarget.disabled === false) {
this.episodeSelectorTarget.checked = !this.episodeSelectorTarget.checked;
}
}
toggleList() {
this.listTarget.classList.toggle('hidden');
}
download() {
this.options.forEach(option => {
const optionSelector = option.querySelector('input[type="checkbox"]');
if (true === optionSelector.checked) {
const downloadBtn = option.querySelector('button.download-btn');
const downloadBtnController = this.application.getControllerForElementAndIdentifier(downloadBtn, 'download-button');
downloadBtnController.download();
optionSelector.checked = false;
this.episodeSelectorTarget.checked = false;
}
})
}
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

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

After

Width:  |  Height:  |  Size: 928 B

View File

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

After

Width:  |  Height:  |  Size: 224 B

View File

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

After

Width:  |  Height:  |  Size: 257 B

2
bash/app/wget_download.sh Executable file
View File

@@ -0,0 +1,2 @@
# $1 = movies/tvshows/etc, $2 = title of media, $3 = URL of download
cd /var/download/${1} && if [ ! -d "${2}" ]; then mkdir "${2}"; fi && cd "${2}" && wget "${3}"

View File

@@ -4,6 +4,11 @@
DocumentRoot /var/www/public
DirectoryIndex /index.php
<LocationMatch "/hub/">
ProxyPass http://mercure:80/
ProxyPassReverse http://mercure:80/
</LocationMatch>
<Directory /var/www/public>
AllowOverride None
Order Allow,Deny

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project name="Caldwell Digital Symfony Template" default="build">
<!-- build dev for dev envs -->
<target name="build" depends="setEnv,composer,compileAssets" />
<target name="build" depends="setEnv,composer,compileAssets,migrateDb,clearCache" />
<target name="composer" description="Run composer">
<exec executable="composer">
@@ -9,20 +9,35 @@
</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" />
<arg value="tailwind:build" />
</exec>
<exec executable="php">
<arg value="bin/console" />
<arg value="asset-map:compile" />
</exec>
</target>
<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="db_url" value="${DATABASE_URL}" />
</replacetokens>
</filterchain>
</copy>
<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>
</project>

View File

@@ -12,11 +12,50 @@ services:
- $PWD/bash/caddy:/etc/caddy
- $PWD/bash/certs:/etc/ssl
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
php:
build: .
volumes:
- ./:/var/www
worker:
build: .
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume async -vv --time-limit=3600
scheduler:
build: .
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3000: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
ports:
@@ -36,3 +75,6 @@ services:
volumes:
mysql:
mercure_data:
mercure_config:
redis_data:

View File

@@ -8,20 +8,38 @@
"ext-ctype": "*",
"ext-iconv": "*",
"1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4",
"nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"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",
@@ -81,6 +99,9 @@
}
},
"require-dev": {
"symfony/maker-bundle": "^1.62"
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
}
}

3487
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,4 +11,10 @@ return [
OneToMany\RichBundle\RichBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
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],
];

56
config/dist/ldap.security.yaml vendored Normal file
View File

@@ -0,0 +1,56 @@
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: ^/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

54
config/dist/local.security.yaml vendored Normal file
View File

@@ -0,0 +1,54 @@
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: ^/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: redis://localhost
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_HOST)%'
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu

11
config/packages/csrf.yaml Normal file
View File

@@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

View File

@@ -0,0 +1,66 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
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
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -2,8 +2,16 @@
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
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
#esi: true
#fragments: true

View File

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

View File

@@ -0,0 +1,39 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
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
failed: 'doctrine://default?queue_name=failed'
default_bus: messenger.bus.default
buses:
messenger.bus.default: []
routing:
# 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
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View File

@@ -5,13 +5,29 @@ 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: users_in_memory
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
@@ -22,8 +38,9 @@ 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: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:

View File

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

View File

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

View File

@@ -4,5 +4,20 @@ controllersIndex:
namespace: App\Controller
type: attribute
defaults:
schemes: [ https ]
schemes: [ 'https' ]
controllersUser:
resource:
path: ../src/User/Framework/Controller
namespace: App\User\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

@@ -0,0 +1,5 @@
live_component:
resource: '@LiveComponentBundle/config/routes.php'
prefix: '/_components'
# adjust prefix to add localization to your components
#prefix: '/{_locale}/_components'

View File

@@ -0,0 +1,8 @@
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

61
config/security.yaml Normal file
View File

@@ -0,0 +1,61 @@
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,6 +4,10 @@
# 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.default_movies_dir: movies
media.default_tvshows_dir: tvshows
media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%'
services:
# default configuration for services in *this* file
@@ -22,3 +26,39 @@ 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:
$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,5 +1,42 @@
services:
php:
image: code.caldwell.digital/home/torsearch/app:${TAG}
image: registry.caldwell.digital/home/torsearch/app:${TAG}
ports:
- "8001:80"
deploy:
replicas: 2
worker:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
deploy:
replicas: 2
scheduler:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3000: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
volumes:
mercure_config:
mercure_data:

View File

@@ -22,4 +22,10 @@ return [
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
'@symfony/ux-live-component' => [
'path' => './vendor/symfony/ux-live-component/assets/dist/live_controller.js',
],
'@hotwired/turbo' => [
'version' => '7.3.0',
],
];

0
migrations/.gitignore vendored Executable file
View File

View File

@@ -0,0 +1,31 @@
<?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 Version20241211055503 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('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_75EA56E0FB7336F0 (queue_name), INDEX IDX_75EA56E0E3BD61CE (available_at), INDEX IDX_75EA56E016BA31DB (delivered_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE messenger_messages');
}
}

View File

@@ -0,0 +1,43 @@
<?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 Version20241218024301 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('CREATE TABLE episode (id INT AUTO_INCREMENT NOT NULL, series_id_id INT DEFAULT NULL, imdb_id VARCHAR(255) DEFAULT NULL, tvdb_id VARCHAR(255) DEFAULT NULL, title VARCHAR(255) DEFAULT NULL, year VARCHAR(5) DEFAULT NULL, poster VARCHAR(500) DEFAULT NULL, season VARCHAR(255) DEFAULT NULL, episode VARCHAR(255) DEFAULT NULL, episode_code VARCHAR(255) DEFAULT NULL, download_directory VARCHAR(500) DEFAULT NULL, INDEX IDX_DDAA1CDAACB7A4A (series_id_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE series (id INT AUTO_INCREMENT NOT NULL, imdb_id VARCHAR(255) NOT NULL, tvdb_id VARCHAR(255) DEFAULT NULL, title VARCHAR(255) DEFAULT NULL, year VARCHAR(5) DEFAULT NULL, poster VARCHAR(500) DEFAULT NULL, directory VARCHAR(255) DEFAULT NULL, number_seasons INT DEFAULT NULL, number_episodes INT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE episode ADD CONSTRAINT FK_DDAA1CDAACB7A4A FOREIGN KEY (series_id_id) REFERENCES series (id)');
$this->addSql('DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages');
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages');
$this->addSql('DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages');
$this->addSql('ALTER TABLE messenger_messages CHANGE id id INT AUTO_INCREMENT NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE episode DROP FOREIGN KEY FK_DDAA1CDAACB7A4A');
$this->addSql('DROP TABLE episode');
$this->addSql('DROP TABLE series');
$this->addSql('ALTER TABLE messenger_messages CHANGE id id BIGINT AUTO_INCREMENT NOT NULL');
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20241226201901 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
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20241226205937 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('CREATE TABLE download (id INT AUTO_INCREMENT NOT NULL, imdb_id VARCHAR(20) DEFAULT NULL, title VARCHAR(255) DEFAULT NULL, url VARCHAR(1024) NOT NULL, filename VARCHAR(1024) DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, progress INT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE download');
}
}

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
<?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 Version20250217221120 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('CREATE TABLE processed_messages (id INT AUTO_INCREMENT NOT NULL, run_id INT NOT NULL, attempt SMALLINT NOT NULL, message_type VARCHAR(255) NOT NULL, description VARCHAR(255) DEFAULT NULL, 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) NOT NULL, tags VARCHAR(255) DEFAULT NULL, failure_type VARCHAR(255) DEFAULT NULL, failure_message LONGTEXT DEFAULT NULL, results JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE preferences (id INT AUTO_INCREMENT NOT NULL, value VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' ');
$this->addSql('DROP TABLE processed_messages');
}
}

View File

@@ -0,0 +1,46 @@
<?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

@@ -0,0 +1,83 @@
<?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

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class 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

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class 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

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class 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

@@ -0,0 +1,41 @@
<?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

@@ -0,0 +1,53 @@
<?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

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class 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

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class 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);
}
}

8
phpstan.dist.neon Normal file
View File

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

View File

@@ -0,0 +1,58 @@
<?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

@@ -0,0 +1,37 @@
<?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('broadcast/Alert.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,42 @@
<?php
namespace App\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class DownloadController extends AbstractController
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $bus,
) {}
#[Route('/download', name: 'app_download', methods: ['POST'])]
public function download(
DownloadMediaInput $input,
): Response {
$download = $this->downloadRepository->insert(
$this->getUser(),
$input->url,
$input->title,
$input->filename,
$input->imdbId,
$input->mediaType,
"",
);
$input->downloadId = $download->getId();
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

@@ -2,17 +2,28 @@
namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository;
use App\Tmdb\Tmdb;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends AbstractController
{
public function __construct(
private readonly DownloadRepository $downloadRepository,
private readonly Tmdb $tmdb,
) {}
#[Route('/', name: 'app_index')]
public function index(): Response
{
// dd($this->getUser()->getActiveDownloads());
return $this->render('index/index.html.twig', [
'controller_name' => 'IndexController',
'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(),
'popular_movies' => $this->tmdb->popularMovies(1, 6),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]);
}
}

View File

@@ -6,9 +6,12 @@ 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 Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class SearchController extends AbstractController
{
@@ -30,10 +33,9 @@ final class SearchController extends AbstractController
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $getDownloadOptionsInput,
GetMediaInfoInput $input,
): Response {
$result = $this->getMediaInfoHandler->handle($getDownloadOptionsInput->toCommand());
$result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [

View File

@@ -6,34 +6,89 @@ 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\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/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input): Response
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
{
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
$cacheId = sprintf(
"page.torrentio.movies.%s.%s",
$input->tmdbId,
$input->imdbId
);
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
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,
]);
});
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input): Response
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
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): 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(
'alerts',
$this->renderer->render('broadcast/Alert.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,
]);
});
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DownloadMediaCommand>
*/
class DownloadMediaCommand implements CommandInterface
{
public function __construct(
public string $url,
public string $title,
public string $filename,
public string $mediaType,
public string $imdbId,
public int $userId,
public ?int $downloadId = null,
) {}
}

View File

@@ -1,14 +0,0 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class GetDownloadOptionsCommand implements CommandInterface
{
/** @implements CommandInterface<GetDownloadOptionsCommand> */
public function __construct(
public string $tmdbId,
public string $mediaType,
) {}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Download\Action\Handler;
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;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
/** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */
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);
}
try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
$this->downloader->download(
$command->mediaType,
$command->title,
$command->url,
$download->getId()
);
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
} catch (\Throwable $exception) {
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);
}
return new DownloadMediaResult(200, "Success.");
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use App\Tmdb\Tmdb;
use App\Torrentio\Client\Torrentio;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
class GetDownloadOptionsHandler implements HandlerInterface
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->mediaDetails($command->tmdbId, $command->mediaType);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DownloadMediaInput> */
class DownloadMediaInput implements InputInterface
{
public function __construct(
#[SourceRequest('url')]
public string $url,
#[SourceRequest('title')]
public string $title,
#[SourceRequest('filename')]
public string $filename,
#[SourceRequest('mediaType')]
public string $mediaType,
#[SourceRequest('imdbId')]
public string $imdbId,
public ?int $userId = null,
public ?int $downloadId = null,
) {}
public function toCommand(): CommandInterface
{
return new DownloadMediaCommand(
$this->url,
$this->title,
$this->filename,
$this->mediaType,
$this->imdbId,
$this->downloadId,
$this->userId
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<DownloadMediaResult> */
class DownloadMediaResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
) {}
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Download\Action\Result;
use App\Tmdb\TmdbResult;
class GetDownloadOptionsResult
{
public function __construct(
public TmdbResult $media,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Download\Downloader;
use App\Message\DownloadMessage;
use App\Message\DownloadMovieMessage;
use App\Message\DownloadTvShowMessage;
interface DownloaderInterface
{
/**
* @param string $baseDir
* @param string $title
* @param string $url
* @return void
* Downloads the requested file.
*/
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void;
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Download\Downloader;
use App\Download\Framework\Entity\Download;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class ProcessDownloader implements DownloaderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
/**
* @inheritDoc
*/
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();
$process = new Process([
'/bin/sh',
'/var/www/bash/app/wget_download.sh',
$baseDir,
$title,
$url
]);
$process->setTimeout(1800); // 30 min
$process->setIdleTimeout(600); // 10 min
$process->start();
try {
$progress = 0;
$this->entityManager->flush();
$process->wait(function ($type, $buffer) use ($progress, $downloadEntity): void {
if (Process::ERR === $type) {
$pregMatchOutput = [];
preg_match('/[\d]+%/', $buffer, $pregMatchOutput);
if (!empty($pregMatchOutput)) {
if ($pregMatchOutput[0] !== $progress) {
$progress = (int) $pregMatchOutput[0];
$downloadEntity->setProgress($progress);
$this->entityManager->flush();
}
}
}
fwrite(STDOUT, $buffer);
});
$downloadEntity->setProgress(100);
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
}
$this->entityManager->flush();
}
}

View File

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

View File

View File

@@ -0,0 +1,165 @@
<?php
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')]
class Download
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $imdbId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $mediaType = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 1024)]
private ?string $url = null;
#[ORM\Column(length: 1024, nullable: true)]
private ?string $filename = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $status = null;
#[ORM\Column(nullable: true)]
private ?int $progress = null;
#[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;
}
public function setId(int $id): static
{
$this->id = $id;
return $this;
}
public function getImdbId(): ?string
{
return $this->imdbId;
}
public function setImdbId(?string $imdbId): static
{
$this->imdbId = $imdbId;
return $this;
}
public function getMediaType(): ?string
{
return $this->mediaType;
}
public function setMediaType(?string $mediaType): static
{
$this->mediaType = $mediaType;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(string $url): static
{
$this->url = $url;
return $this;
}
public function getFilename(): ?string
{
return $this->filename;
}
public function setFilename(?string $filename): static
{
$this->filename = $filename;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
public function getProgress(): ?int
{
return $this->progress;
}
public function setProgress(?int $progress): static
{
$this->progress = $progress;
return $this;
}
public function getBatchId(): ?string
{
return $this->batchId;
}
public function setBatchId(?string $batchId): static
{
$this->batchId = $batchId;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
}

View File

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\Download;
use App\User\Framework\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Knp\Component\Pager\Paginator;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Download>
*/
class DownloadRepository extends ServiceEntityRepository
{
private ManagerRegistry $managerRegistry;
public function __construct(ManagerRegistry $registry, ManagerRegistry $managerRegistry)
{
parent::__construct($registry, Download::class);
$this->managerRegistry = $managerRegistry;
}
public function getCompletePaginated(int $pageNumber = 1, int $perPage = 10)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
}
public function getActivePaginated(int $pageNumber = 1, int $perPage = 5)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
}
public function insert(
UserInterface $user,
string $url,
string $title,
string $filename,
string $imdbId,
string $mediaType,
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);
$this->getEntityManager()->flush();
return $download;
}
public function updateStatus(int $id, string $status): Download
{
$download = $this->find($id);
$download->setStatus($status);
$this->getEntityManager()->flush();
return $download;
}
public function delete(int $id)
{
$entity = $this->find($id);
$this->getEntityManager()->remove($entity);
$this->getEntityManager()->flush();
}
public function latest(int $limit = 1)
{
return $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->setParameter('statuses', ['Complete'])
->setMaxResults($limit)
->orderBy('d.id', 'DESC')
->getQuery()
->getResult();
}
}

View File

@@ -0,0 +1,18 @@
<?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,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorMovieCommand implements CommandInterface
{
public function __construct(
public int $movieMonitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvEpisodeCommand implements CommandInterface
{
public function __construct(
public int $movieMonitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvSeasonCommand implements CommandInterface
{
public function __construct(
public int $monitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvShowCommand implements CommandInterface
{
public function __construct(
public int $monitorId,
) {}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\AddMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<AddMonitorCommand> */
readonly class AddMonitorHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $movieMonitorRepository,
private UserRepository $userRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$user = $this->userRepository->find($command->userId);
$monitor = (new Monitor())
->setUser($user)
->setTmdbId($command->tmdbId)
->setImdbId($command->imdbId)
->setTitle($command->title)
->setMonitorType($command->monitorType)
->setSeason($command->season)
->setEpisode($command->episode)
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->movieMonitorRepository->getEntityManager()->persist($monitor);
$this->movieMonitorRepository->getEntityManager()->flush();
return new AddMonitorResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorMovieHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $movieMonitorRepository,
private GetMovieOptionsHandler $getMovieOptionsHandler,
private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Security $security,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler');
/** @var Monitor $monitor */
$monitor = $this->movieMonitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
$this->logger->info('> [MonitorMovieHandler] Searching for "' . $monitor->getTitle() . '" download options');
$results = $this->getMovieOptionsHandler->handle(
new GetMovieOptionsCommand($monitor->getTmdbId(), $monitor->getImdbId())
);
$this->logger->info('> [MonitorMovieHandler] Found ' . count($results->results) . ' download options');
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
if (null !== $result) {
$this->logger->info('> [MonitorMovieHandler] 1 result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand(
$result->url,
$monitor->getTitle(),
$result->filename,
'movies',
$monitor->getImdbId(),
$monitor->getUser()->getId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeIMmutable());
} else {
$monitor->setStatus('Active');
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->incrementSearchCount();
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvEpisodeHandler implements HandlerInterface
{
public function __construct(
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler');
$monitor = $this->monitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$this->logger->info('> [MonitorTvEpisodeHandler] Searching for "' . $monitor->getTitle() . '" season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' download options');
$results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$monitor->getTmdbId(),
$monitor->getImdbId(),
$monitor->getSeason(),
$monitor->getEpisode()
)
);
$this->logger->info('> [MonitorTvEpisodeHandler] Found ' . count($results->results) . ' download options');
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
if (null !== $result) {
$this->logger->info('> [MonitorTvEpisodeHandler] 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand(
$result->url,
$monitor->getTitle(),
$result->filename,
'tvshows',
$monitor->getImdbId(),
$monitor->getUser()->getId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable());
} else {
$this->logger->info('> [MonitorTvEpisodeHandler] 0 matching results found, monitor will run at next interval');
$monitor->setStatus('Active');
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Monitor\Action\Handler;
use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvSeasonHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Tmdb $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvSeasonHandler] Executing MonitorTvSeasonHandler');
$monitor = $this->monitorRepository->find($command->monitorId);
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode))
->rekey(fn($episode) => $episode->episode);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB
$episodesInSeason = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId())
->episodes[$monitor->getSeason()]
)->rekey(fn($episode) => $episode['episode_number']);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($episodesInSeason) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
// Dispatch Episode commands for each missing Episode
foreach ($episodesInSeason as $episode) {
$monitorCheck = $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $monitor->getSeason(),
'episode' => $episode['episode_number'],
'status' => ['New', 'Active', 'In Progress']
]);
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for season ' . $monitor->getSeason() . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (null !== $monitorCheck ? 'YES' : 'NO'));
if (!array_key_exists($episode['episode_number'], $downloadedEpisodes->toArray())
&& null === $monitorCheck
) {
$episodeMonitor = (new Monitor())
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvepisode')
->setSeason($monitor->getSeason())
->setEpisode($episode['episode_number'])
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($episodeMonitor);
$this->monitorRepository->getEntityManager()->flush();
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvSeasonHandler] Dispatching MonitorTvEpisodeCommand for season ' . $episodeMonitor->getSeason() . ' episode ' . $episodeMonitor->getEpisode() . ' for title: ' . $monitor->getTitle());
}
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Monitor\Action\Handler;
use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvShowHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Tmdb $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
$monitor = $this->monitorRepository->find($command->monitorId);
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode));
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB
$episodesInShow = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId())
->episodes
)->flat(1);
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
// Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
$episodeAlreadyDownloaded = $downloadedEpisodes->find(
fn($ep) => $ep->episode === $episode['episode_number'] && $ep->season === $episode['season_number']
);
$episodeAlreadyDownloaded = !is_null($episodeAlreadyDownloaded);
if (false === $episodeAlreadyDownloaded) {
$monitor = (new Monitor())
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvshow')
->setSeason($episode['season_number'])
->setEpisode($episode['episode_number'])
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($monitor);
$this->monitorRepository->getEntityManager()->flush();
$command = new MonitorTvEpisodeCommand($monitor->getId());
$this->bus->dispatch($command);
$this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' for title: ' . $monitor->getTitle());
}
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush();
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Monitor\Action\Input;
use App\Monitor\Action\Command\AddMonitorCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
class AddMonitorInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public int|string $userId,
#[SourceRequest('tmdbId')]
public string $tmdbId,
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('title')]
public string $title,
#[SourceRequest('monitorType')]
public string $monitorType,
#[SourceRequest('season', nullify: true)]
public ?int $season,
#[SourceRequest('episode', nullify: true)]
public ?int $episode,
) {}
public function toCommand(): CommandInterface
{
return new AddMonitorCommand(
$this->userId,
$this->title,
$this->imdbId,
$this->tmdbId,
$this->monitorType,
$this->season,
$this->episode,
);
}
}

View File

@@ -1,24 +1,24 @@
<?php
namespace App\Download\Action\Input;
namespace App\Monitor\Action\Input;
use App\Download\Action\Command\GetDownloadOptionsCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
class GetDownloadOptionsInput implements InputInterface
class MonitorMovieInput implements InputInterface
{
public function __construct(
#[SourceRoute('tmdbId')]
public string $tmdbId,
#[SourceRoute('mediaType')]
public string $mediaType,
#[SourceRoute('imdbId')]
public string $imdbId,
) {}
public function toCommand(): CommandInterface
{
return new GetDownloadOptionsCommand($this->tmdbId, $this->mediaType);
return new MonitorMovieCommand($this->tmdbId, $this->imdbId);
}
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class AddMonitorResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class MonitorMovieResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class MonitorTvEpisodeResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Monitor\Framework\Controller;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class ApiController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')]
private readonly Environment $renderer,
private readonly HubInterface $hub,
private readonly Security $security,
) {}
#[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])]
public function addMonitor(
AddMonitorInput $input,
AddMonitorHandler $handler,
HubInterface $hub,
) {
$command = $input->toCommand();
$command->userId = $this->security->getUser()->getId();
$response = $handler->handle($command);
$hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$input->title}",
])
));
return $this->json([
'status' => 200,
'message' => $response
]);
}
}

View File

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