Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7d7025114 | |||
| 41114446d0 | |||
| 592e02484e | |||
| bd9fde94d1 | |||
| d0b2852de5 | |||
| 2fae99e24b | |||
| b74b563c56 | |||
| 04993ebb27 | |||
| db521ad9a9 | |||
| 6a7474173e | |||
| 9f38429c2a | |||
| 9fd6745125 | |||
| 60376ca0a2 | |||
| 6f1f1032f6 | |||
| c6e98eff4c | |||
| cff0d5234e | |||
| d2e7650b6c | |||
| bb6dcdef30 | |||
| b5526dc2dd | |||
| 3959696b66 | |||
| 7353806915 | |||
| 42e232bef3 | |||
| 45b484d44c | |||
| dd52a903f6 | |||
| 5729949774 | |||
| e055ed0c15 | |||
| 46d90e800c | |||
| 9fb513bfbd | |||
| 2384bb2414 | |||
| 7c8fa0c439 | |||
| 97aa8d8982 | |||
| a88720fe7e | |||
| 8a12303470 | |||
| 13b9047841 | |||
| 8c0ec98c20 | |||
| 2c9138290a | |||
| c1a6cddb8f | |||
| 64d3fbbddb | |||
| 32389cb27a | |||
| 5e48fdb978 | |||
| 5f54e48b3f |
13
.env
13
.env
@@ -38,3 +38,16 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
|
||||
REDIS_HOST=redis://redis
|
||||
|
||||
###> symfony/mailer ###
|
||||
MAILER_DSN=null://null
|
||||
###< symfony/mailer ###
|
||||
|
||||
AUTH_METHOD=form_login
|
||||
|
||||
###> drenso/symfony-oidc-bundle ###
|
||||
OIDC_WELL_KNOWN_URL="https://oidc/.well-known"
|
||||
OIDC_CLIENT_ID="Enter your OIDC client id"
|
||||
OIDC_CLIENT_SECRET="Enter your OIDC client secret"
|
||||
OIDC_BYPASS_FORM_LOGIN=false
|
||||
###< drenso/symfony-oidc-bundle ###
|
||||
|
||||
46
assets/controllers/action_button_controller.js
Normal file
46
assets/controllers/action_button_controller.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
|
||||
*/
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
|
||||
initialize() {
|
||||
// Called once when the controller is first instantiated (per element)
|
||||
|
||||
// Here you can initialize variables, create scoped callables for event
|
||||
// listeners, instantiate external libraries, etc.
|
||||
// this._fooBar = this.fooBar.bind(this)
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Called every time the controller is connected to the DOM
|
||||
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
|
||||
|
||||
// Here you can add event listeners on the element or target elements,
|
||||
// add or remove classes, attributes, dispatch custom events, etc.
|
||||
// this.fooTarget.addEventListener('click', this._fooBar)
|
||||
}
|
||||
|
||||
// Add custom controller actions here
|
||||
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
|
||||
|
||||
disconnect() {
|
||||
// Called anytime its element is disconnected from the DOM
|
||||
// (on page change, when it's removed from or moved in the DOM, etc.)
|
||||
|
||||
// Here you should remove all event listeners added in "connect()"
|
||||
// this.fooTarget.removeEventListener('click', this._fooBar)
|
||||
}
|
||||
|
||||
default() {
|
||||
console.log('Looks like you need to add an action to your action button...')
|
||||
}
|
||||
|
||||
monitorDispatch() {
|
||||
fetch('/api/monitor/dispatch')
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Controller } from '@hotwired/stimulus';
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['button', 'options']
|
||||
static outlets = ['result-filter']
|
||||
static outlets = ['result-filter', 'dialog']
|
||||
static values = {
|
||||
tmdbId: String,
|
||||
imdbId: String,
|
||||
@@ -54,6 +54,9 @@ export default class extends Controller {
|
||||
title: this.titleValue,
|
||||
monitorType: 'tvshows',
|
||||
});
|
||||
if (this.hasDialogOutlet) {
|
||||
this.dialogOutlet.close();
|
||||
}
|
||||
}
|
||||
|
||||
async monitorSeason() {
|
||||
|
||||
@@ -51,6 +51,7 @@ export default class extends Controller {
|
||||
let selectedCount = 0;
|
||||
|
||||
this.options.forEach((option) => {
|
||||
const optionHeader = document.querySelector(`[data-option-id="${option.dataset['localId']}"]`)
|
||||
const props = {
|
||||
"resolution": option.querySelector('#resolution').textContent.trim(),
|
||||
"codec": option.querySelector('#codec').textContent.trim(),
|
||||
@@ -62,6 +63,8 @@ export default class extends Controller {
|
||||
let include = true;
|
||||
option.classList.add('r-tablerow');
|
||||
option.classList.remove('hidden');
|
||||
optionHeader.classList.add('r-tablerow');
|
||||
optionHeader.classList.remove('hidden');
|
||||
option.querySelector('input[type="checkbox"]').checked = false;
|
||||
|
||||
for (let [key, value] of Object.entries(activeFilter)) {
|
||||
@@ -88,6 +91,8 @@ export default class extends Controller {
|
||||
if (false === include) {
|
||||
option.classList.remove('r-tablerow');
|
||||
option.classList.add('hidden');
|
||||
optionHeader.classList.remove('r-tablerow');
|
||||
optionHeader.classList.add('hidden');
|
||||
} else if (true === firstIncluded) {
|
||||
count = 1;
|
||||
selectedCount = selectedCount + 1;
|
||||
|
||||
@@ -25,6 +25,7 @@ export default class extends Controller {
|
||||
static outlets = ['movie-results', 'tv-results', 'tv-episode-list']
|
||||
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
|
||||
static values = {
|
||||
'imdbId': String,
|
||||
'media-type': String,
|
||||
'episodes': Array,
|
||||
'reverseMappedQualities': Object,
|
||||
@@ -126,6 +127,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
async filter() {
|
||||
const downloadSeasonSpan = document.querySelector("#downloadSeasonModal");
|
||||
const currentSeason = this.activeFilter['season'];
|
||||
|
||||
let results = [];
|
||||
@@ -144,6 +146,7 @@ export default class extends Controller {
|
||||
} else if ("tvshows" === this.mediaTypeValue) {
|
||||
results = this.tvResultsOutlets;
|
||||
this.activeFilter.season = this.seasonTarget.value;
|
||||
downloadSeasonSpan.innerText = this.activeFilter.season;
|
||||
await results.forEach((list) => list.filter(this.activeFilter, currentSeason, this.seasonTarget.value));
|
||||
}
|
||||
}
|
||||
@@ -156,6 +159,14 @@ export default class extends Controller {
|
||||
this.selectAllTarget.checked = false;
|
||||
}
|
||||
|
||||
downloadSeason() {
|
||||
fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
selectAllEpisodes() {
|
||||
this.tvResultsOutlets.forEach((episode) => {
|
||||
if (episode.isActive()) {
|
||||
|
||||
@@ -128,6 +128,7 @@ export default class extends Controller {
|
||||
let selectedCount = 0;
|
||||
|
||||
this.options.forEach((option) => {
|
||||
const optionHeader = document.querySelector(`[data-option-id="${option.dataset['localId']}"]`)
|
||||
const props = {
|
||||
"resolution": option.querySelector('#resolution').textContent.trim(),
|
||||
"codec": option.querySelector('#codec').textContent.trim(),
|
||||
@@ -138,6 +139,8 @@ export default class extends Controller {
|
||||
let include = true;
|
||||
option.classList.add('r-tablerow');
|
||||
option.classList.remove('hidden');
|
||||
optionHeader.classList.add('r-tablerow');
|
||||
optionHeader.classList.remove('hidden');
|
||||
option.querySelector('input[type="checkbox"]').checked = false;
|
||||
|
||||
for (let [key, value] of Object.entries(activeFilter)) {
|
||||
@@ -164,6 +167,8 @@ export default class extends Controller {
|
||||
if (false === include) {
|
||||
option.classList.remove('r-tablerow');
|
||||
option.classList.add('hidden');
|
||||
optionHeader.classList.remove('r-tablerow');
|
||||
optionHeader.classList.add('hidden');
|
||||
} else if (true === firstIncluded) {
|
||||
count = 1;
|
||||
selectedCount = selectedCount + 1;
|
||||
|
||||
@@ -64,6 +64,14 @@ dialog[data-dialog-target="dialog"][closing] {
|
||||
animation: fade-out 200ms forwards;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
@apply bg-gray-50 text-gray-50 p-1 bg-transparent border-b-2 border-orange-400
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
@apply bg-green-600/40 px-1.5 py-1 w-full rounded-md text-gray-50 backdrop-filter backdrop-blur-sm border-2 border-green-500 hover:bg-green-700/40
|
||||
}
|
||||
|
||||
.r-tablecell {
|
||||
display: none;
|
||||
}
|
||||
@@ -74,7 +82,7 @@ dialog[data-dialog-target="dialog"][closing] {
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.r-tablecell {
|
||||
display: inline-table;
|
||||
display: table-cell;
|
||||
}
|
||||
|
||||
.r-tablerow {
|
||||
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
tty: true
|
||||
environment:
|
||||
TZ: America/Chicago
|
||||
command: php /app/bin/console messenger:consume media_cache -vv --time-limit=3600
|
||||
command: php /app/bin/console messenger:consume async -vv --time-limit=3600
|
||||
|
||||
|
||||
scheduler:
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"dragonmantank/cron-expression": "^3.4",
|
||||
"drenso/symfony-oidc-bundle": "^4.2",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"league/pipeline": "^1.1",
|
||||
"nesbot/carbon": "^3.9",
|
||||
@@ -36,7 +37,9 @@
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "7.3.*",
|
||||
"symfony/framework-bundle": "7.3.*",
|
||||
"symfony/http-client": "7.3.*",
|
||||
"symfony/ldap": "7.3.*",
|
||||
"symfony/mailer": "7.3.*",
|
||||
"symfony/mercure-bundle": "^0.3.9",
|
||||
"symfony/messenger": "7.3.*",
|
||||
"symfony/runtime": "7.3.*",
|
||||
@@ -50,9 +53,11 @@
|
||||
"symfony/ux-turbo": "^2.24",
|
||||
"symfony/ux-twig-component": "^2.24",
|
||||
"symfony/yaml": "7.3.*",
|
||||
"symfonycasts/reset-password-bundle": "^1.23",
|
||||
"symfonycasts/tailwind-bundle": "^0.10.0",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/twig": "^2.12|^3.0"
|
||||
"twig/twig": "^2.12|^3.0",
|
||||
"web-token/jwt-library": "^4.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
|
||||
951
composer.lock
generated
951
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -20,4 +20,6 @@ return [
|
||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
|
||||
Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true],
|
||||
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
|
||||
Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -18,6 +18,12 @@ doctrine:
|
||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
# App:
|
||||
# type: attribute
|
||||
# is_bundle: false
|
||||
# dir: '%kernel.project_dir%/src/Entity'
|
||||
# prefix: 'App\Entity'
|
||||
# alias: App
|
||||
Download:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
|
||||
19
config/packages/drenso_oidc.yaml
Normal file
19
config/packages/drenso_oidc.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
drenso_oidc:
|
||||
#default_client: default # The default client, will be aliased to OidcClientInterface
|
||||
clients:
|
||||
default: # The client name, each client will be aliased to its name (for example, $defaultOidcClient)
|
||||
# Required OIDC client configuration
|
||||
well_known_url: '%env(OIDC_WELL_KNOWN_URL)%'
|
||||
client_id: '%env(OIDC_CLIENT_ID)%'
|
||||
client_secret: '%env(OIDC_CLIENT_SECRET)%'
|
||||
redirect_route: '/login/oidc/auth'
|
||||
|
||||
# Extra configuration options
|
||||
#redirect_route: '/login_check'
|
||||
#custom_client_headers: []
|
||||
|
||||
# Add any extra client
|
||||
#link: # Will be accessible using $linkOidcClient
|
||||
#well_known_url: '%env(LINK_WELL_KNOWN_URL)%'
|
||||
#client_id: '%env(LINK_CLIENT_ID)%'
|
||||
#client_secret: '%env(LINK_CLIENT_SECRET)%'
|
||||
7
config/packages/mailer.yaml
Normal file
7
config/packages/mailer.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
framework:
|
||||
mailer:
|
||||
dsn: 'smtp://%env(SMTP_USER)%:%env(SMTP_PASS)%@%env(SMTP_HOST)%:%env(SMTP_PORT)%'
|
||||
envelope:
|
||||
sender: '%env(SMTP_FROM)%'
|
||||
headers:
|
||||
From: '%env(SMTP_FROM_NAME)% <%env(SMTP_FROM)%>'
|
||||
@@ -37,6 +37,7 @@ framework:
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
'App\Download\Action\Command\DownloadMediaCommand': async
|
||||
'App\Download\Action\Command\DownloadSeasonCommand': async
|
||||
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async
|
||||
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
|
||||
'App\Monitor\Action\Command\MonitorTvShowCommand': async
|
||||
|
||||
2
config/packages/reset_password.yaml
Normal file
2
config/packages/reset_password.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
symfonycasts_reset_password:
|
||||
request_password_repository: App\User\Framework\Repository\ResetPasswordRequestRepository
|
||||
@@ -10,6 +10,9 @@ security:
|
||||
class: App\User\Framework\Entity\User
|
||||
property: email
|
||||
|
||||
app_oidc:
|
||||
id: App\User\Framework\Security\OidcUserProvider
|
||||
|
||||
app_ldap:
|
||||
id: App\User\Framework\Security\LdapUserProvider
|
||||
|
||||
@@ -18,14 +21,18 @@ security:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_local
|
||||
logout:
|
||||
path: /logout
|
||||
provider: app_oidc
|
||||
form_login:
|
||||
login_path: app_login
|
||||
check_path: app_login
|
||||
enable_csrf: true
|
||||
logout:
|
||||
path: app_logout
|
||||
oidc:
|
||||
login_path: '/login/oidc'
|
||||
check_path: '/login/oidc/auth'
|
||||
enable_end_session_listener: true
|
||||
entry_point: form_login
|
||||
|
||||
# activate different ways to authenticate
|
||||
# https://symfony.com/doc/current/security.html#the-firewall
|
||||
@@ -36,6 +43,7 @@ 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: ^/reset-password, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
security:
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||
providers:
|
||||
users_in_memory: { memory: null }
|
||||
app_local:
|
||||
entity:
|
||||
class: App\User\Framework\Entity\User
|
||||
property: email
|
||||
|
||||
app_ldap:
|
||||
id: App\User\Framework\Security\LdapUserProvider
|
||||
|
||||
firewalls:
|
||||
dev:
|
||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||
security: false
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_ldap
|
||||
entry_point: form_login_ldap
|
||||
form_login_ldap:
|
||||
login_path: app_login
|
||||
check_path: app_login
|
||||
enable_csrf: true
|
||||
service: Symfony\Component\Ldap\Ldap
|
||||
dn_string: '%env(LDAP_DN_STRING)%'
|
||||
form_login:
|
||||
login_path: app_login
|
||||
check_path: app_login
|
||||
enable_csrf: true
|
||||
logout:
|
||||
path: app_logout
|
||||
|
||||
# activate different ways to authenticate
|
||||
# https://symfony.com/doc/current/security.html#the-firewall
|
||||
|
||||
# https://symfony.com/doc/current/security/impersonating_user.html
|
||||
# switch_user: true
|
||||
|
||||
# Easy way to control access for large sections of your site
|
||||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
- { path: ^/register, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
|
||||
|
||||
when@test:
|
||||
security:
|
||||
password_hashers:
|
||||
# By default, password hashers are resource intensive and take time. This is
|
||||
# important to generate secure password hashes. In tests however, secure hashes
|
||||
# are not important, waste resources and increase test times. The following
|
||||
# reduces the work factor to the lowest possible values.
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
||||
algorithm: auto
|
||||
cost: 4 # Lowest possible value for bcrypt
|
||||
time_cost: 3 # Lowest possible value for argon
|
||||
memory_cost: 10 # Lowest possible value for argon
|
||||
@@ -6,6 +6,7 @@
|
||||
parameters:
|
||||
# App
|
||||
app.url: '%env(APP_URL)%'
|
||||
app.version: '%env(default:app.default.version:APP_VERSION)%'
|
||||
|
||||
# Debrid Services
|
||||
app.debrid.real_debrid.key: '%env(REAL_DEBRID_KEY)%'
|
||||
@@ -34,7 +35,14 @@ parameters:
|
||||
app.default.version: '0.dev'
|
||||
app.default.timezone: 'America/Chicago'
|
||||
|
||||
app.version: '%env(default:app.default.version:APP_VERSION)%'
|
||||
# Auth
|
||||
auth.default.method: 'form_login'
|
||||
auth.method: '%env(default:auth.default.method:AUTH_METHOD)%'
|
||||
|
||||
auth.oidc.well_known_url: '%env(OIDC_WELL_KNOWN_URL)%'
|
||||
auth.oidc.client_id: '%env(OIDC_CLIENT_ID)%'
|
||||
auth.oidc.client_secret: '%env(OIDC_CLIENT_SECRET)%'
|
||||
auth.oidc.bypass_form_login: '%env(bool:OIDC_BYPASS_FORM_LOGIN)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
# or pass your certificates into the 'app' container.
|
||||
# Please omit any trailing slashes. The APP_URL is
|
||||
# used to generate the Mercure URL behind the scenes.
|
||||
APP_URL="https://torsearch.idocode.io"
|
||||
APP_URL="https://dev.caldwell.digital"
|
||||
APP_SECRET="70169beadfbc8101c393cbfbba27a313"
|
||||
APP_ENV=prod
|
||||
|
||||
# Mercure is a Caddy module built into the webserver
|
||||
# that facilitates the usage of websockets to transmit
|
||||
# real time data (download progress, etc.)
|
||||
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
||||
|
||||
# Use the DATABASE_URL below to use the MariaDB container
|
||||
# provided in the example.compose.yml file, or remove this
|
||||
# line and fill in the details of your own MySQL/MariaDB server
|
||||
@@ -19,39 +24,48 @@ DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-Ma
|
||||
# This key is never saved anywhere
|
||||
# else and is passed to Torrentio
|
||||
# to retrieve download options
|
||||
#REAL_DEBRID_KEY=""
|
||||
REAL_DEBRID_KEY=""
|
||||
|
||||
# Enter you TMDB API key
|
||||
# Enter your TMDB API key
|
||||
# This is used to provide rich search results
|
||||
# when searching for media and rendering the
|
||||
# Popular Movies and TV Shows section.
|
||||
#TMDB_API=
|
||||
|
||||
REAL_DEBRID_KEY=""
|
||||
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw
|
||||
|
||||
|
||||
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
|
||||
TMDB_API=""
|
||||
|
||||
# Use your own Redis instance or use the
|
||||
# below value to use the container included
|
||||
# in the example compose.yml file.
|
||||
REDIS_HOST="redis://redis"
|
||||
|
||||
### Auth ###
|
||||
# Change to "oidc" to and provide the required
|
||||
# environment variables below to use OIDC auth.
|
||||
AUTH_METHOD=form_login
|
||||
|
||||
# OIDC
|
||||
OIDC_WELL_KNOWN_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
# Allows you to skip the login page and directly
|
||||
# rely on your IdP for auth.
|
||||
OIDC_BYPASS_FORM_LOGIN=
|
||||
|
||||
|
||||
# LDAP Config: To use LDAP, enter the below fields
|
||||
# and run 'php bin/console config:set auth.method ldap'
|
||||
LDAP_HOST=
|
||||
LDAP_PORT=
|
||||
LDAP_ENCRYPTION=
|
||||
LDAP_BASE_DN=
|
||||
LDAP_BIND_USER=
|
||||
LDAP_BIND_PASS=
|
||||
LDAP_DN_STRING=
|
||||
LDAP_UID_KEY="uid"
|
||||
# (LDAP is still in progress and not ready for use)
|
||||
#LDAP_HOST=
|
||||
#LDAP_PORT=
|
||||
#LDAP_ENCRYPTION=
|
||||
#LDAP_BASE_DN=
|
||||
#LDAP_BIND_USER=
|
||||
#LDAP_BIND_PASS=
|
||||
#LDAP_DN_STRING=
|
||||
#LDAP_UID_KEY="uid"
|
||||
# LDAP group that identifies an Admin
|
||||
# Users with this LDAP group will automatically
|
||||
# get the admin role in this system.
|
||||
LDAP_ADMIN_ROLE_DN=""
|
||||
LDAP_EMAIL_ATTRIBUTE=mail
|
||||
LDAP_USERNAME_ATTRIBUTE=uid
|
||||
LDAP_NAME_ATTRIBUTE=displayname
|
||||
#LDAP_ADMIN_ROLE_DN=""
|
||||
#LDAP_EMAIL_ATTRIBUTE=mail
|
||||
#LDAP_USERNAME_ATTRIBUTE=uid
|
||||
#LDAP_NAME_ATTRIBUTE=displayname
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
services:
|
||||
# The "entrypoint" into the application. This reverse proxy
|
||||
# proxies traffic back to their respective services. If not
|
||||
# running behind a reverse proxy inject your SSL certificates
|
||||
# into this container.
|
||||
# This container runs the actual web app in a php:8.4-fpm
|
||||
# base container.
|
||||
app:
|
||||
image: code.caldwell.digital/home/torsearch-app:latest
|
||||
ports:
|
||||
@@ -12,8 +6,8 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /mnt/media/downloads/movies:/var/download/movies
|
||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
||||
- ./downloads/movies:/var/download/movies
|
||||
- ./downloads/tvshows:/var/download/tvshows
|
||||
environment:
|
||||
TZ: America/Chicago
|
||||
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
|
||||
@@ -32,11 +26,11 @@ services:
|
||||
worker:
|
||||
image: code.caldwell.digital/home/torsearch-worker:latest
|
||||
volumes:
|
||||
- /mnt/media/downloads/movies:/var/download/movies
|
||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
||||
- ./downloads/movies:/var/download/movies
|
||||
- ./downloads/tvshows:/var/download/tvshows
|
||||
environment:
|
||||
TZ: America/Chicago
|
||||
command: -vvv
|
||||
command: -vv --time-limit=3600 --limit=10
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
@@ -52,10 +46,11 @@ services:
|
||||
scheduler:
|
||||
image: code.caldwell.digital/home/torsearch-scheduler:latest
|
||||
volumes:
|
||||
- /mnt/media/downloads/movies:/var/download/movies
|
||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
||||
- ./downloads/movies:/var/download/movies
|
||||
- ./downloads/tvshows:/var/download/tvshows
|
||||
env_file:
|
||||
- .env
|
||||
command: -vv
|
||||
environment:
|
||||
TZ: America/Chicago
|
||||
restart: always
|
||||
|
||||
35
migrations/Version20250708033046.php
Normal file
35
migrations/Version20250708033046.php
Normal 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 Version20250708033046 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 monitor ADD only_future TINYINT(1) NOT NULL DEFAULT 1
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE monitor DROP only_future
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
35
migrations/Version20250709161037.php
Normal file
35
migrations/Version20250709161037.php
Normal 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 Version20250709161037 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE download CHANGE batch_id episode_id 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 download CHANGE episode_id batch_id VARCHAR(255) DEFAULT NULL
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
47
migrations/Version20250709200956.php
Normal file
47
migrations/Version20250709200956.php
Normal 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 Version20250709200956 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 reset_password_request (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_7CE748AA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE monitor CHANGE only_future only_future TINYINT(1) NOT 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 reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE reset_password_request
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE monitor CHANGE only_future only_future TINYINT(1) DEFAULT 1 NOT NULL
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,21 @@ final class ConfigResolver
|
||||
|
||||
#[Autowire(param: 'media.tvshows.path')]
|
||||
private readonly ?string $tvshowsPath = null,
|
||||
|
||||
#[Autowire(param: 'auth.method')]
|
||||
private readonly ?string $authMethod = null,
|
||||
|
||||
#[Autowire(param: 'auth.oidc.well_known_url')]
|
||||
private readonly ?string $authOidcWellKnownUrl = null,
|
||||
|
||||
#[Autowire(param: 'auth.oidc.client_id')]
|
||||
private readonly ?string $authOidcClientId = null,
|
||||
|
||||
#[Autowire(param: 'auth.oidc.client_secret')]
|
||||
private readonly ?string $authOidcClientSecret = null,
|
||||
|
||||
#[Autowire(param: 'auth.oidc.bypass_form_login')]
|
||||
private ?bool $authOidcBypassFormLogin = null,
|
||||
) {}
|
||||
|
||||
public function validate(): bool
|
||||
@@ -46,4 +61,35 @@ final class ConfigResolver
|
||||
{
|
||||
return $this->messages;
|
||||
}
|
||||
|
||||
public function authIs(string $method): bool
|
||||
{
|
||||
if (strtolower($method) === strtolower($this->getAuthMethod())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getAuthMethod(): string
|
||||
{
|
||||
return strtolower($this->authMethod);
|
||||
}
|
||||
|
||||
public function bypassFormLogin(): bool
|
||||
{
|
||||
return $this->authOidcBypassFormLogin;
|
||||
}
|
||||
|
||||
public function getAuthConfig(): array
|
||||
{
|
||||
return [
|
||||
'method' => $this->authMethod,
|
||||
'oidc' => [
|
||||
'well_known_url' => $this->authOidcWellKnownUrl,
|
||||
'client_id' => $this->authOidcClientId,
|
||||
'client_secret' => $this->authOidcClientSecret,
|
||||
'bypass_form_login' => $this->authOidcBypassFormLogin,
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'config:set',
|
||||
description: 'Add a short description for your command',
|
||||
description: '[deprecated] This command currently serves no use. It may be re-purposed or removed in the future.',
|
||||
)]
|
||||
class ConfigSetCommand extends Command
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Base\Framework\Command;
|
||||
|
||||
use App\User\Framework\Entity\Preference;
|
||||
use App\User\Framework\Entity\UserPreference;
|
||||
use App\User\Framework\Repository\PreferenceOptionRepository;
|
||||
use App\User\Framework\Repository\PreferencesRepository;
|
||||
@@ -14,7 +15,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'db:seed',
|
||||
description: 'Seed the database with required data.',
|
||||
description: 'Seeds the database with required data. This command is run every time a new container is created from the torsearch-app image and is part of the init process.',
|
||||
)]
|
||||
class SeedDatabaseCommand extends Command
|
||||
{
|
||||
@@ -50,17 +51,23 @@ class SeedDatabaseCommand extends Command
|
||||
$preferences = $this->getPreferences();
|
||||
|
||||
foreach ($preferences as $preference) {
|
||||
if ($this->preferenceRepository->find($preference['id'])) {
|
||||
continue;
|
||||
$isNewRecord = false;
|
||||
$preferenceRecord = $this->preferenceRepository->findOneBy(['id' => $preference['id']]);
|
||||
if (null === $preferenceRecord) {
|
||||
$isNewRecord = true;
|
||||
$preferenceRecord = new Preference();
|
||||
}
|
||||
|
||||
$this->preferenceRepository->getEntityManager()->persist((new \App\User\Framework\Entity\Preference())
|
||||
$preferenceRecord
|
||||
->setId($preference['id'])
|
||||
->setName($preference['name'])
|
||||
->setDescription($preference['description'])
|
||||
->setEnabled($preference['enabled'])
|
||||
->setType($preference['type'])
|
||||
);
|
||||
->setType($preference['type']);
|
||||
|
||||
if (true === $isNewRecord) {
|
||||
$this->preferenceRepository->getEntityManager()->persist($preferenceRecord);
|
||||
}
|
||||
}
|
||||
|
||||
$this->preferenceRepository->getEntityManager()->flush();
|
||||
|
||||
@@ -11,7 +11,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'startup:status',
|
||||
description: 'Add a short description for your command',
|
||||
description: 'Used by the Docker healthcheck system to signal when the container is healthy.',
|
||||
)]
|
||||
class StartupStatusCommand extends Command
|
||||
{
|
||||
|
||||
112
src/Base/Framework/Command/UserResetPasswordCommand.php
Normal file
112
src/Base/Framework/Command/UserResetPasswordCommand.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Base\Framework\Command;
|
||||
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
#[AsCommand(name: 'user:reset-password', description: 'Resets the password for the given user. Requires either the ID or email of the User. You will be asked for the password after running the command.')]
|
||||
class UserResetPasswordCommand extends Command
|
||||
{
|
||||
private readonly Security $security;
|
||||
private readonly UserRepository $userRepository;
|
||||
private readonly UserPasswordHasherInterface $hasher;
|
||||
|
||||
public function __construct(
|
||||
Security $security,
|
||||
UserRepository $userRepository,
|
||||
UserPasswordHasherInterface $hasher,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->security = $security;
|
||||
$this->userRepository = $userRepository;
|
||||
$this->hasher = $hasher;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('id', null, InputOption::VALUE_REQUIRED, 'The ID of the user in the database.')
|
||||
->addOption('email', null, InputOption::VALUE_REQUIRED, 'The email of the user.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$queryParams = $this->parseInput($input, $io);
|
||||
if ([] === $queryParams) {
|
||||
$io->error('No ID or Email specified. Please run again and pass the "--id" or "--email" option.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findOneBy($queryParams);
|
||||
if (null === $user) {
|
||||
$io->error('No such user exists.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$newPassword = $this->askForPassword($input, $output);
|
||||
$this->updateUsersPassword($user, $newPassword);
|
||||
} catch (\Throwable $exception) {
|
||||
$io->error($exception->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success('Success. The password has been reset.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function parseInput(InputInterface $input, SymfonyStyle $io): array
|
||||
{
|
||||
if ($input->getOption('id')) {
|
||||
return ['id' => $input->getOption('id')];
|
||||
} elseif ($input->getOption('email')) {
|
||||
return ['email' => $input->getOption('email')];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function askForPassword(InputInterface $input, OutputInterface $output): ?string
|
||||
{
|
||||
$questionHelper = new QuestionHelper();
|
||||
$question = new Question('New password (input is hidden): ')
|
||||
->setHidden(true)
|
||||
->setHiddenFallback(false)
|
||||
->setNormalizer(function (?string $value): string {
|
||||
return $value ?? '';
|
||||
})
|
||||
->setValidator(function (string $value): string {
|
||||
if ('' === trim($value)) {
|
||||
throw new \Exception('The password cannot be empty');
|
||||
}
|
||||
return $value;
|
||||
})
|
||||
->setMaxAttempts(5)
|
||||
;
|
||||
|
||||
return $questionHelper->ask($input, $output, $question);
|
||||
}
|
||||
|
||||
private function updateUsersPassword(UserInterface $user, string $newPassword): void
|
||||
{
|
||||
$user->setPassword(
|
||||
$this->hasher->hashPassword($user, $newPassword)
|
||||
);
|
||||
$this->userRepository->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Base\Framework\Controller;
|
||||
|
||||
use App\Base\Util\Broadcaster;
|
||||
use App\Base\Service\Broadcaster;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\User\Framework\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class IndexController extends AbstractController
|
||||
@@ -29,4 +31,21 @@ final class IndexController extends AbstractController
|
||||
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/email')]
|
||||
public function sendEmail(MailerInterface $mailer): Response
|
||||
{
|
||||
$email = (new Email())
|
||||
->to('brock@caldwell.digital')
|
||||
->subject('Time for Symfony Mailer!')
|
||||
->text('Sending emails is fun again!')
|
||||
->html('<p>See Twig integration for better HTML integration!</p>');
|
||||
|
||||
$mailer->send($email);
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'message' => 'Email sent!'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Base\Util;
|
||||
namespace App\Base\Service;
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Monitor\Service;
|
||||
namespace App\Base\Service;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
12
src/Base/Util/EpisodeId.php
Normal file
12
src/Base/Util/EpisodeId.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Base\Util;
|
||||
|
||||
class EpisodeId
|
||||
{
|
||||
public static function fromSeasonEpisodeNumbers(int $season, int $episode): string
|
||||
{
|
||||
return "S". str_pad($season, 2, "0", STR_PAD_LEFT) .
|
||||
"E". str_pad($episode, 2, "0", STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
18
src/Download/Action/Command/DownloadSeasonCommand.php
Normal file
18
src/Download/Action/Command/DownloadSeasonCommand.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Command;
|
||||
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<DownloadSeasonCommand>
|
||||
*/
|
||||
class DownloadSeasonCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
public int $userId,
|
||||
public int $season,
|
||||
public string $imdbId,
|
||||
public string $mediaType = 'tvshows',
|
||||
) {}
|
||||
}
|
||||
106
src/Download/Action/Handler/DownloadSeasonHandler.php
Normal file
106
src/Download/Action/Handler/DownloadSeasonHandler.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Handler;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Download\Action\Command\DownloadMediaCommand;
|
||||
use App\Download\Action\Command\DownloadSeasonCommand;
|
||||
use App\Download\Action\Result\DownloadMediaResult;
|
||||
use App\Download\Action\Result\DownloadSeasonResult;
|
||||
use App\Download\DownloadOptionEvaluator;
|
||||
use App\Tmdb\Tmdb;
|
||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||
use App\User\Dto\UserPreferencesFactory;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use Nihilarr\PTN;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/** @implements HandlerInterface<DownloadSeasonCommand, DownloadMediaResult> */
|
||||
readonly class DownloadSeasonHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private MediaFiles $mediaFiles,
|
||||
private LoggerInterface $logger,
|
||||
private Tmdb $tmdb,
|
||||
private MessageBusInterface $bus,
|
||||
private DownloadOptionEvaluator $downloadOptionEvaluator,
|
||||
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
|
||||
private UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
$series = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] Executing DownloadTvSeasonHandler for "' . $series->title . '" season ' . $command->season);
|
||||
|
||||
$episodesInSeason = Map::from($series->episodes[$command->season]);
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ...Found ' . count($episodesInSeason) . ' episodes in season ' . $command->season);
|
||||
|
||||
$downloadCommands = [];
|
||||
foreach ($episodesInSeason as $episode) {
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode['episode_number']);
|
||||
|
||||
$results = $this->getTvShowOptionsHandler->handle(
|
||||
new GetTvShowOptionsCommand(
|
||||
$series->tmdbId,
|
||||
$command->imdbId,
|
||||
$command->season,
|
||||
$episode['episode_number']
|
||||
)
|
||||
);
|
||||
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ......Found ' . count($results->results) . ' total download options, beginning evaluation');
|
||||
|
||||
$userPreferences = UserPreferencesFactory::createFromUser(
|
||||
$this->userRepository->findOneBy(['id' => $command->userId])
|
||||
);
|
||||
|
||||
$result = $this->downloadOptionEvaluator->evaluateOptions($results->results, $userPreferences);
|
||||
|
||||
if (null !== $result) {
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching result');
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ......Dispatching DownloadMediaCommand for "' . $series->title . '" season ' . $command->season . ' episode ' . $episode['episode_number']);
|
||||
$downloadCommand = new DownloadMediaCommand(
|
||||
$result->url,
|
||||
$series->title,
|
||||
$result->filename,
|
||||
'tvshows',
|
||||
$command->imdbId,
|
||||
$command->userId,
|
||||
);
|
||||
$this->bus->dispatch($downloadCommand);
|
||||
$downloadCommands[] = $downloadCommand;
|
||||
} else {
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 0 matching results');
|
||||
}
|
||||
}
|
||||
|
||||
return new DownloadSeasonResult(
|
||||
status: 200,
|
||||
message: 'Success',
|
||||
data: ['downloads' => $downloadCommands],
|
||||
);
|
||||
}
|
||||
|
||||
private function getDownloadedEpisodes(string $title)
|
||||
{
|
||||
// Check current episodes
|
||||
$downloadedEpisodes = $this->mediaFiles
|
||||
->getEpisodes($title)
|
||||
->map(fn($episode) => (object) (new PTN())->parse($episode))
|
||||
->filter(fn ($episode) =>
|
||||
property_exists($episode, 'episode')
|
||||
&& property_exists($episode, 'season')
|
||||
&& null !== $episode->episode
|
||||
&& null !== $episode->season
|
||||
)
|
||||
->rekey(fn($episode) => $episode->episode);
|
||||
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
|
||||
}
|
||||
}
|
||||
37
src/Download/Action/Input/DownloadSeasonInput.php
Normal file
37
src/Download/Action/Input/DownloadSeasonInput.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Input;
|
||||
|
||||
use App\Download\Action\Command\DownloadMediaCommand;
|
||||
use App\Download\Action\Command\DownloadSeasonCommand;
|
||||
use OneToMany\RichBundle\Attribute\SourceRequest;
|
||||
use OneToMany\RichBundle\Attribute\SourceRoute;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\InputInterface;
|
||||
|
||||
/** @implements InputInterface<DownloadSeasonInput> */
|
||||
class DownloadSeasonInput implements InputInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[SourceRoute('imdbId')]
|
||||
public string $imdbId,
|
||||
|
||||
#[SourceRoute('season')]
|
||||
public int $season,
|
||||
|
||||
#[SourceRequest('mediaType')]
|
||||
public string $mediaType = 'tvshows',
|
||||
|
||||
public ?int $userId = null,
|
||||
) {}
|
||||
|
||||
public function toCommand(): CommandInterface
|
||||
{
|
||||
return new DownloadSeasonCommand(
|
||||
$this->userId,
|
||||
$this->season,
|
||||
$this->imdbId,
|
||||
$this->mediaType,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/Download/Action/Result/DownloadSeasonResult.php
Normal file
15
src/Download/Action/Result/DownloadSeasonResult.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Result;
|
||||
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
/** @implements ResultInterface<DownloadSeasonResult> */
|
||||
class DownloadSeasonResult implements ResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public int $status,
|
||||
public string $message,
|
||||
public array $data,
|
||||
) {}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Monitor\Service;
|
||||
namespace App\Download;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Torrentio\Result\TorrentioResult;
|
||||
use App\User\Dto\UserPreferences;
|
||||
|
||||
class MonitorOptionEvaluator
|
||||
class DownloadOptionEvaluator
|
||||
{
|
||||
/**
|
||||
* @param Monitor $monitor
|
||||
@@ -14,7 +15,7 @@ class MonitorOptionEvaluator
|
||||
* @return TorrentioResult|null
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function evaluateOptions(Monitor $monitor, array $results): ?TorrentioResult
|
||||
public function evaluateOptions(array $results, UserPreferences $userPreferences): ?TorrentioResult
|
||||
{
|
||||
$sizeLow = 000;
|
||||
$sizeHigh = 4096;
|
||||
@@ -22,35 +23,33 @@ class MonitorOptionEvaluator
|
||||
$bestMatches = [];
|
||||
$matches = [];
|
||||
|
||||
$userPreferences = $monitor->getUser()->getUserPreferenceValues();
|
||||
|
||||
foreach ($results as $result) {
|
||||
if (!in_array($userPreferences['language'], $result->languages)) {
|
||||
if (!in_array($userPreferences->language, $result->languages)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($result->resolution === $userPreferences['resolution']
|
||||
&& $result->codec === $userPreferences['codec']
|
||||
if ($result->resolution === $userPreferences->resolution
|
||||
&& $result->codec === $userPreferences->codec
|
||||
) {
|
||||
$bestMatches[] = $result;
|
||||
}
|
||||
|
||||
if ($userPreferences['resolution'] === '2160p'
|
||||
&& $userPreferences['codec'] === $result->codec
|
||||
if ($userPreferences->resolution === '2160p'
|
||||
&& $userPreferences->codec === $result->codec
|
||||
&& $result->resolution === '1080p'
|
||||
) {
|
||||
$matches[] = $result;
|
||||
}
|
||||
|
||||
if ($userPreferences['codec'] === 'h264'
|
||||
&& $userPreferences['resolution'] === $result->resolution
|
||||
if ($userPreferences->codec === 'h264'
|
||||
&& $userPreferences->resolution === $result->resolution
|
||||
&& $result->codec === 'h265'
|
||||
) {
|
||||
$matches[] = $result;
|
||||
}
|
||||
|
||||
if (($userPreferences['codec'] === null )
|
||||
&& ($userPreferences['resolution'] === null )) {
|
||||
if (($userPreferences->codec === null )
|
||||
&& ($userPreferences->resolution === null )) {
|
||||
$matches[] = $result;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Download\Downloader;
|
||||
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use App\Monitor\Service\MediaFiles;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
@@ -15,7 +15,6 @@ class ProcessDownloader implements DownloaderInterface
|
||||
/**
|
||||
* @var RedisAdapter $cache
|
||||
*/
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private MediaFiles $mediaFiles,
|
||||
@@ -34,11 +33,11 @@ class ProcessDownloader implements DownloaderInterface
|
||||
$downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences();
|
||||
$path = $this->getDownloadPath($mediaType, $title, $downloadPreferences);
|
||||
|
||||
$processArgs = ['wget', $url];
|
||||
$processArgs = ['wget', '-O', $downloadEntity->getFilename(), $url];
|
||||
|
||||
if ($downloadEntity->getStatus() === 'Paused') {
|
||||
$downloadEntity->setStatus('In Progress');
|
||||
$processArgs = ['wget', '-c', $url];
|
||||
$processArgs = ['wget', '-c', '-O', $downloadEntity->getFilename(), $url];
|
||||
} else {
|
||||
$downloadEntity->setProgress(0);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
namespace App\Download\Framework\Controller;
|
||||
|
||||
use App\Base\Util\Broadcaster;
|
||||
use App\Base\Service\Broadcaster;
|
||||
use App\Download\Action\Handler\DeleteDownloadHandler;
|
||||
use App\Download\Action\Handler\PauseDownloadHandler;
|
||||
use App\Download\Action\Handler\ResumeDownloadHandler;
|
||||
use App\Download\Action\Input\DeleteDownloadInput;
|
||||
use App\Download\Action\Input\DownloadMediaInput;
|
||||
use App\Download\Action\Input\DownloadSeasonInput;
|
||||
use App\Download\Action\Input\PauseDownloadInput;
|
||||
use App\Download\Action\Input\ResumeDownloadInput;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use Nihilarr\PTN;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
@@ -29,13 +29,6 @@ class ApiController extends AbstractController
|
||||
public function download(
|
||||
DownloadMediaInput $input,
|
||||
): Response {
|
||||
$ptn = (object) new Ptn()->parse($input->filename);
|
||||
if ($input->mediaType === "tvshows" &&
|
||||
!property_exists($ptn, 'episode') && !property_exists($ptn, 'season')
|
||||
) {
|
||||
$input->filename = $input->episodeId . '_' . $input->filename;
|
||||
}
|
||||
|
||||
$download = $this->downloadRepository->insert(
|
||||
$this->getUser(),
|
||||
$input->url,
|
||||
@@ -43,10 +36,8 @@ class ApiController extends AbstractController
|
||||
$input->filename,
|
||||
$input->imdbId,
|
||||
$input->mediaType,
|
||||
"",
|
||||
$input->episodeId,
|
||||
);
|
||||
$this->downloadRepository->getEntityManager()->persist($download);
|
||||
$this->downloadRepository->getEntityManager()->flush();
|
||||
$input->downloadId = $download->getId();
|
||||
$input->userId = $this->getUser()->getId();
|
||||
|
||||
@@ -105,4 +96,18 @@ class ApiController extends AbstractController
|
||||
|
||||
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
|
||||
}
|
||||
|
||||
#[Route('/api/download/season/{imdbId}/{season}', name: 'api_download_season', methods: ['GET'])]
|
||||
public function downloadSeason(
|
||||
DownloadSeasonInput $input,
|
||||
): Response {
|
||||
$input->userId = $this->getUser()->getId();
|
||||
$this->bus->dispatch($input->toCommand());
|
||||
$this->broadcaster->alert(
|
||||
title: 'Success',
|
||||
message: "Your download for season $input->season has been added to the queue.",
|
||||
);
|
||||
|
||||
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class Download
|
||||
private ?int $progress = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $batchId = null;
|
||||
private ?string $episodeId = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'downloads')]
|
||||
private ?User $user = null;
|
||||
@@ -143,14 +143,14 @@ class Download
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBatchId(): ?string
|
||||
public function getEpisodeId(): ?string
|
||||
{
|
||||
return $this->batchId;
|
||||
return $this->episodeId;
|
||||
}
|
||||
|
||||
public function setBatchId(?string $batchId): static
|
||||
public function setEpisodeId(?string $episodeId): static
|
||||
{
|
||||
$this->batchId = $batchId;
|
||||
$this->episodeId = $episodeId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Download\Framework\Entity\Download;
|
||||
use App\User\Framework\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Nihilarr\PTN;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
@@ -62,9 +63,15 @@ class DownloadRepository extends ServiceEntityRepository
|
||||
string $filename,
|
||||
string $imdbId,
|
||||
string $mediaType,
|
||||
string $batchId,
|
||||
?string $episodeId = null,
|
||||
string $status = 'New'
|
||||
): Download {
|
||||
$ptn = (object) new Ptn()->parse($filename);
|
||||
if ($mediaType === "tvshows" &&
|
||||
!property_exists($ptn, 'episode') && !property_exists($ptn, 'season')
|
||||
) {
|
||||
$filename = $episodeId . '_' . $filename;
|
||||
}
|
||||
/** @var User $user */
|
||||
$download = (new Download())
|
||||
->setUser($user)
|
||||
@@ -73,7 +80,7 @@ class DownloadRepository extends ServiceEntityRepository
|
||||
->setFilename($filename)
|
||||
->setImdbId($imdbId)
|
||||
->setMediaType($mediaType)
|
||||
->setBatchId($batchId)
|
||||
->setEpisodeId($episodeId)
|
||||
->setProgress(0)
|
||||
->setStatus($status);
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ readonly class MonitorMovieHandler implements HandlerInterface
|
||||
public function __construct(
|
||||
private MonitorRepository $movieMonitorRepository,
|
||||
private GetMovieOptionsHandler $getMovieOptionsHandler,
|
||||
private MonitorOptionEvaluator $monitorOptionEvaluator,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private MessageBusInterface $bus,
|
||||
private LoggerInterface $logger,
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Monitor\Action\Handler;
|
||||
|
||||
use App\Base\Util\EpisodeId;
|
||||
use App\Download\Action\Command\DownloadMediaCommand;
|
||||
use App\Download\DownloadOptionEvaluator;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\Monitor\Action\Command\MonitorMovieCommand;
|
||||
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Monitor\Service\MonitorOptionEvaluator;
|
||||
use App\Tmdb\Tmdb;
|
||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||
@@ -25,12 +28,13 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
|
||||
private MonitorOptionEvaluator $monitorOptionEvaluator,
|
||||
private DownloadOptionEvaluator $downloadOptionEvaluator,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private MessageBusInterface $bus,
|
||||
private LoggerInterface $logger,
|
||||
private MonitorRepository $monitorRepository,
|
||||
private Tmdb $tmdb,
|
||||
private DownloadRepository $downloadRepository,
|
||||
) {}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
@@ -65,17 +69,27 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
|
||||
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found ' . count($results->results) . ' total download options, beginning evaluation');
|
||||
|
||||
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
|
||||
$result = $this->downloadOptionEvaluator->evaluateOptions($results->results, UserPreferencesFactory::createFromUser($monitor->getUser()));
|
||||
|
||||
if (null !== $result) {
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"', ['filter' => UserPreferencesFactory::createFromUser($monitor->getUser())]);
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
|
||||
$download = $this->downloadRepository->insert(
|
||||
user: $monitor->getUser(),
|
||||
url: $result->url,
|
||||
title: $monitor->getTitle(),
|
||||
filename: $result->filename,
|
||||
imdbId: $monitor->getImdbId(),
|
||||
mediaType: 'tvshows',
|
||||
episodeId: EpisodeId::fromSeasonEpisodeNumbers($monitor->getSeason(), $monitor->getEpisode()),
|
||||
);
|
||||
$this->bus->dispatch(new DownloadMediaCommand(
|
||||
$result->url,
|
||||
$monitor->getTitle(),
|
||||
$result->filename,
|
||||
$download->getUrl(),
|
||||
$download->getTitle(),
|
||||
$download->getFilename(),
|
||||
'tvshows',
|
||||
$monitor->getImdbId(),
|
||||
$download->getImdbId(),
|
||||
$monitor->getUser()->getId(),
|
||||
$download->getId(),
|
||||
));
|
||||
$monitor->setStatus('Complete');
|
||||
$monitor->setDownloadedAt(new DateTimeImmutable());
|
||||
@@ -83,17 +97,16 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 0 matching results found, monitor will run at next interval');
|
||||
$monitor->setStatus('Active');
|
||||
}
|
||||
|
||||
$monitor->setLastSearch(new DateTimeImmutable());
|
||||
$monitor->setSearchCount($monitor->getSearchCount() + 1);
|
||||
$this->entityManager->flush();
|
||||
} catch (\Throwable $exception) {
|
||||
$this->logger->error('> [MonitorTvEpisodeHandler] ...Exception thrown: ' . $exception->getMessage());
|
||||
$this->logger->error($exception->getMessage());
|
||||
$monitor->setStatus('Active');
|
||||
$this->monitorRepository->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
$monitor->setLastSearch(new DateTimeImmutable());
|
||||
$monitor->setSearchCount($monitor->getSearchCount() + 1);
|
||||
$this->monitorRepository->getEntityManager()->flush();
|
||||
|
||||
return new MonitorTvEpisodeResult(
|
||||
status: 'OK',
|
||||
result: [
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
namespace App\Monitor\Action\Handler;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
|
||||
use App\Monitor\Action\Command\MonitorTvSeasonCommand;
|
||||
use App\Monitor\Action\Result\MonitorTvSeasonResult;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Monitor\Service\MediaFiles;
|
||||
use App\Tmdb\Tmdb;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
namespace App\Monitor\Action\Handler;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Monitor\Action\Command\MonitorMovieCommand;
|
||||
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
|
||||
use App\Monitor\Action\Result\MonitorTvShowResult;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Monitor\Service\MediaFiles;
|
||||
use App\Tmdb\Tmdb;
|
||||
use Carbon\Carbon;
|
||||
use DateTimeImmutable;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Monitor\Framework\Controller;
|
||||
|
||||
use App\Base\Util\Broadcaster;
|
||||
use App\Base\Service\Broadcaster;
|
||||
use App\Monitor\Action\Handler\AddMonitorHandler;
|
||||
use App\Monitor\Action\Handler\DeleteMonitorHandler;
|
||||
use App\Monitor\Action\Input\AddMonitorInput;
|
||||
@@ -54,10 +54,12 @@ class ApiController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/api/monitor/dispatch', name: 'api_monitor_dispatch', methods: ['GET'])]
|
||||
public function dispatch(MonitorDispatcher $dispatcher): Response
|
||||
public function dispatch(MonitorDispatcher $dispatcher, Broadcaster $broadcaster): Response
|
||||
{
|
||||
$dispatcher();
|
||||
|
||||
$broadcaster->alert('Success', 'The monitor job has been dispatched.');
|
||||
|
||||
return $this->json([
|
||||
'status' => 200,
|
||||
'message' => 'Manually dispatched MonitorDispatcher'
|
||||
|
||||
@@ -50,6 +50,9 @@ class Monitor
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $searchCount = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $onlyFuture = true;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||
private ?\DateTimeInterface $lastSearch = null;
|
||||
|
||||
@@ -147,6 +150,11 @@ class Monitor
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isOnlyFuture(): bool
|
||||
{
|
||||
return $this->onlyFuture;
|
||||
}
|
||||
|
||||
public function getLastSearch(): ?\DateTimeInterface
|
||||
{
|
||||
return Carbon::parse($this->lastSearch);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Search\Action\Handler;
|
||||
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Search\Action\Command\GetMediaInfoCommand;
|
||||
use App\Search\Action\Result\GetMediaInfoResult;
|
||||
use App\Tmdb\Tmdb;
|
||||
@@ -14,12 +15,19 @@ class GetMediaInfoHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Tmdb $tmdb,
|
||||
private readonly MediaFiles $mediaFiles
|
||||
) {}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
|
||||
|
||||
if ("tvshows" === $command->mediaType) {
|
||||
foreach ($media->episodes[$command->season] as $key => $episode) {
|
||||
$media->episodes[$command->season][$key]['file'] = $this->mediaFiles->episodeExists($media->title, $command->season, $episode['episode_number']);
|
||||
}
|
||||
}
|
||||
|
||||
return new GetMediaInfoResult($media, $command->season);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Torrentio\Action\Handler;
|
||||
|
||||
use App\Monitor\Service\MediaFiles;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Tmdb\Tmdb;
|
||||
use App\Torrentio\Action\Result\GetMovieOptionsResult;
|
||||
use App\Torrentio\Client\Torrentio;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Torrentio\Action\Handler;
|
||||
|
||||
use App\Monitor\Service\MediaFiles;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Tmdb\Tmdb;
|
||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Torrentio\Framework\Controller;
|
||||
|
||||
use App\Base\Util\Broadcaster;
|
||||
use App\Base\Service\Broadcaster;
|
||||
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
|
||||
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||
use App\Torrentio\Action\Input\GetMovieOptionsInput;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Torrentio\Result;
|
||||
|
||||
use App\Base\Util\CountryLanguages;
|
||||
use App\User\Database\CountryLanguages;
|
||||
use Nihilarr\PTN;
|
||||
|
||||
class ResultFactory
|
||||
@@ -21,7 +21,6 @@ class ResultFactory
|
||||
string $bingeGroup = "-"
|
||||
) {
|
||||
$ptn = (object) (new PTN())->parse($title);
|
||||
// dump($ptn);
|
||||
return new TorrentioResult(
|
||||
self::trimTitle($title),
|
||||
urldecode($url),
|
||||
@@ -40,7 +39,8 @@ class ResultFactory
|
||||
$ptn->episode ?? "-",
|
||||
self::setLanguages($title),
|
||||
self::setLanguageFlags($title),
|
||||
false
|
||||
false,
|
||||
uniqid()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,5 +23,6 @@ class TorrentioResult
|
||||
public ?array $languages = [],
|
||||
public ?string $languageFlags = "-",
|
||||
public ?bool $selected = false,
|
||||
public ?string $localId = "-"
|
||||
) {}
|
||||
}
|
||||
|
||||
10
src/Twig/Components/ActionButton.php
Normal file
10
src/Twig/Components/ActionButton.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||
|
||||
#[AsTwigComponent]
|
||||
final class ActionButton
|
||||
{
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Base\Util\QualityList;
|
||||
use App\User\Database\QualityList;
|
||||
use App\User\Framework\Repository\PreferencesRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
|
||||
namespace App\Twig\Extensions;
|
||||
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Service\MediaFiles;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
|
||||
use App\Torrentio\Result\TorrentioResult;
|
||||
use ChrisUllyott\FileSize;
|
||||
use Tmdb\Model\Tv\Episode;
|
||||
use Twig\Attribute\AsTwigFilter;
|
||||
use Twig\Attribute\AsTwigFunction;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Base\Util;
|
||||
namespace App\User\Database;
|
||||
|
||||
class CountryCodes
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Base\Util;
|
||||
namespace App\User\Database;
|
||||
|
||||
class CountryLanguages
|
||||
{
|
||||
@@ -137,4 +137,13 @@ class CountryLanguages
|
||||
|
||||
return $countryLanguages[$countryName] ?? null;
|
||||
}
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach (static::$languages as $language) {
|
||||
$result[$language] = $language;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Base\Util;
|
||||
namespace App\User\Database;
|
||||
|
||||
class ProviderList
|
||||
{
|
||||
@@ -23,4 +23,13 @@ class ProviderList
|
||||
{
|
||||
return self::$providers;
|
||||
}
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach (static::$providers as $provider) {
|
||||
$result[$provider] = $provider;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Base\Util;
|
||||
namespace App\User\Database;
|
||||
|
||||
class QualityList
|
||||
{
|
||||
@@ -100,6 +100,15 @@ class QualityList
|
||||
return array_search($key, self::$qualities) ?? null;
|
||||
}
|
||||
|
||||
public static function asSelectOptions(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach (array_keys(static::$qualities) as $quality) {
|
||||
$result[$quality] = $quality;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function getAsReverseMap(): array
|
||||
{
|
||||
$results = [];
|
||||
@@ -6,10 +6,10 @@ class UserPreferences
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
public readonly string $resolution,
|
||||
public readonly string $codec,
|
||||
public readonly string $language,
|
||||
public readonly string $provider,
|
||||
public readonly string $quality,
|
||||
public readonly ?string $resolution,
|
||||
public readonly ?string $codec,
|
||||
public readonly ?string $language,
|
||||
public readonly ?string $provider,
|
||||
public readonly ?string $quality,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,46 @@
|
||||
|
||||
namespace App\User\Dto;
|
||||
|
||||
use App\User\Framework\Entity\PreferenceOption;
|
||||
use App\User\Framework\Entity\User;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class UserPreferencesFactory
|
||||
{
|
||||
public static function createFromUser(User $user): UserPreferences
|
||||
/** @param User $user */
|
||||
public static function createFromUser(UserInterface $user): UserPreferences
|
||||
{
|
||||
return new UserPreferences(
|
||||
resolution: $user->getUserPreference('resolution')->getPreferenceValue(),
|
||||
codec: $user->getUserPreference('codec')->getPreferenceValue(),
|
||||
language: $user->getUserPreference('language')->getPreferenceValue(),
|
||||
provider: $user->getUserPreference('provider')->getPreferenceValue(),
|
||||
quality: $user->getUserPreference('quality')->getPreferenceValue(),
|
||||
resolution: self::getNestedValue($user, 'resolution'),
|
||||
codec: self::getNestedValue($user, 'codec'),
|
||||
language: self::getValue($user, 'language'),
|
||||
provider: self::getValue($user, 'provider'),
|
||||
quality: self::getValue($user, 'quality'),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param User $user */
|
||||
private static function getValue(UserInterface $user, string $preferenceId)
|
||||
{
|
||||
$value = $user->getUserPreference($preferenceId)->getPreferenceValue();
|
||||
if ($value === "") {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @param User $user */
|
||||
private static function getNestedValue(UserInterface $user, string $preferenceId): ?string
|
||||
{
|
||||
$preference = $user->getUserPreference($preferenceId);
|
||||
if (null === $preference) {
|
||||
return null;
|
||||
}
|
||||
return $preference->getPreference()
|
||||
->getPreferenceOptions()
|
||||
->filter(fn (PreferenceOption $option) => (string) $option->getId() === $preference->getPreferenceValue())
|
||||
->first()
|
||||
->getValue()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,30 @@
|
||||
|
||||
namespace App\User\Framework\Controller\Web;
|
||||
|
||||
use App\Base\ConfigResolver;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
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, UserRepository $userRepository): Response
|
||||
public function login(ConfigResolver $config, AuthenticationUtils $authenticationUtils, UserRepository $userRepository): Response
|
||||
{
|
||||
if ((new ArrayCollection($userRepository->findAll()))->count() === 0) {
|
||||
return $this->redirectToRoute('app_getting_started');
|
||||
}
|
||||
|
||||
if ($config->authIs('oidc') && $config->bypassFormLogin()) {
|
||||
return $this->redirectToRoute('app_login_oidc');
|
||||
}
|
||||
|
||||
// get the login error if there is one
|
||||
$error = $authenticationUtils->getLastAuthenticationError();
|
||||
|
||||
@@ -25,13 +33,14 @@ class LoginController extends AbstractController
|
||||
$lastUsername = $authenticationUtils->getLastUsername();
|
||||
|
||||
return $this->render('user/login.html.twig', [
|
||||
'show_oidc_button' => $config->authIs('oidc'),
|
||||
'last_username' => $lastUsername,
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/logout', name: 'app_logout')]
|
||||
public function logout(): void
|
||||
public function logout(Security $security, Request $request): void
|
||||
{
|
||||
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
|
||||
}
|
||||
|
||||
46
src/User/Framework/Controller/Web/LoginOidcController.php
Normal file
46
src/User/Framework/Controller/Web/LoginOidcController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Controller\Web;
|
||||
|
||||
use App\Base\ConfigResolver;
|
||||
use Drenso\OidcBundle\OidcClientInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class LoginOidcController extends AbstractController
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private ConfigResolver $configResolver,
|
||||
) {}
|
||||
|
||||
#[Route('/login/oidc', name: 'app_login_oidc')]
|
||||
public function oidcStart(OidcClientInterface $oidcClient): RedirectResponse
|
||||
{
|
||||
if (false === $this->configResolver->authIs('oidc')) {
|
||||
throw new \Exception('You must configure the OIDC environment variables before logging in at this route.');
|
||||
}
|
||||
|
||||
// Redirect to authorization @ OIDC provider
|
||||
return $oidcClient->generateAuthorizationRedirect(scopes: ['openid', 'profile']);
|
||||
}
|
||||
|
||||
#[Route('/login/oidc/auth', name: 'app_login_oidc_auth')]
|
||||
public function oidcAuthenticate(): RedirectResponse
|
||||
{
|
||||
if (false === $this->configResolver->authIs('oidc')) {
|
||||
throw new \Exception('You must configure the OIDC environment variables before logging in at this route.');
|
||||
}
|
||||
|
||||
throw new \LogicException('This method can be blank - it will be intercepted by the "oidc" key on your firewall.');
|
||||
}
|
||||
|
||||
#[Route('/logout/oidc', 'app_logout_oidc')]
|
||||
public function oidcLogout(OidcClientInterface $oidcClient, Request $request, Security $security): RedirectResponse
|
||||
{
|
||||
// ToDo: Configure multiple authentication methods and redirect to the form login here
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\User\Framework\Controller\Web;
|
||||
|
||||
use App\Base\Util\Broadcaster;
|
||||
use App\Base\Util\CountryLanguages;
|
||||
use App\Base\Util\ProviderList;
|
||||
use App\Base\Util\QualityList;
|
||||
use App\Base\Service\Broadcaster;
|
||||
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
|
||||
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
|
||||
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
|
||||
use App\User\Action\Input\SaveUserMediaPreferencesInput;
|
||||
use App\User\Database\CountryLanguages;
|
||||
use App\User\Database\ProviderList;
|
||||
use App\User\Database\QualityList;
|
||||
use App\User\Dto\UserPreferencesFactory;
|
||||
use App\User\Framework\Form\GettingStartedFilterForm;
|
||||
use App\User\Framework\Repository\PreferencesRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -41,6 +43,7 @@ class PreferencesController extends AbstractController
|
||||
'qualities' => QualityList::getBaseQualities(),
|
||||
'mediaPreferences' => $mediaPreferences,
|
||||
'downloadPreferences' => $downloadPreferences,
|
||||
'filterForm' => $this->createForm(GettingStartedFilterForm::class, (array) UserPreferencesFactory::createFromUser($this->getUser())),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -72,6 +75,7 @@ class PreferencesController extends AbstractController
|
||||
'qualities' => QualityList::getBaseQualities(),
|
||||
'mediaPreferences' => $mediaPreferences,
|
||||
'downloadPreferences' => $downloadPreferences,
|
||||
'filterForm' => $this->createForm(GettingStartedFilterForm::class ?? null),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,30 +5,22 @@ namespace App\User\Framework\Controller\Web;
|
||||
use App\User\Action\Command\RegisterUserCommand;
|
||||
use App\User\Action\Handler\RegisterUserHandler;
|
||||
use App\User\Framework\Entity\User;
|
||||
use App\User\Framework\Form\GettingStartedFilterForm;
|
||||
use App\User\Framework\Form\RegistrationFormType;
|
||||
use App\User\Framework\Pipeline\GettingStarted\AddPreferencesToDatabase;
|
||||
use App\User\Framework\Pipeline\GettingStarted\GettingStartedInput;
|
||||
use App\User\Framework\Pipeline\GettingStarted\MigrateDatabase;
|
||||
use App\User\Framework\Repository\PreferencesRepository;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use League\Pipeline\Pipeline;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class RegistrationController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly RegisterUserHandler $registerUserHandler,
|
||||
private readonly RequestStack $requestStack
|
||||
)
|
||||
{
|
||||
}
|
||||
private readonly RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
#[Route('/register', name: 'app_register')]
|
||||
public function register(
|
||||
@@ -57,7 +49,7 @@ class RegistrationController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route(path: '/getting-started', name: 'app_getting_started')]
|
||||
public function gettingStarted(Request $request, Security $security, UserRepository $userRepository, PreferencesRepository $preferencesRepository, KernelInterface $kernel, LoggerInterface $logger): Response
|
||||
public function gettingStarted(Request $request, Security $security, UserRepository $userRepository): Response
|
||||
{
|
||||
if ((new ArrayCollection($userRepository->findAll()))->count() !== 0) {
|
||||
return $this->redirectToRoute('app_index');
|
||||
@@ -73,14 +65,42 @@ class RegistrationController extends AbstractController
|
||||
password: $form->get('plainPassword')->getData(),
|
||||
));
|
||||
|
||||
$security->login($user->user);
|
||||
$security->login($user->user, 'form_login');
|
||||
$this->requestStack->getCurrentRequest()->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
|
||||
|
||||
return $this->redirectToRoute('app_index');
|
||||
return $this->redirectToRoute('app_getting_started_filter');
|
||||
}
|
||||
|
||||
return $this->render('user/getting-started.html.twig', [
|
||||
return $this->render('user/getting_started/register-user.html.twig', [
|
||||
'registrationForm' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/getting-started/filter', name: 'app_getting_started_filter')]
|
||||
public function gettingStartedPreferences(Request $request, UserRepository $userRepository): Response
|
||||
{
|
||||
if ((new ArrayCollection($userRepository->findAll()))->count() !== 0) {
|
||||
return $this->redirectToRoute('app_index');
|
||||
}
|
||||
|
||||
$form = $this->createForm(GettingStartedFilterForm::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
foreach ($form->getData() as $preference => $value) {
|
||||
if (null !== $value) {
|
||||
$this->getUser()->updateUserPreference($preference, $value);
|
||||
}
|
||||
}
|
||||
$userRepository->getEntityManager()->flush();
|
||||
return $this->redirectToRoute('app_index');
|
||||
}
|
||||
|
||||
return $this->render(
|
||||
'user/getting_started/filter.html.twig',
|
||||
[
|
||||
'form' => $form,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
191
src/User/Framework/Controller/Web/ResetPasswordController.php
Normal file
191
src/User/Framework/Controller/Web/ResetPasswordController.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Controller\Web;
|
||||
|
||||
use App\Base\ConfigResolver;
|
||||
use App\User\Framework\Entity\User;
|
||||
use App\User\Framework\Form\ChangePasswordForm;
|
||||
use App\User\Framework\Form\ResetPasswordRequestForm;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||
|
||||
#[Route('/reset-password')]
|
||||
class ResetPasswordController extends AbstractController
|
||||
{
|
||||
use ResetPasswordControllerTrait;
|
||||
|
||||
public function __construct(
|
||||
private ResetPasswordHelperInterface $resetPasswordHelper,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private readonly ConfigResolver $configResolver,
|
||||
private readonly Security $security
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display & process form to request a password reset.
|
||||
*/
|
||||
#[Route('', name: 'app_forgot_password_request')]
|
||||
public function request(
|
||||
Request $request,
|
||||
MailerInterface $mailer,
|
||||
LoggerInterface $logger
|
||||
): Response {
|
||||
$form = $this->createForm(ResetPasswordRequestForm::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($this->configResolver->authIs('oidc')) {
|
||||
$this->addFlash('reset_password_error', 'Your auth method is set to "oidc", so you will need to reset your password with your identity provider.');
|
||||
return $this->render('user/reset_password/request.html.twig', [
|
||||
'requestForm' => $form,
|
||||
])->setStatusCode(Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var string $email */
|
||||
$email = $form->get('email')->getData();
|
||||
return $this->processSendingPasswordResetEmail($email, $mailer, $logger);
|
||||
}
|
||||
|
||||
return $this->render('user/reset_password/request.html.twig', [
|
||||
'requestForm' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation page after a user has requested a password reset.
|
||||
*/
|
||||
#[Route('/check-email', name: 'app_check_email')]
|
||||
public function checkEmail(): Response
|
||||
{
|
||||
// Generate a fake token if the user does not exist or someone hit this page directly.
|
||||
// This prevents exposing whether or not a user was found with the given email address or not
|
||||
if (null === ($resetToken = $this->getTokenObjectFromSession())) {
|
||||
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
|
||||
}
|
||||
|
||||
return $this->render('user/reset_password/check_email.html.twig', [
|
||||
'resetToken' => $resetToken,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and process the reset URL that the user clicked in their email.
|
||||
*/
|
||||
#[Route('/reset/{token}', name: 'app_reset_password')]
|
||||
public function reset(
|
||||
Request $request,
|
||||
UserPasswordHasherInterface $passwordHasher,
|
||||
TranslatorInterface $translator,
|
||||
?string $token = null
|
||||
): Response {
|
||||
if ($token) {
|
||||
// We store the token in session and remove it from the URL, to avoid the URL being
|
||||
// loaded in a browser and potentially leaking the token to 3rd party JavaScript.
|
||||
$this->storeTokenInSession($token);
|
||||
|
||||
return $this->redirectToRoute('app_reset_password');
|
||||
}
|
||||
|
||||
$token = $this->getTokenFromSession();
|
||||
|
||||
if (null === $token) {
|
||||
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var User $user */
|
||||
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
|
||||
} catch (ResetPasswordExceptionInterface $e) {
|
||||
$this->addFlash('reset_password_error', sprintf(
|
||||
'%s - %s',
|
||||
$translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
|
||||
$translator->trans($e->getReason(), [], 'ResetPasswordBundle')
|
||||
));
|
||||
|
||||
return $this->redirectToRoute('app_forgot_password_request');
|
||||
}
|
||||
|
||||
// The token is valid; allow the user to change their password.
|
||||
$form = $this->createForm(ChangePasswordForm::class);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// A password reset token should be used only once, remove it.
|
||||
$this->resetPasswordHelper->removeResetRequest($token);
|
||||
|
||||
/** @var string $plainPassword */
|
||||
$plainPassword = $form->get('plainPassword')->getData();
|
||||
|
||||
// Encode(hash) the plain password, and set it.
|
||||
$user->setPassword($passwordHasher->hashPassword($user, $plainPassword));
|
||||
$this->entityManager->flush();
|
||||
|
||||
// The session is cleaned up after the password has been changed.
|
||||
$this->cleanSessionAfterReset();
|
||||
|
||||
return $this->redirectToRoute('app_index');
|
||||
}
|
||||
|
||||
return $this->render('user/reset_password/reset.html.twig', [
|
||||
'resetForm' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
private function processSendingPasswordResetEmail(
|
||||
string $emailFormData,
|
||||
MailerInterface $mailer,
|
||||
LoggerInterface $logger
|
||||
): RedirectResponse {
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy([
|
||||
'email' => $emailFormData,
|
||||
]);
|
||||
|
||||
// Do not reveal whether a user account was found or not.
|
||||
if (!$user) {
|
||||
return $this->redirectToRoute('app_check_email');
|
||||
}
|
||||
|
||||
try {
|
||||
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
|
||||
} catch (ResetPasswordExceptionInterface $e) {
|
||||
$logger->error('> [ResetPasswordController@processSendingPasswordResetEmail] ' . $e->getMessage());
|
||||
|
||||
$this->addFlash(
|
||||
'reset_password_error',
|
||||
'Your password reset token could not be generated. If you\'re the system administrator, check the server logs for more details.'
|
||||
);
|
||||
|
||||
return $this->redirectToRoute('app_check_email');
|
||||
}
|
||||
|
||||
$email = (new TemplatedEmail())
|
||||
->to((string) $user->getEmail())
|
||||
->subject('Your password reset request')
|
||||
->htmlTemplate('user/reset_password/email.html.twig')
|
||||
->context([
|
||||
'resetToken' => $resetToken,
|
||||
])
|
||||
;
|
||||
|
||||
$mailer->send($email);
|
||||
|
||||
// Store the token object in session for retrieval in check-email route.
|
||||
$this->setTokenObjectInSession($resetToken);
|
||||
|
||||
return $this->redirectToRoute('app_check_email');
|
||||
}
|
||||
}
|
||||
39
src/User/Framework/Entity/ResetPasswordRequest.php
Normal file
39
src/User/Framework/Entity/ResetPasswordRequest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Entity;
|
||||
|
||||
use App\User\Framework\Repository\ResetPasswordRequestRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
|
||||
class ResetPasswordRequest implements ResetPasswordRequestInterface
|
||||
{
|
||||
use ResetPasswordRequestTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $user = null;
|
||||
|
||||
public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->initialize($expiresAt, $selector, $hashedToken);
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*/
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return (string) $this->username ?? $this->email;
|
||||
return (string) $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,7 +156,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
public function getUserPreference(string $preferenceName): ?UserPreference
|
||||
{
|
||||
foreach ($this->userPreferences as $userPreference) {
|
||||
if ($userPreference->getPreference()->getName() === $preferenceName) {
|
||||
if ($userPreference->getPreference()->getName() === $preferenceName
|
||||
|| $userPreference->getPreference()->getId() === $preferenceName
|
||||
) {
|
||||
return $userPreference;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\User\Framework\EventListener;
|
||||
|
||||
use App\Base\ConfigResolver;
|
||||
use App\Base\Util\Broadcaster;
|
||||
use App\Base\Service\Broadcaster;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
|
||||
|
||||
62
src/User/Framework/Form/ChangePasswordForm.php
Normal file
62
src/User/Framework/Form/ChangePasswordForm.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
|
||||
use Symfony\Component\Validator\Constraints\PasswordStrength;
|
||||
|
||||
class ChangePasswordForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('plainPassword', RepeatedType::class, [
|
||||
'type' => PasswordType::class,
|
||||
'options' => [
|
||||
'attr' => [
|
||||
'autocomplete' => 'new-password',
|
||||
'class' => 'text-input w-full mb-4'
|
||||
],
|
||||
'label_attr' => [
|
||||
'class' => 'block'
|
||||
]
|
||||
],
|
||||
'first_options' => [
|
||||
'constraints' => [
|
||||
new NotBlank([
|
||||
'message' => 'Please enter a password',
|
||||
]),
|
||||
new Length([
|
||||
'min' => 12,
|
||||
'minMessage' => 'Your password should be at least {{ limit }} characters',
|
||||
// max length allowed by Symfony for security reasons
|
||||
'max' => 4096,
|
||||
]),
|
||||
new PasswordStrength(),
|
||||
new NotCompromisedPassword(),
|
||||
],
|
||||
'label' => 'New password',
|
||||
],
|
||||
'second_options' => [
|
||||
'label' => 'Repeat Password',
|
||||
],
|
||||
'invalid_message' => 'The password fields must match.',
|
||||
// Instead of being set onto the object directly,
|
||||
// this is read and encoded in the controller
|
||||
'mapped' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
}
|
||||
59
src/User/Framework/Form/GettingStartedFilterForm.php
Normal file
59
src/User/Framework/Form/GettingStartedFilterForm.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Form;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\User\Database\CountryLanguages;
|
||||
use App\User\Database\ProviderList;
|
||||
use App\User\Database\QualityList;
|
||||
use App\User\Framework\Repository\PreferenceOptionRepository;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class GettingStartedFilterForm extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PreferenceOptionRepository $preferenceOptionRepository,
|
||||
) {}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$this->addChoiceField($builder, 'language', CountryLanguages::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'quality', QualityList::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'provider', ProviderList::asSelectOptions());
|
||||
$this->addChoiceField($builder, 'resolution', $this->getPreferenceChoices('resolution'));
|
||||
$this->addChoiceField($builder, 'codec', $this->getPreferenceChoices('codec'));
|
||||
}
|
||||
|
||||
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void
|
||||
{
|
||||
$question = [
|
||||
'attr' => ['class' => 'w-full text-input mb-4'],
|
||||
'label_attr' => ['class' => 'w-full block font-semibold'],
|
||||
'choices' => $this->addDefaultChoice($choices),
|
||||
];
|
||||
$builder->add($fieldName, ChoiceType::class, $question);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
|
||||
private function getPreferenceChoices(string $preference): array
|
||||
{
|
||||
$options = $this->preferenceOptionRepository->findBy(['preference' => $preference]);
|
||||
$result = [];
|
||||
foreach ($options as $item) {
|
||||
$result[$item->getName()] = $item->getId();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function addDefaultChoice(array $choices): iterable
|
||||
{
|
||||
return ['n/a' => null] + $choices;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class RegistrationFormType extends AbstractType
|
||||
'message' => 'Please enter a password',
|
||||
]),
|
||||
new Length([
|
||||
'min' => 6,
|
||||
'min' => 8,
|
||||
'minMessage' => 'Your password should be at least {{ limit }} characters',
|
||||
// max length allowed by Symfony for security reasons
|
||||
'max' => 4096,
|
||||
|
||||
31
src/User/Framework/Form/ResetPasswordRequestForm.php
Normal file
31
src/User/Framework/Form/ResetPasswordRequestForm.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
|
||||
class ResetPasswordRequestForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('email', EmailType::class, [
|
||||
'attr' => ['autocomplete' => 'email'],
|
||||
'constraints' => [
|
||||
new NotBlank([
|
||||
'message' => 'Please enter your email',
|
||||
]),
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Repository;
|
||||
|
||||
use App\User\Framework\Entity\ResetPasswordRequest;
|
||||
use App\User\Framework\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
|
||||
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ResetPasswordRequest>
|
||||
*/
|
||||
class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
|
||||
{
|
||||
use ResetPasswordRequestRepositoryTrait;
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ResetPasswordRequest::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
*/
|
||||
public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface
|
||||
{
|
||||
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
|
||||
}
|
||||
}
|
||||
57
src/User/Framework/Security/OidcUserProvider.php
Normal file
57
src/User/Framework/Security/OidcUserProvider.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\User\Framework\Security;
|
||||
|
||||
use App\User\Framework\Entity\User;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use Drenso\OidcBundle\Exception\OidcException;
|
||||
use Drenso\OidcBundle\Model\OidcTokens;
|
||||
use Drenso\OidcBundle\Model\OidcUserData;
|
||||
use Drenso\OidcBundle\Security\UserProvider\OidcUserProviderInterface;
|
||||
use Symfony\Component\PasswordHasher\PasswordHasherInterface;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
||||
use Symfony\Component\Security\Core\User\OidcUser;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class OidcUserProvider implements OidcUserProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
public function ensureUserExists(string $userIdentifier, OidcUserData $userData, OidcTokens $tokens): void
|
||||
{
|
||||
$user = $this->userRepository->findOneBy(['email' => $userIdentifier]);
|
||||
|
||||
if (null === $user) {
|
||||
$user = new User()
|
||||
->setEmail(!empty($userData->getEmail()) ? $userData->getEmail() : $userData->getSub())
|
||||
->setName(!empty($userData->getFullName()) ? $userData->getFullName() : $userData->getGivenName())
|
||||
->setPassword('n/a')
|
||||
;
|
||||
$this->userRepository->getEntityManager()->persist($user);
|
||||
$this->userRepository->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadOidcUser(string $userIdentifier): UserInterface
|
||||
{
|
||||
return $this->userRepository->findOneBy(['email' => $userIdentifier]);
|
||||
}
|
||||
|
||||
public function refreshUser(UserInterface $user): UserInterface
|
||||
{
|
||||
return $this->userRepository->findOneBy(['email' => $user->getUserIdentifier()]);
|
||||
}
|
||||
|
||||
public function supportsClass(string $class): bool
|
||||
{
|
||||
return User::class === $class || OidcUser::class === $class;
|
||||
}
|
||||
|
||||
public function loadUserByIdentifier(string $identifier): UserInterface
|
||||
{
|
||||
return $this->userRepository->findOneBy(['email' => $identifier]);
|
||||
}
|
||||
}
|
||||
36
symfony.lock
36
symfony.lock
@@ -50,6 +50,18 @@
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"drenso/symfony-oidc-bundle": {
|
||||
"version": "4.2",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "2.0",
|
||||
"ref": "e2b975158d940a191f48e3ff2c59108a1d7225e6"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/drenso_oidc.yaml"
|
||||
]
|
||||
},
|
||||
"php-http/discovery": {
|
||||
"version": "1.20",
|
||||
"recipe": {
|
||||
@@ -157,6 +169,18 @@
|
||||
"src/Kernel.php"
|
||||
]
|
||||
},
|
||||
"symfony/mailer": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "4.3",
|
||||
"ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/mailer.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/maker-bundle": {
|
||||
"version": "1.62",
|
||||
"recipe": {
|
||||
@@ -363,6 +387,18 @@
|
||||
"config/routes/web_profiler.yaml"
|
||||
]
|
||||
},
|
||||
"symfonycasts/reset-password-bundle": {
|
||||
"version": "1.23",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "97c1627c0384534997ae1047b93be517ca16de43"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/reset_password.yaml"
|
||||
]
|
||||
},
|
||||
"symfonycasts/tailwind-bundle": {
|
||||
"version": "0.10",
|
||||
"recipe": {
|
||||
|
||||
@@ -32,7 +32,8 @@ module.exports = {
|
||||
"truncate",
|
||||
"text-wrap",
|
||||
"rounded-sm",
|
||||
"rounded-md"
|
||||
"rounded-md",
|
||||
"r-tablecell",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
@@ -15,8 +15,12 @@
|
||||
</head>
|
||||
<body class="bg-cyan-950 flex flex-col h-full">
|
||||
<h1 class="px-4 py-4 text-3xl font-extrabold text-orange-500">Torsearch</h1>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<div class="p-4 flex flex-col justify-center items-center">
|
||||
{% block body %}{% endblock %}
|
||||
<div class="mt-2 inline-flex gap-4 justify-between text-white">
|
||||
<a class="text-sm" href="{{ path('app_login') }}">Sign In</a>
|
||||
<span class="text-sm">v{{ version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
</div>
|
||||
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
|
||||
<twig:Header />
|
||||
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
|
||||
<div class="flex mt-4 gap-2 items-center grow-0 md:px-4">
|
||||
{% block action_buttons %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
13
templates/components/ActionButton.html.twig
Normal file
13
templates/components/ActionButton.html.twig
Normal file
@@ -0,0 +1,13 @@
|
||||
<button
|
||||
class="h-6 bg-{{ color|default('orange') }}-500/80 hover:bg-{{ color|default('orange') }}-600/80 px-2 text-{{ text_color|default('white') }} rounded-ms text-sm font-semibold"
|
||||
|
||||
{% if custom_controller|default and custom_action|default %}
|
||||
{{ attributes.defaults(stimulus_controller(custom_controller, custom_controller_vars|default({}))) }}
|
||||
{{ stimulus_action(custom_controller, custom_action|default('default'), custom_action_event|default('click'), custom_action_params|default({})) }}
|
||||
{% else %}
|
||||
{{ attributes.defaults(stimulus_controller('action_button')) }}
|
||||
{{ stimulus_action('action_button', action|default('default')) }}
|
||||
{% endif %}
|
||||
>
|
||||
{{ text|default('button') }}
|
||||
</button>
|
||||
@@ -1,4 +1,4 @@
|
||||
<div{{ attributes.defaults(stimulus_controller('download_list')) }} class="min-w-48" >
|
||||
<div{{ attributes.defaults(stimulus_controller('download_list')) }} class="min-w-48 overflow-scroll" >
|
||||
{% set table_body_id = (type == "complete") ? "complete_downloads" : "active_downloads" %}
|
||||
|
||||
{% if this.isWidget == false %}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
{{ download.title }}
|
||||
</a>
|
||||
|
||||
{% if download.mediaType == "tvshows" %}
|
||||
— <span class="ml-1">(S{{ download.ptn.season }}E{{ download.ptn.episode }})</span>
|
||||
{% if download.mediaType == "tvshows" and download.episodeId != null %}
|
||||
— <span class="ml-1">(S{{ download.episodeId }})</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||
style="width:{{ download.progress }}%">
|
||||
</div>
|
||||
<div class="absolute text-black text-center" style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">{{ download.progress }}%</div>
|
||||
<div class="text-black text-center" style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">{{ download.progress }}%</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<twig:StatusBadge color="green" status="Complete" />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div id="filter" class="flex flex-col gap-4"
|
||||
{{ stimulus_controller('result_filter', {reverseMappedQualities: this.reverseMappedQualities}) }}
|
||||
{{ stimulus_controller('result_filter', {reverseMappedQualities: this.reverseMappedQualities, imdbId: results.media.imdbId}) }}
|
||||
data-result-filter-media-type-value="{{ results.media.mediaType }}"
|
||||
data-result-filter-movie-results-outlet=".results"
|
||||
data-result-filter-tv-results-outlet=".results"
|
||||
data-result-filter-tv-episode-list-outlet=".episode-list"
|
||||
data-action="change->result-filter#filter movie-results:optionsLoaded@window->result-filter#loadOptions tv-results:optionsLoaded@window->result-filter#loadOptions"
|
||||
data-action="change->result-filter#filter movie-results:optionsLoaded@window->result-filter#loadOptions tv-results:optionsLoaded@window->result-filter#loadOptions action-button:downloadSeason@window->result-filter#downloadSeason"
|
||||
>
|
||||
<div class="w-full p-4 flex flex-col md:flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
|
||||
<label for="resolution">
|
||||
@@ -94,10 +94,19 @@
|
||||
|
||||
{% if results.media.mediaType == "tvshows" %}
|
||||
<div class="flex flex-row gap-2 justify-end px-8">
|
||||
<button class="px-1.5 py-1 bg-green-600 hover:bg-green-700 rounded-md text-sm"
|
||||
<twig:Modal heading="Back up a sec!" button_text="Download Season" submit_action="{{ stimulus_action('result_filter', 'downloadSeason', 'click')|stimulus_action('dialog', 'close') }}" button_class="px-1.5 py-1 bg-green-600 rounded-ms text-sm font-semibold" show_cancel show_submit>
|
||||
Downloading an entire season this way will use the filter from your
|
||||
<a href="{{ path('app_user_preferences') }}" class="text-underline">preferences</a> to choose
|
||||
the appropriate file(s).
|
||||
<br /><br />
|
||||
Do you wish to download <strong>season <span id="downloadSeasonModal">{{ results.season }}</span></strong> of "<strong>{{ results.media.title }}</strong>"?
|
||||
</twig:Modal>
|
||||
|
||||
<button class="px-1.5 py-1 bg-green-600 hover:bg-green-700 rounded-ms text-sm font-semibold"
|
||||
{{ stimulus_target('result_filter', 'downloadSelected') }}
|
||||
{{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }}
|
||||
>Download Selected</button>
|
||||
|
||||
<input type="checkbox" name="selectAll" id="selectAll"
|
||||
{{ stimulus_target('result_filter', 'selectAll') }}
|
||||
{{ stimulus_action('result_filter', 'selectAllEpisodes', 'change') }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div{{ attributes }} data-controller="dialog" data-action="click->dialog#backdropClose" class="flex flex-row items-center">
|
||||
<div{{ attributes }} data-controller="dialog" data-action="click->dialog#backdropClose" class="modal flex flex-row items-center {{ container_class|default('') }}">
|
||||
<dialog data-dialog-target="dialog" class="py-3 px-4 w-[30rem] rounded-md">
|
||||
<h2 class="mb-4 text-2xl font-bold">{{ heading }}</h2>
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
{% endif %}
|
||||
</dialog>
|
||||
|
||||
<button type="button" data-action="dialog#open">{{ button_text|raw }}</button>
|
||||
<button type="button" class="{{ button_class|default('') }}" data-action="dialog#open">{{ button_text|raw }}</button>
|
||||
</div>
|
||||
@@ -1,4 +1,4 @@
|
||||
<div{{ attributes.defaults(stimulus_controller('monitor_list')) }}>
|
||||
<div{{ attributes.defaults(stimulus_controller('monitor_list')) }} class="overflow-scroll">
|
||||
{% if this.isWidget == false %}
|
||||
<div class="flex flex-row mb-2 justify-end">
|
||||
<twig:DownloadSearch search_path="app_search" placeholder="Find {{ type == "complete" ? "a" : "an" }} {{ type }} monitor..." />
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
mediaType: mediaType,
|
||||
imdbId: imdbId
|
||||
}) }}">
|
||||
<h3 class="text-center text-white text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3>
|
||||
<h3 class="text-center text-white md:text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,31 @@
|
||||
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Air date {{ episode['name'] }}">
|
||||
{{ episode['air_date']|date(null, 'UTC') }}
|
||||
</small>
|
||||
|
||||
{% if episode['file'] != false %}
|
||||
<span data-controller="popover">
|
||||
<template data-popover-target="content">
|
||||
<div data-popover-target="card" class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
|
||||
<p class="font-bold text-sm text-left">Existing file(s) for this episode:</p>
|
||||
<ul class="list-disc ml-3">
|
||||
<li class="font-normal">{{ episode['file'].realPath|strip_media_path }} — <strong>{{ episode['file'].size|filesize }}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<small
|
||||
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
|
||||
data-action="mouseenter->popover#show mouseleave->popover#hide"
|
||||
>
|
||||
exists
|
||||
</small>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if episode['file'] == false %}
|
||||
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
|
||||
missing
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 justify-between">
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</twig:Card>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<twig:Card title="Popular Movies" contentClass="flex flex-col gap-4 md:flex-row md:justify-between w-full">
|
||||
<twig:Card title="Popular Movies" contentClass="grid grid-cols-2 gap-4 md:flex md:flex-row md:justify-between w-full">
|
||||
{% for movie in popular_movies %}
|
||||
<twig:Poster imdbId="{{ movie.imdbId }}"
|
||||
tmdbId="{{ movie.tmdbId }}"
|
||||
@@ -32,7 +32,7 @@
|
||||
/>
|
||||
{% endfor %}
|
||||
</twig:Card>
|
||||
<twig:Card title="Popular TV Shows" contentClass="flex flex-col md:flex-row justify-between w-full">
|
||||
<twig:Card title="Popular TV Shows" contentClass="grid grid-cols-2 gap-4 md:flex flex-col md:flex-row justify-between w-full">
|
||||
{% for movie in popular_tvshows %}
|
||||
<twig:Poster imdbId="{{ movie.imdbId }}"
|
||||
tmdbId="{{ movie.tmdbId }}"
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
{% block title %}Monitors — Torsearch{% endblock %}
|
||||
{% block h2 %}Monitors{% endblock %}
|
||||
|
||||
{% block action_buttons %}
|
||||
<twig:ActionButton action="monitorDispatch" text="Run Monitors" />
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="px-4 py-2">
|
||||
<twig:Card title="Active Monitors">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="p-4 flex flex-col grow gap-4">
|
||||
<h2 class="mb-2 text-3xl font-bold text-gray-50">Media Results</h2>
|
||||
<div class="flex flex-row w-full gap-2">
|
||||
<twig:Card title="" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
|
||||
<twig:Card title="" class="w-full" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
|
||||
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6">
|
||||
{% if results.media.poster != null %}
|
||||
<img class="w-full md:w-40 rounded-lg" src="{{ results.media.poster }}" />
|
||||
@@ -22,87 +22,30 @@
|
||||
{{ results.media.title }} - {{ results.media.year }}
|
||||
</h3>
|
||||
|
||||
{# <div data-controller="dropdown" class="relative"#}
|
||||
{# {{ stimulus_controller('monitor_button', {#}
|
||||
{# tmdbId: results.media.tmdbId,#}
|
||||
{# imdbId: results.media.imdbId,#}
|
||||
{# title: results.media.title,#}
|
||||
{# })}}#}
|
||||
{# data-monitor-button-result-filter-outlet="#filter"#}
|
||||
{# >#}
|
||||
{# <button type="button" data-action="dropdown#toggle click@window->dropdown#hide"#}
|
||||
{# class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm#}
|
||||
{# px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2#}
|
||||
{# border-green-500">#}
|
||||
{# Monitor#}
|
||||
{# <svg class="w-2.5 h-2.5 ms-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">#}
|
||||
{# <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" /></svg>#}
|
||||
{# </svg>#}
|
||||
{# </button>#}
|
||||
|
||||
{# <div#}
|
||||
{# data-dropdown-target="menu"#}
|
||||
{# class="hidden transition transform origin-top-right absolute right-0#}
|
||||
{# flex flex-col rounded-md shadow-sm w-44 bg-green-800 border-2 border-green-500 mt-1"#}
|
||||
{# data-transition-enter-from="opacity-0 scale-95"#}
|
||||
{# data-transition-enter-to="opacity-100 scale-100"#}
|
||||
{# data-transition-leave-from="opacity-100 scale-100"#}
|
||||
{# data-transition-leave-to="opacity-0 scale-95"#}
|
||||
{# >#}
|
||||
{# <a href="#"#}
|
||||
{# data-action="dropdown#toggle"#}
|
||||
{# class="backdrop-filter p-2 bg-opacity-100 hover:bg-green-950 rounded-t-md"#}
|
||||
{# >#}
|
||||
{# Entire Series#}
|
||||
{# </a>#}
|
||||
{# <a href="#"#}
|
||||
{# data-action="dropdown#toggle"#}
|
||||
{# class="backdrop-filter p-2 bg-opacity-100 hover:bg-green-950 rounded-b-md"#}
|
||||
{# >#}
|
||||
{# Season#}
|
||||
{# </a>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
|
||||
|
||||
{% if results.media.mediaType == "tvshows" %}
|
||||
<div {{ stimulus_controller('monitor_button', {
|
||||
tmdbId: results.media.tmdbId,
|
||||
imdbId: results.media.imdbId,
|
||||
title: results.media.title,
|
||||
})}}
|
||||
data-monitor-button-result-filter-outlet="#filter"
|
||||
data-monitor-button-dialog-outlet=".monitor-modal"
|
||||
>
|
||||
<button data-monitor-button-target="button" {{ stimulus_action('monitor_button', 'toggle', 'click') }}
|
||||
class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm
|
||||
px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2
|
||||
border-green-500"
|
||||
type="button"
|
||||
<twig:Modal
|
||||
unique_class="monitor-modal"
|
||||
button_class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm
|
||||
px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2
|
||||
border-green-500"
|
||||
container_class="monitor-modal"
|
||||
heading="'Hol Up!" button_text="Monitor" submit_action="{{ stimulus_action('monitor_button', 'monitorSeries', 'click') }}" show_cancel show_submit
|
||||
>
|
||||
Monitor
|
||||
<svg class="w-2.5 h-2.5 ms-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div data-monitor-button-target="options"
|
||||
class="absolute mt-1 right-12 z-40 hidden divide-y rounded-md shadow-sm
|
||||
w-44 bg-green-800 backdrop-filter bg-opacity-100 border-2 border-green-500"
|
||||
>
|
||||
<ul class="py-2 text-sm text-gray-100" aria-labelledby="dropdownDefaultButton">
|
||||
<li {{ stimulus_action('monitor_button', 'monitorSeries', 'click') }}>
|
||||
<button class="px-4 py-2 hover:bg-green-950 w-full text-left">
|
||||
Entire Series
|
||||
</button>
|
||||
</li>
|
||||
<li {{ stimulus_action('monitor_button', 'monitorSeason', 'click') }}>
|
||||
<button class="px-4 py-2 hover:bg-green-950 w-full text-left">
|
||||
Season
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
Monitoring a series will continuously search for new episodes and attempt to automatically download them. Your download preferences
|
||||
will be used to choose the correct file. To stop monitoring for new episodes, delete the monitor.
|
||||
<br /><br />
|
||||
Would you like to add a new monitor for "{{ results.media.title }}"?
|
||||
</twig:Modal>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% if results.file != false %}
|
||||
<div class="p-3 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
|
||||
<p class="font-bold text-sm text-left">Existing file(s) for this movie:</p>
|
||||
<ul class="list-disc ml-3">
|
||||
<ul class="list-disc ml-3 overflow-scroll">
|
||||
<li class="font-normal">{{ results.file.realPath|strip_media_path }} — <strong>{{ results.file.size|filesize }}</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
>
|
||||
<thead class="text-xs text-gray-700 uppercase dark:text-gray-400">
|
||||
{% for result in results.results %}
|
||||
<tr class="dark:bg-stone-600 overflow-hidden flex flex-col md:flex-col flex-no wrap md:table-row border-b border-gray-500">
|
||||
<tr data-option-id="{{ result.localId }}" class="dark:bg-stone-600 overflow-hidden flex flex-col md:flex-col flex-no wrap md:table-row border-b border-gray-500">
|
||||
<th scope="col"
|
||||
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
|
||||
Size
|
||||
@@ -41,7 +41,7 @@
|
||||
</thead>
|
||||
<tbody class="flex-1 sm:flex-none">
|
||||
{% for result in results.results %}
|
||||
<tr class="bg-white dark:bg-slate-700 flex flex-col flex-no wrap r-tablerow border-b border-gray-500" data-provider="{{ result.provider }}" data-quality="{{ result.quality }}" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }}"{% endif %}>
|
||||
<tr class="bg-white dark:bg-slate-700 flex flex-col flex-no wrap r-tablerow border-b border-gray-500" data-local-id="{{ result.localId }}" data-provider="{{ result.provider }}" data-quality="{{ result.quality }}" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }}"{% endif %}>
|
||||
<td id="size" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
|
||||
{{ result.size }}
|
||||
</td>
|
||||
|
||||
20
templates/user/getting_started/filter.html.twig
Normal file
20
templates/user/getting_started/filter.html.twig
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends 'bare.html.twig' %}
|
||||
|
||||
{% block title %}Getting Started — Torsearch{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
|
||||
<h2 class="text-2xl text-bold text-center text-gray-50">Getting Started</h2>
|
||||
<p class="mb-1">Now let's create your first Filter.</p>
|
||||
{# <p class="mb-2 text-sm">Your filter will be pre-applied to your results, so you're only shown what you want to see. Don't worry, though, you can toggle each filter option afterwards, so you can see the rest of the results.</p>#}
|
||||
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.language) }}
|
||||
{{ form_row(form.quality) }}
|
||||
{{ form_row(form.provider) }}
|
||||
{{ form_row(form.resolution) }}
|
||||
{{ form_row(form.codec) }}
|
||||
<button class="submit-button">Save</button>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Getting Started — Torsearch{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 min-w-96 border-orange-500 border-2 text-gray-50">
|
||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
|
||||
<h2 class="text-2xl text-bold text-center text-gray-50">Getting Started</h2>
|
||||
<p class="mb-2">Let's get started by creating your first User.</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Log in — Torsearch{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 min-w-96 border-orange-500 border-2 text-gray-50">
|
||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
|
||||
<h2 class="text-xl font-bold">Login</h2>
|
||||
<form method="post" class="flex flex-col gap-2">
|
||||
{% if error %}
|
||||
@@ -40,14 +40,28 @@
|
||||
</label>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" data-controller="csrf-protection">
|
||||
|
||||
<div class="mb-2">
|
||||
<input type="checkbox" name="_remember_me" id="_remember_me">
|
||||
<label for="_remember_me">Remember me</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 flex flex-row justify-between">
|
||||
<div>
|
||||
<input type="checkbox" name="_remember_me" id="_remember_me">
|
||||
<label for="_remember_me">Remember me</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button type="submit" class="bg-green-600/40 px-1.5 py-1 w-full rounded-md text-gray-50 backdrop-filter backdrop-blur-sm border-2 border-green-500 hover:bg-green-700/40">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if show_oidc_button == "oidc" %}
|
||||
<a href="{{ path('app_login_oidc') }}" class="bg-sky-950/60 px-1.5 py-1 w-full rounded-md text-gray-50 text-center backdrop-filter backdrop-blur-sm border-2 border-gray-950 hover:bg-orange-700/40">
|
||||
Sign in with OIDC
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex">
|
||||
<a href="{{ path('app_forgot_password_request') }}">Forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
21
templates/user/reset_password/check_email.html.twig
Normal file
21
templates/user/reset_password/check_email.html.twig
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends 'bare.html.twig' %}
|
||||
|
||||
{% block title %}Password Reset Email Sent — Torsearch{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50">
|
||||
<h2 class="text-xl font-bold">Head over to your email</h2>
|
||||
|
||||
<div class="mb-3 flex flex-col gap-4">
|
||||
<p>
|
||||
If an account matching your email exists, then an email was just sent that contains a
|
||||
link that you can use to reset your password. This link will expire in
|
||||
{{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.
|
||||
</p>
|
||||
<p>
|
||||
If you don't receive an email please check your spam folder or
|
||||
<a href="{{ path('app_forgot_password_request') }}">try again</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
9
templates/user/reset_password/email.html.twig
Normal file
9
templates/user/reset_password/email.html.twig
Normal file
@@ -0,0 +1,9 @@
|
||||
<h1>Hi!</h1>
|
||||
|
||||
<p>To reset your password, please visit the following link</p>
|
||||
|
||||
<a href="{{ url('app_reset_password', {token: resetToken.token}) }}">{{ url('app_reset_password', {token: resetToken.token}) }}</a>
|
||||
|
||||
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p>
|
||||
|
||||
<p>Cheers!</p>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user