Compare commits

...

45 Commits

Author SHA1 Message Date
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
128 changed files with 6279 additions and 578 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

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

View File

@@ -1 +1,4 @@
FROM registry.caldwell.digital/library/php:8.4-apache
COPY ./bash/vhost.conf /etc/apache2/sites-enabled/vhost.conf
RUN rm /etc/apache2/sites-enabled/000-default.conf

View File

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

View File

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

@@ -6,7 +6,12 @@ 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
};
@@ -20,11 +25,12 @@ export default class extends Controller {
async setOptions() {
if (this.options.length === 0) {
await fetch(`/torrentio/movies/${this.imdbIdValue}`)
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 +40,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

@@ -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,13 +29,19 @@ 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();
});
}
@@ -45,8 +55,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 +67,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"><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" />
<target name="composer" description="Run composer">
<exec executable="composer">
@@ -10,6 +10,10 @@
</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" />
@@ -20,9 +24,26 @@
<copy file="${project.basedir}/.env.dist" tofile="${project.basedir}/.env.local" overwrite="true">
<filterchain>
<replacetokens begintoken="%%" endtoken="%%">
<token key="app_secret" value="${APP_SECRET}" />
<token key="db_url" value="${DATABASE_URL}" />
<token key="download_dir" value="${DOWNLOAD_DIR}" />
<token key="rd_key" value="${REAL_DEBRID_KEY}" />
<token key="tmdb_api" value="${TMDB_API}" />
<token key="mercure_url" value="${MERCURE_URL}" />
<token key="mercure_public_url" value="${MERCURE_PUBLIC_URL}" />
<token key="mercure_jwt_secret" value="${MERCURE_JWT_SECRET}" />
<token key="jellyfin_url" value="${JELLYFIN_URL}" />
<token key="jellyfin_token" value="${JELLYFIN_TOKEN}" />
</replacetokens>
</filterchain>
</copy>
</target>
<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>
</project>

View File

@@ -17,6 +17,31 @@ services:
volumes:
- ./:/var/www
worker:
build: .
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
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 +61,5 @@ services:
volumes:
mysql:
mercure_data:
mercure_config:

View File

@@ -8,20 +8,31 @@
"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",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-tmdb/api": "^4.1",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/runtime": "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 +92,8 @@
}
},
"require-dev": {
"symfony/maker-bundle": "^1.62"
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
}
}

2556
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],
];

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,60 @@
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
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,12 @@
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: 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,27 @@
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: '%env(MESSENGER_TRANSPORT_DSN)%'
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
# 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,26 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\User\Framework\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
@@ -22,8 +35,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,11 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true
when@test:
framework:
profiler: { collect: false }

View File

@@ -4,5 +4,12 @@ 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']

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

View File

@@ -22,3 +22,8 @@ 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"
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'

View File

@@ -1,5 +1,36 @@
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
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,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,40 @@
<?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\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(
$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,27 @@
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
{
return $this->render('index/index.html.twig', [
'controller_name' => 'IndexController',
'active_downloads' => $this->downloadRepository->getActivePaginated(),
'recent_downloads' => $this->downloadRepository->latest(5),
'popular_movies' => $this->tmdb->popularMovies(1, 6),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]);
}
}

View File

@@ -17,7 +17,7 @@ final class TorrentioController extends AbstractController
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
) {}
#[Route('/torrentio/movies/{imdbId}', name: 'app_torrentio_movies')]
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input): Response
{
$results = $this->getMovieOptionsHandler->handle($input->toCommand());

View File

@@ -0,0 +1,20 @@
<?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 $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,56 @@
<?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 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,
) {}
public function handle(CommandInterface $command): ResultInterface
{
if (null === $command->downloadId) {
$download = $this->downloadRepository->insert(
$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,43 @@
<?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 $downloadId = null,
) {}
public function toCommand(): CommandInterface
{
return new DownloadMediaCommand(
$this->url,
$this->title,
$this->filename,
$this->mediaType,
$this->imdbId,
$this->downloadId,
);
}
}

View File

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

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,149 @@
<?php
namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository;
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;
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;
}
}

View File

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

View File

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\Download;
use App\ValueObject\DownloadRequest;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Knp\Component\Pager\Paginator;
use Knp\Component\Pager\PaginatorInterface;
/**
* @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', 'DESC')
->setParameter('statuses', ['New', 'In Progress'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
}
public function insert(
string $url,
string $title,
string $filename,
string $imdbId,
string $mediaType,
string $batchId,
string $status = 'New'
): Download {
$download = (new Download())
->setUrl($url)
->setTitle($title)
->setFilename($filename)
->setImdbId($imdbId)
->setMediaType($mediaType)
->setBatchId($batchId)
->setStatus($status);
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function insertFromDownloadRequest(DownloadRequest $request): Download
{
$download = (new Download())
->setUrl($request->downloadUrl)
->setTitle($request->seriesName)
->setFilename($request->filename)
->setImdbId($request->imdbCode)
->setMediaType($request->mediaType)
->setStatus('New');
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function updateStatus(int $id, string $status): Download
{
$download = $this->find($id);
$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 getPendingByBatchId(string $batchId): ?array
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.batchId = :batchId')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('batchId', $batchId)
->getQuery();
return $query->getResult();
}
public function latest(int $limit = 1)
{
return $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->setParameter('statuses', ['Complete'])
->setMaxResults($limit)
->orderBy('d.id', 'DESC')
->getQuery()
->getResult();
}
}

View File

@@ -4,9 +4,9 @@ namespace App\Search\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<GetMediaInfoCommand> */
class GetMediaInfoCommand implements CommandInterface
{
/** @implements CommandInterface<GetMediaInfoCommand> */
public function __construct(
public string $tmdbId,
public string $mediaType,

View File

@@ -4,12 +4,9 @@ namespace App\Search\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<SearchCommand> */
class SearchCommand implements CommandInterface
{
/**
* @param string $term
* @implements CommandInterface<SearchCommand>
*/
public function __construct(
public string $term
) {}

View File

@@ -2,12 +2,14 @@
namespace App\Search\Action\Handler;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Result\GetMediaInfoResult;
use App\Tmdb\Tmdb;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<GetMediaInfoCommand, GetMediaInfoResult> */
class GetMediaInfoHandler implements HandlerInterface
{
public function __construct(

View File

@@ -8,13 +8,13 @@ use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/*** @implements HandlerInterface<SearchResult> */
class SearchHandler implements HandlerInterface
{
public function __construct(
private Tmdb $tmdb,
) {}
/*** @implements HandlerInterface<SearchResult> */
public function handle(CommandInterface $command): ResultInterface
{
return new SearchResult(

View File

@@ -2,12 +2,13 @@
namespace App\Search\Action\Input;
use App\Download\Action\Command\GetDownloadOptionsCommand;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Search\Action\Command\GetMediaInfoCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<GetMediaInfoInput> */
class GetMediaInfoInput implements InputInterface
{
public function __construct(

View File

@@ -8,9 +8,7 @@ use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<SearchCommand>
*/
/** @implements InputInterface<SearchCommand> */
class SearchInput implements InputInterface
{
public function __construct(

View File

@@ -5,9 +5,9 @@ namespace App\Search\Action\Result;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<GetMediaInfoResult> */
class GetMediaInfoResult implements ResultInterface
{
/** @implements ResultInterface<GetMediaInfoResult> */
public function __construct(
public TmdbResult $media,
) {}

View File

@@ -4,6 +4,7 @@ namespace App\Search\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<SearchResult> */
class SearchResult implements ResultInterface
{
public function __construct(

View File

@@ -94,12 +94,35 @@ class Tmdb
$this->tvRepository = new TvRepository($this->client);
}
public function popularMovies(int $page = 1)
public function popularMovies(int $page = 1, ?int $limit = null)
{
$movies = $this->movieRepository->getPopular(['page' => $page]);
foreach ($movies as $movie) {
$this->parseResult($movie);
$movies = $movies->map(function ($movie) use ($movies) {
return $this->parseResult($movies[$movie], "movie");
});
$movies = array_values($movies->toArray());
if (null !== $limit) {
$movies = array_slice($movies, 0, $limit);
}
return $movies;
}
public function popularTvShows(int $page = 1, ?int $limit = null)
{
$movies = $this->tvRepository->getPopular(['page' => $page]);
$movies = $movies->map(function ($movie) use ($movies) {
return $this->parseResult($movies[$movie], "movie");
});
$movies = array_values($movies->toArray());
if (null !== $limit) {
$movies = array_slice($movies, 0, $limit);
}
return $movies;

View File

@@ -7,6 +7,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class GetMovieOptionsCommand implements CommandInterface
{
public function __construct(
public string $tmdbId,
public string $imdbId,
) {}
}

View File

@@ -2,6 +2,7 @@
namespace App\Torrentio\Action\Handler;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Result\GetMovieOptionsResult;
use App\Torrentio\Client\Torrentio;
use OneToMany\RichBundle\Contract\CommandInterface;
@@ -11,13 +12,15 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetMovieOptionsHandler implements HandlerInterface
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio,
) {}
public function handle(CommandInterface $command): ResultInterface
{
return new GetMovieOptionsResult(
results: $this->torrentio->search($command->imdbId, 'movies')
media: $this->tmdb->mediaDetails($command->tmdbId, 'movies'),
results: $this->torrentio->search($command->imdbId, 'movies'),
);
}
}

View File

@@ -3,12 +3,14 @@
namespace App\Torrentio\Action\Handler;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Torrentio\Client\Torrentio;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<GetTvShowOptionsCommand, GetTvShowOptionsResult> */
class GetTvShowOptionsHandler implements HandlerInterface
{
public function __construct(

View File

@@ -10,12 +10,15 @@ use OneToMany\RichBundle\Contract\InputInterface;
class GetMovieOptionsInput implements InputInterface
{
public function __construct(
#[SourceRoute('tmdbId')]
public string $tmdbId,
#[SourceRoute('imdbId')]
public string $imdbId,
) {}
public function toCommand(): CommandInterface
{
return new GetMovieOptionsCommand($this->imdbId);
return new GetMovieOptionsCommand($this->tmdbId, $this->imdbId);
}
}

View File

@@ -2,11 +2,13 @@
namespace App\Torrentio\Action\Result;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface;
class GetMovieOptionsResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public array $results
) {}
}

View File

@@ -3,6 +3,7 @@
namespace App\Torrentio\Result;
use App\Util\CountryCodes;
use App\Util\CountryLanguages;
use Nihilarr\PTN;
class ResultFactory
@@ -15,7 +16,8 @@ class ResultFactory
$ptn = (object) (new PTN())->parse($title);
return new TorrentioResult(
self::trimTitle($title),
$url,
urldecode($url),
self::setFilename($url),
self::setSize($title),
self::setSeeders($title),
self::setProvider($title),
@@ -33,6 +35,12 @@ class ResultFactory
);
}
public static function setFilename(string $url)
{
$file = explode("/", urldecode($url));
return end($file);
}
public static function setSize(string $title): string
{
$sizeMatch = [];
@@ -82,13 +90,14 @@ class ResultFactory
});
$languages = array_map(function ($flag) {
return CountryCodes::convertFromAbbr(strtoupper(substr($flag['short_name'], strlen('flag-'))));
return CountryLanguages::fromCountryCode(strtoupper(substr($flag['short_name'], strlen('flag-'))));
},
$flags);
if (count($languages) > 0) {
return array_values($languages);
} else {
return ["English"];
return ["English (US)"];
}
}

View File

@@ -7,6 +7,7 @@ class TorrentioResult
public function __construct(
public ?string $title = "-",
public ?string $url = "-",
public ?string $filename = "-",
public ?string $size = "-",
public ?string $seeders = "-",
public ?string $provider = "-",

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Twig\Components;
use App\Download\Framework\Repository\DownloadRepository;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class ActiveDownloadList
{
use DefaultActionTrait;
public function __construct(
private DownloadRepository $downloadRepository,
) {}
#[LiveAction]
public function getDownloads()
{
return $this->downloadRepository->getActivePaginated();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class Alert
{
use DefaultActionTrait;
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Twig\Components;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class Filter
{
use DefaultActionTrait;
public array $preferences = [];
public array $userPreferences = [];
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly Security $security,
) {
$this->preferences = Map::from($this->preferencesRepository->findEnabled())
->rekey(fn($element) => $element->getId())
->map(fn($element) => $element->getPreferenceOptions()->toArray())
->toArray();
$this->userPreferences = Map::from($this->security->getUser()->getUserPreferenceValues())
->toArray();
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\User\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesCommand implements CommandInterface
{
public function __construct(
public string $resolution,
public string $codec,
public string $language,
public string $provider,
) {}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Result\SaveUserMediaPreferencesResult;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Symfony\Bundle\SecurityBundle\Security;
/** @implements HandlerInterface<SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly Security $token,
) {}
public function handle(C $command): R
{
/** @var User $user */
$user = $this->token->getUser();
foreach ($command as $preference => $value) {
if ($user->hasUserPreference($preference)) {
$user->updateUserPreference($preference, $value);
$this->entityManager->flush();
continue;
}
$preference = $this->preferenceRepository->find($preference);
$user->addUserPreference(
(new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue($value)
);
}
$this->entityManager->flush();
return new SaveUserMediaPreferencesResult($user->getUserPreferences());
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\User\Action\Input;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<SaveUserMediaPreferencesInput, SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public mixed $userId,
#[SourceRequest('resolution')]
public string $resolution,
#[SourceRequest('codec')]
public string $codec,
#[SourceRequest('language')]
public string $language,
#[SourceRequest('provider')]
public string $provider,
) {}
public function toCommand(): C
{
return new SaveUserMediaPreferencesCommand(
$this->resolution,
$this->codec,
$this->language,
$this->provider,
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Action\Result;
use Doctrine\Common\Collections\Collection;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface */
class SaveUserMediaPreferencesResult implements ResultInterface
{
public function __construct(
public Collection $userPreferences,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\User\Framework\Controller\Api;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class PreferencesApiController extends AbstractController
{
#[Route('/api/preferences/media', name: 'api_preferences_media')]
public function getMediaPreferences(
PreferencesRepository $preferencesRepository,
): Response {
return $this->json(
Map::from($preferencesRepository->findEnabled())
->rekey(fn($element) => $element->getId())
->toArray()
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\User\Framework\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class UserApiController extends AbstractController
{
#[Route('/api/user/preferences/values', name: 'api_user_preferences_values')]
public function getUserPreferenceValues(
Security $security
): Response {
return $this->json(
$security->getUser()->getUserPreferenceValues()
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\User\Framework\Controller\Web;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class LoginController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('user/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\User\Framework\Controller\Web;
use Aimeos\Map;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use App\Util\CountryCodes;
use App\Util\CountryLanguages;
use App\Util\ProviderList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class PreferencesController extends AbstractController
{
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
private readonly Security $security,
) {}
#[Route('/media/preferences', 'app_media_preferences', methods: ['GET'])]
public function mediaPreferences(): Response
{
$enabledPreferences = $this->preferencesRepository->findEnabled();
if ($this->security->getUser()->getUserPreferences()->count() !== count($enabledPreferences)) {
$this->setUserPreferences($this->security->getUser(), $enabledPreferences);
}
$userPreferences = $this->security->getUser()->getUserPreferences()->toArray();
$userPreferences = Map::from($userPreferences)
->rekey(fn($preference) => $preference->getPreference()->getId());
$languages = CountryLanguages::$languages;
sort($languages);
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'userPreferences' => $userPreferences->toArray(),
]
);
}
#[Route('/media/preferences', 'app_save_media_preferences', methods: ['POST'])]
public function saveMediaPreferences(
SaveUserMediaPreferencesInput $input,
): Response
{
$userPreferences = $this->saveUserMediaPreferencesHandler->handle($input->toCommand())->userPreferences;
$userPreferences = Map::from($userPreferences)->rekey(fn($preference) => $preference->getPreference()->getId());
$languages = CountryLanguages::$languages;
sort($languages);
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'userPreferences' => $userPreferences->toArray(),
]
);
}
private function setUserPreferences(User $user, array $preferences): void
{
foreach ($preferences as $preference) {
if (false === $user->hasUserPreference($preference->getId())) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
$this->preferencesRepository->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\User\Framework\Controller\Web;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Form\RegistrationFormType;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $userPasswordHasher,
EntityManagerInterface $entityManager,
PreferencesRepository $preferencesRepository,
): Response {
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
// encode the plain password
$user->setPassword($userPasswordHasher->hashPassword($user, $plainPassword));
$entityManager->persist($user);
$entityManager->flush();
$this->setUserPreferences($user, $preferencesRepository->findEnabled());
$preferencesRepository->getEntityManager()->flush();
return $this->redirectToRoute('app_index');
}
return $this->render('user/register.html.twig', [
'registrationForm' => $form,
]);
}
private function setUserPreferences(User $user, array $preferences): void
{
foreach ($preferences as $preference) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PreferencesRepository::class)]
class Preference
{
#[ORM\Id]
#[ORM\Column]
private ?string $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $description = null;
#[ORM\Column]
private ?bool $enabled = null;
/**
* @var Collection<int, PreferenceOption>
*/
#[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference', fetch: 'EAGER')]
private Collection $preferenceOptions;
public function __construct()
{
$this->preferenceOptions = new ArrayCollection();
}
public function getId(): ?string
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
/**
* @return Collection<int, PreferenceOption>
*/
public function getPreferenceOptions(): Collection
{
return $this->preferenceOptions;
}
public function addPreferenceOption(PreferenceOption $preferenceOption): static
{
if (!$this->preferenceOptions->contains($preferenceOption)) {
$this->preferenceOptions->add($preferenceOption);
$preferenceOption->setPreference($this);
}
return $this;
}
public function removePreferenceOption(PreferenceOption $preferenceOption): static
{
if ($this->preferenceOptions->removeElement($preferenceOption)) {
// set the owning side to null (unless already changed)
if ($preferenceOption->getPreference() === $this) {
$preferenceOption->setPreference(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\PreferenceOptionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore;
#[ORM\Entity(repositoryClass: PreferenceOptionRepository::class)]
class PreferenceOption
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $value = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'preferenceOptions')]
private ?Preference $preference = null;
#[ORM\Column]
private ?bool $enabled = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
public function getValue(): ?string
{
return $this->value;
}
public function setValue(?string $value): static
{
$this->value = $value;
return $this;
}
public function getPreference(): ?Preference
{
return $this->preference;
}
public function setPreference(?Preference $preference): static
{
$this->preference = $preference;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace App\User\Framework\Entity;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $name;
#[ORM\Column(type: 'json')]
private array $roles = [];
#[ORM\Column(type: 'string')]
private string $password;
/**
* @var Collection<int, UserPreference>
*/
#[ORM\OneToMany(targetEntity: UserPreference::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private Collection $userPreferences;
public function __construct()
{
$this->userPreferences = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): self
{
$this->name = $name;
return $this;
}
/**
* The public representation of the user (e.g. a username, an email address, etc.)
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
/**
* @return Collection<int, UserPreference>
*/
public function getUserPreferences(): Collection
{
return $this->userPreferences;
}
public function getUserPreference(string $preferenceName)
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getName() === $preferenceName) {
return $userPreference->getPreference();
}
}
}
public function hasUserPreference(string $preferenceName): bool
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getId() === $preferenceName) {
return true;
}
}
return false;
}
public function updateUserPreference(string $preferenceName, mixed $preferenceValue): static
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getId() === $preferenceName) {
$userPreference->setPreferenceValue($preferenceValue);
}
}
return $this;
}
public function addUserPreference(UserPreference $userPreference): static
{
if (!$this->userPreferences->contains($userPreference)) {
$this->userPreferences->add($userPreference);
$userPreference->setUser($this);
}
return $this;
}
public function removeUserPreference(UserPreference $userPreference): static
{
if ($this->userPreferences->removeElement($userPreference)) {
// set the owning side to null (unless already changed)
if ($userPreference->getUser() === $this) {
$userPreference->setUser(null);
}
}
return $this;
}
public function getUserPreferenceValues()
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->map(function (UserPreference $userPreference) {
if (in_array($userPreference->getPreference()->getId(), ['language', 'provider'])) {
return $userPreference->getPreferenceValue();
}
foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) {
// dd((int) $userPreference->getPreferenceValue(), $preferenceOption->getId(), $preferenceOption->getValue());
if ($preferenceOption->getId() === (int) $userPreference->getPreferenceValue()) {
return $preferenceOption->getValue();
}
}
return null;
})
->toArray();
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\UserPreferenceRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserPreferenceRepository::class)]
class UserPreference
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'userPreferences')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\ManyToOne(fetch: 'EAGER')]
#[ORM\JoinColumn(nullable: false)]
private ?Preference $preference = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $preference_value = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getPreference(): ?Preference
{
return $this->preference;
}
public function setPreference(?Preference $preference): static
{
$this->preference = $preference;
return $this;
}
public function getPreferenceValue(): ?string
{
return $this->preference_value;
}
public function setPreferenceValue(?string $preference_value): static
{
$this->preference_value = $preference_value;
return $this;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\User\Framework\Form;
use App\User\Framework\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class RegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email')
->add('name')
->add('plainPassword', PasswordType::class, [
// instead of being set onto the object directly,
// this is read and encoded in the controller
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

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