Compare commits

...

201 Commits

Author SHA1 Message Date
9db0bfd4c6 fix: filter style tweaks, loading icon patch 2025-07-26 17:39:28 -05:00
18a165fc40 wip: filter uses form object 2025-07-26 15:46:43 -05:00
0e13b74b3b fix: multi-choice filter styles 2025-07-26 14:16:12 -05:00
f9ec089f8b fix: working multi-choice filtering 2025-07-26 10:20:37 -05:00
87e72ec55e fix: adds ImdbMatcher 2025-07-25 21:35:29 -05:00
23a88ec6bb fix: search by imdb id 2025-07-24 23:31:03 -05:00
d33a961f2d fix: codec filter 2025-07-24 17:09:30 -05:00
566886ef0e feat: uses web components to simplify javascript logic 2025-07-24 17:05:19 -05:00
65acd5d21b wip: working 'download selected' button 2025-07-24 16:47:08 -05:00
a27fcf334a wip: download single episode 2025-07-24 16:24:21 -05:00
56c5156380 wip: working movies & tvshows w/ filtering 2025-07-24 15:52:42 -05:00
18b00fc5ae fix: performs filtering in web component 2025-07-24 11:54:28 -05:00
e39faa3398 wip: working episode-container 2025-07-24 11:16:22 -05:00
2a9bacea8c fix: removes preference options db table 2025-07-24 00:02:38 -05:00
0988517bd0 fix: separates preference options from db to files 2025-07-23 23:19:19 -05:00
d1ae26db45 fix: ajax form submit, alert render on mobile 2025-07-23 22:48:36 -05:00
75e9c1e8c3 fix: separates get/post routes 2025-07-23 21:37:31 -05:00
93f5b716b2 fix: creates form for user media preferences 2025-07-23 21:17:18 -05:00
8ff9cddbb0 fix: duplicate z-index 2025-07-23 09:44:51 -05:00
b0d7bfefd7 fix: animates mobile menu transition 2025-07-23 09:40:05 -05:00
df4bb3b736 fix: progress bar alignment 2025-07-23 08:36:57 -05:00
265d782f99 fix: deep links from monitors 2025-07-22 23:01:52 -05:00
dc9242d96e fix: links to episodes from downloads 2025-07-22 22:49:07 -05:00
24355a4b30 fix: supports linking directly to tv season 2025-07-20 10:26:12 -05:00
3b0ba81ce3 fix: bad type checking in media file dto 2025-07-17 22:17:55 -05:00
dc2845d74d fix: bad caching causing turbo frames not to reload properly 2025-07-16 20:11:30 -05:00
5e722dcbc7 fix: deletes media file when download deleted 2025-07-15 23:53:19 -05:00
a126871af8 fix: allows local or queued downloads 2025-07-15 10:29:32 -05:00
70f551cea9 feat: command to download media 2025-07-15 10:17:42 -05:00
4824c2d344 fix: adds pull-to-refresh to pwa 2025-07-14 21:11:57 -05:00
c09c7ad030 chore: remove unused code 2025-07-14 20:55:47 -05:00
f610297294 fix: turbo frame for tvshow results 2025-07-14 20:54:12 -05:00
f2971eee9c wip: returns movie results in turbo frames 2025-07-14 16:20:36 -05:00
47108af1f8 fix: pre-loads media postes 2025-07-14 15:32:08 -05:00
f7163b5e00 fix: typo in download list season id 2025-07-13 22:42:39 -05:00
31e364d691 fix: renders media exists badge on movie results 2025-07-13 22:21:04 -05:00
b42981b2a1 chore: cleanup 2025-07-13 21:27:54 -05:00
accfa9c9bf fix: missing/exists badge on tvshows results 2025-07-13 21:13:42 -05:00
8b50b50466 fix: dev env tweaks 2025-07-12 14:30:33 -05:00
e38498f69b fix: adds basic auth 2025-07-12 08:33:59 -05:00
490f341875 fix: increase font weight of mobile 'T' logo 2025-07-12 00:26:43 -05:00
b1b28864ea fix: manifest colors 2025-07-11 23:30:15 -05:00
891ce81902 feat: basic pwa 2025-07-11 23:14:36 -05:00
b7d7025114 feat: adds step to define filter in getting started process 2025-07-11 22:12:11 -05:00
41114446d0 chore: code reorganization 2025-07-11 19:05:50 -05:00
592e02484e fix: docs 2025-07-11 16:38:49 -05:00
bd9fde94d1 fix: updates example compose 2025-07-11 16:37:36 -05:00
d0b2852de5 fix: blocks pw resets when auth method = oidc 2025-07-11 15:58:45 -05:00
2fae99e24b fix: creates new users on demand from idp 2025-07-11 15:40:19 -05:00
b74b563c56 wip: adds config options for oidc 2025-07-11 12:30:56 -05:00
04993ebb27 wip: working oidc login 2025-07-11 11:27:34 -05:00
db521ad9a9 fix: style tweaks 2025-07-10 13:40:31 -05:00
6a7474173e fix: update reset password controller to use smtp settings from config 2025-07-10 12:16:01 -05:00
9f38429c2a feat: adds command to rest user password 2025-07-10 11:32:53 -05:00
9fd6745125 chore: adds descriptions to command 2025-07-10 10:39:32 -05:00
60376ca0a2 chore: adds description to command 2025-07-10 10:35:43 -05:00
6f1f1032f6 fix: standardizes styles of the 'bare' template for pre-authenticated pages 2025-07-10 10:32:38 -05:00
c6e98eff4c fix: puts posters in 2 columns on mobile 2025-07-09 23:43:54 -05:00
cff0d5234e feat: password reset 2025-07-09 23:14:46 -05:00
d2e7650b6c fix: episode id not being added when monitor downloads episode 2025-07-09 18:15:55 -05:00
bb6dcdef30 wip: configures mailer 2025-07-09 14:13:53 -05:00
b5526dc2dd fix: poorly styled table 2025-07-09 14:13:27 -05:00
3959696b66 fix: uses episode id from database in download list row 2025-07-09 13:17:10 -05:00
7353806915 fix: wrong season listed in download season modal 2025-07-09 13:06:14 -05:00
42e232bef3 fix: example docker compose 2025-07-09 12:15:30 -05:00
45b484d44c fix: removes 'complete' as a status to query for when looking for existing monitors 2025-07-09 11:49:47 -05:00
dd52a903f6 fix: prepends episode id to tvshow files that don't include it 2025-07-09 11:47:20 -05:00
5729949774 fix: extra table headers in mobile option rows 2025-07-09 00:04:46 -05:00
e055ed0c15 fix: horizontal scroll to existing files on movies 2025-07-08 23:46:11 -05:00
46d90e800c fix: user identifier, horizontal scroll on tables 2025-07-08 23:22:13 -05:00
9fb513bfbd fix: last update broken movie results 2025-07-08 19:34:29 -05:00
2384bb2414 fix: adds missing indicator to tv episode results 2025-07-08 19:28:14 -05:00
7c8fa0c439 fix: migration 2025-07-08 18:47:10 -05:00
97aa8d8982 fix: migrations 2025-07-08 16:43:37 -05:00
a88720fe7e fix: monitor not increasing count after error 2025-07-08 16:26:07 -05:00
8a12303470 fix: download season logging 2025-07-08 16:22:16 -05:00
13b9047841 wip: downloads entire season 2025-07-08 16:17:21 -05:00
8c0ec98c20 fix: database seeder didn't update existing records 2025-07-08 11:36:18 -05:00
2c9138290a wip: adds download season button/modal 2025-07-07 21:58:37 -05:00
c1a6cddb8f fix: action button size 2025-07-07 16:35:18 -05:00
64d3fbbddb fix: forces results card to full screen width 2025-07-07 16:15:19 -05:00
32389cb27a fix: adds action button to manually run monitors 2025-07-07 16:12:21 -05:00
5e48fdb978 fix: removes monitor button for movies 2025-07-07 15:06:26 -05:00
5f54e48b3f fix: adds modal for adding new monitor 2025-07-07 15:04:20 -05:00
073a37c080 fix: monitor logging 2025-07-07 14:08:42 -05:00
3fe28c74a1 fix: episode air date showing 1 day behind 2025-07-07 12:40:29 -05:00
5c5fa8fde2 fix: displays warning if reald debrid or tmdb keys are missing 2025-07-07 00:14:22 -05:00
8fa06d4462 chore: moves search controller to search module 2025-07-06 22:56:47 -05:00
1fc5a8e500 chore: moves common code to Base namespace 2025-07-06 22:53:13 -05:00
a0050e425b feat: adds quality profile 2025-07-06 19:49:26 -05:00
791af9c9e7 fix: works with tv & movies 2025-07-06 19:26:33 -05:00
e54bcd44d8 wip: filters movie results, adds options to filter input 2025-07-06 15:37:29 -05:00
402d513147 fix(styles): turns h1 into link to dashboard, removes console.logs 2025-07-06 13:22:23 -05:00
d2de374f57 fix(nav): adds margin to h1 heading on mobile so its not behind search bar 2025-07-06 13:03:51 -05:00
9a1847a2c3 fix: allows normal search alongside autocomplete 2025-07-06 12:41:56 -05:00
17f6316353 fix: better styles for active option 2025-07-06 12:19:37 -05:00
cc366eb09f fix: moves tmdb search under tmdb namespace 2025-07-06 11:07:11 -05:00
b0425f7085 fix: styles results, updates loader 2025-07-06 10:05:11 -05:00
023b1b7844 fix: redirects user on selection 2025-07-06 09:31:47 -05:00
eafcf3fcb1 wip: renders live search results 2025-07-06 09:07:51 -05:00
25f803d1dd fix: styles 2025-07-05 14:43:39 -05:00
98041fd20b fix: result filter not filtering 2025-07-05 13:58:00 -05:00
d29b84ec78 fix: better logging for monitor cleanup 2025-07-04 20:54:49 -05:00
ccce0303c3 fix: better logging for monitor cleanup 2025-07-04 15:53:13 -05:00
9eaa120257 fix: stuck monitors 2025-07-04 15:15:09 -05:00
d6cbb53da6 feat: adds torrentio api endpoint 2025-07-04 14:57:39 -05:00
bd47107399 fix: uses parent imdb id if episode id doesn't exist 2025-07-02 16:10:53 -05:00
ac97fdd08f fix: adds r-tablecell class 2025-07-01 23:03:15 -05:00
727c11e1c6 fix: makes user preferences page responsive 2025-06-30 09:16:33 -05:00
be65e2d4e2 fix: makes download list & monitor list responsive 2025-06-30 09:13:49 -05:00
497a3a74cd fix: basic hamburger menu button 2025-06-29 23:19:16 -05:00
101460cd47 fix: mobile results formatting 2025-06-29 22:26:01 -05:00
591c9cdd2a wip: mobile template 2025-06-29 16:01:56 -05:00
2dc53c5270 feat: tags torrentio cache 2025-06-22 22:08:08 -05:00
5938d33c89 fix: alert topic id not being set after getting started controller 2025-06-22 16:40:43 -05:00
d95ab85415 fix: udpates example compose 2025-06-22 16:21:04 -05:00
cc39e46bfd fix: broken delete monitor 2025-06-22 15:50:09 -05:00
ba092ab3c2 chore: remove line 2025-06-22 15:12:37 -05:00
774d6f4999 fix: caches tmdb data 2025-06-22 14:06:01 -05:00
0b56ee937d fix: removes upcoming episodes component from monitors page 2025-06-22 10:08:23 -05:00
7b3d57b94a fix: prepends episode/season number to filename if doesn't exist. should fix monitors repeatedly downloading episodes 2025-06-21 23:26:55 -05:00
be7b610111 fix: monitor tv -> set monitor create date time to 00:00.00 for comparison to air date 2025-06-20 11:46:11 -05:00
3e93a7c9c1 fix: un-hardcodes version 2025-06-20 11:29:50 -05:00
bc78b83f8d fix: includes version number in worker & scheduler 2025-06-20 11:06:34 -05:00
c5bcaeb1d4 fix: shows version in nav bar 2025-06-20 11:05:44 -05:00
e39182ba91 fix: defaults episode count to '-' as an indicator to whether the fetch call has run or not. if there are 0 results after the fetch call, the '-' is updated to '0' 2025-06-20 08:37:35 -05:00
965b747594 fix: monitor checks if episode was released after monitor created 2025-06-20 08:11:27 -05:00
937e3c6270 fix: updates monitor to search for episodes that were released after monitor created 2025-06-20 07:34:18 -05:00
2bb2845ead fix: torrentio for movies 2025-06-19 23:32:07 -05:00
fca189648b fix: adds warning for torrentio rate limit 2025-06-19 23:08:08 -05:00
2121466322 wip: gracefully handles torrentio 429 2025-06-19 22:39:41 -05:00
1e130c3490 fix: stores sessions in redis 2025-06-19 19:50:02 -05:00
4b97faeadb fix: error querying tmdb 2025-06-19 19:30:28 -05:00
3701e31ee0 fix: sets default twig date format as m/d/Y 2025-06-19 16:44:20 -05:00
210c674f25 fix: bad date format 2025-06-19 16:41:42 -05:00
175f4330f1 fix: returns episode data on first page load 2025-06-19 16:23:39 -05:00
12aaf8e737 fix: animates episode toggle list button 2025-06-19 14:49:58 -05:00
2e468dd9b0 fix: cleans up paginator 2025-06-19 14:30:26 -05:00
e070b95a36 wip: working episode pagination, season switcher, monitor only new content 2025-06-19 13:30:22 -05:00
20d397589a fix: undoes num_threads 2025-06-14 15:14:53 -05:00
6c7a35005e fix: sets num_threads=10 2025-06-13 23:44:49 -05:00
0f16423f66 feat: upcoming episodes component 2025-06-12 23:39:06 -05:00
937313fe59 fix: links to series from monitor list row 2025-06-12 19:57:39 -05:00
9b3506ab17 fix: adds hover style on monitor list row 2025-06-12 10:48:23 -05:00
6e0eed8b4e fix: adds default timezone, supports TZ environment variable for changing TZ, renders dates based on TZ 2025-06-12 10:45:28 -05:00
38a5baa17e fix: sets tv show/season monitor status to Active after executing 2025-06-11 20:06:49 -05:00
1d573c09e7 fix: media files -> episodeExists() 2025-06-11 19:40:06 -05:00
7989e2abd2 fix: prevents show & season monitors from completing, forcing them to keep checking for new episodes until removed 2025-06-11 13:06:21 -05:00
df6c68aa46 fix: alert z-index, hover effects on download list row 2025-06-11 12:46:03 -05:00
6cd9a9b18e fix: refactors tv season monitor 2025-06-11 10:30:53 -05:00
b95e8f3794 fix: tvshow monitor 2025-06-11 10:24:58 -05:00
8cc81fea19 wip: working tv season & episode monitors 2025-06-10 21:19:13 -05:00
15648e711b fix: rounded-ms class on download search button 2025-06-10 15:30:41 -05:00
f855aabd69 fix: copy update 2025-06-10 15:17:07 -05:00
55a866170e fix: copy update 2025-06-10 15:15:40 -05:00
9ab4f6cf57 fix: adds timestamps to download entity 2025-06-10 15:10:00 -05:00
d40a4764eb fix: adds loading indicator to download search component 2025-06-10 14:26:37 -05:00
0119830ea7 fix: adds hover styles to download action buttons 2025-06-10 13:45:17 -05:00
7ffa927d55 fix: missing session item for mercure alert topic 2025-06-10 13:39:19 -05:00
0b80779975 fix: entrypoint 2025-06-09 21:39:16 -05:00
3c2092095f feat: pauses & resumes downloads 2025-06-09 16:42:30 -05:00
a7bedae3db wip: pauses downloads 2025-06-09 11:26:17 -05:00
51c2a1c577 fix: checks that season & episode properties exist on PTN 2025-06-09 10:53:41 -05:00
f5ed464719 feat: dedicated queue for warming tv options cache 2025-06-08 17:06:58 -05:00
aa31701ac8 feat: speedbump for deleting monitors 2025-06-08 15:46:34 -05:00
781e4dcd23 feat: speedbump before deleting downloads 2025-06-08 15:25:18 -05:00
b5cd240fbd fix: keyboard navigation on tv results 2025-06-08 10:39:46 -05:00
ce5bc525dd feat: adds search to monitors, adds properties to search against on downloads 2025-06-08 08:58:27 -05:00
63850e48fd feat: shows existing files for already downloaded media 2025-06-07 22:19:05 -05:00
f9a284cb67 wip: displays file info for existing tv episodes 2025-06-07 21:36:33 -05:00
228d320edc feat: tv episode - existance indicator badge 2025-06-07 19:19:12 -05:00
6858d2d722 fix: cleans up download list search bar 2025-06-07 15:31:22 -05:00
8cd004db4a fix: hides search bar when download list is widget 2025-06-07 14:43:38 -05:00
6cc8985c4d feat: adds search to download list component 2025-06-07 14:25:56 -05:00
9bfd92a011 feat: links to movies/series download results page from download list component 2025-06-07 13:18:20 -05:00
ac6276f444 fix: links to media by imdb id 2025-06-07 10:13:40 -05:00
6d4bbf2e72 fix: shows season/episode in download list 2025-06-07 09:14:47 -05:00
8004de1dc8 fix: undoes last change to Caddyfile 2025-06-06 14:20:46 -05:00
2d9a2a1f14 fix: sets num_threads=20 2025-06-05 23:36:17 -05:00
b6bc1645b4 fix: centers download progress in progress bar 2025-06-03 21:52:49 -05:00
16023f1a26 fix: uses frankingphp:8.4-alpine image locally 2025-06-03 11:15:01 -05:00
725034dd4e fix: shows download progress 2025-06-03 11:14:38 -05:00
234aeab120 fix: monitor row being styled incorrectly on update 2025-06-02 10:42:56 -05:00
044df00982 fix: broad implementation of broadcaster 2025-06-01 20:34:39 -05:00
92015feaac fix: missing monitor alert 2025-06-01 20:14:34 -05:00
5b704d9b36 fix: missing monitor alert 2025-06-01 20:14:25 -05:00
4f72481829 fix: orders monitor desc 2025-06-01 16:16:01 -05:00
4ee338e397 upgrade to Symfony 7.3, style updates 2025-06-01 15:58:04 -05:00
08d28d9a4f fix: splits monitor widget types to complete and active 2025-06-01 14:32:15 -05:00
b17313c8fb chore: adds 'asPagiator' utility function 2025-06-01 14:13:53 -05:00
f2b50b4f60 fix: uses paginate trait on download list 2025-06-01 14:08:29 -05:00
393e3ef41f fix: monitors page & pagination 2025-06-01 14:07:29 -05:00
b1ccf3bf00 feat: delete monitors 2025-06-01 09:25:12 -05:00
785794790c fix: adds delete button to broadcasted element 2025-05-31 22:46:31 -05:00
c0f67a32ff feat: delete downloads from DB 2025-05-31 22:21:11 -05:00
325e4e14a7 fix: removes default movies & tvshows paths from .env 2025-05-31 11:46:36 -05:00
f9017297f3 feat: adds nomad deployment file 2025-05-28 18:59:32 -05:00
0c4def42a2 fix: proper flag passed to worker 2025-05-26 09:51:31 -05:00
3100912927 fix: disabled caddy worker mode 2025-05-26 09:39:17 -05:00
07553a172f fix: sets limits on worker 2025-05-25 23:30:31 -05:00
a88da4f637 fix(local deploy): uncomments line 2025-05-25 22:52:51 -05:00
245 changed files with 9811 additions and 2297 deletions

14
.env
View File

@@ -39,5 +39,15 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
REDIS_HOST=redis://redis
MOVIES_PATH=/var/download/movies
TVSHOWS_PATH=/var/download/tvshows
###> 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 ###

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ bolt.db
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
.php-cs-fixer.cache

View File

@@ -1,8 +1,9 @@
FROM dunglas/frankenphp
FROM dunglas/frankenphp:php8.4
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV APP_VERSION="0.0.1"
RUN install-php-extensions \
pdo_mysql \
@@ -11,6 +12,8 @@ RUN install-php-extensions \
zip \
opcache
RUN apt update && apt install -y wget
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile
COPY --chmod=0755 docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -6,6 +6,7 @@ import './bootstrap.js';
* which should already be in your base.html.twig.
*/
import './styles/app.css';
import PullToRefresh from 'pulltorefreshjs';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
@@ -18,3 +19,10 @@ var observer = new MutationObserver(function(mutations) {
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});
const ptr = PullToRefresh.init({
mainElement: 'body',
onRefresh() {
window.location.reload();
}
});

16
assets/bootstrap.js vendored
View File

@@ -1,5 +1,19 @@
import EpisodeContainer from './components/episode-container.js';
import DownloadOptionTr from './components/download-option-tr.js';
import MovieContainer from "./components/movie-container.js";
import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover';
import Dialog from '@stimulus-components/dialog';
import Dropdown from '@stimulus-components/dropdown';
import 'animate.css';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
app.register('popover', Popover);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);
customElements.define('episode-container', EpisodeContainer);
customElements.define('movie-container', MovieContainer);
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});

View File

@@ -0,0 +1,187 @@
export default class DownloadOptionTr extends HTMLTableRowElement {
H264_CODECS = {
'h264': 'h264',
'h.264': 'h264',
'x264': 'h264',
}
H265_CODECS = {
'h265': 'h265',
'h.265': 'h265',
'x265': 'h265',
'hevc': 'h265',
}
#downloadBtnEl;
#selectEpisodeInputEl;
url;
size;
quality;
resolution;
codec;
seeders;
provider;
languages;
mediaType;
season;
episode;
filename;
imdbId;
episodeId;
mediaTitle;
constructor() {
super();
this.url = this.getAttribute('url');
this.size = this.getAttribute('size');
this.quality = this.getAttribute('quality');
this.resolution = this.getAttribute('resolution');
this.codec = this.getAttribute('codec');
this.seeders = this.getAttribute('seeders');
this.provider = this.getAttribute('provider');
this.filename = this.getAttribute('filename');
this.imdbId = this.getAttribute('imdb-id');
this.languages = JSON.parse(this.getAttribute('languages'));
this.mediaType = this.getAttribute('media-type');
this.mediaTitle = this.getAttribute('media-title');
this.season = this.getAttribute('season') ?? null;
this.episode = this.getAttribute('episode') ?? null;
this.episodeId = this.getAttribute('episode-id') ?? null;
this.#downloadBtnEl = this.querySelector('.download-btn');
this.#selectEpisodeInputEl = this.querySelector('input[type="checkbox"]');
this.#downloadBtnEl.addEventListener('click', () => this.download());
}
get isSelected() {
return this.#selectEpisodeInputEl.checked;
}
set isSelected(value) {
this.#selectEpisodeInputEl.checked = value;
}
filter({ detail: { activeFilter } }) {
const optionHeader = document.querySelector(`[data-option-id="${this.dataset['localId']}"]`)
let include = true;
this.classList.add('r-tablerow');
this.classList.remove('hidden');
optionHeader.classList.add('r-tablerow');
optionHeader.classList.remove('hidden');
this.querySelector('input[type="checkbox"]').checked = false;
if (!this.#validateResolutions(activeFilter.resolution)) {
include = false;
}
if (!this.#validateCodecs(activeFilter.codec)) {
include = false;
}
if (!this.#validateLanguages(activeFilter.language)) {
include = false;
}
if (!this.#validateQualities(activeFilter.quality)) {
include = false;
}
if (!this.#validateProviders(activeFilter.provider)) {
include = false;
}
if (false === include) {
this.classList.remove('r-tablerow');
this.classList.add('hidden');
optionHeader.classList.remove('r-tablerow');
optionHeader.classList.add('hidden');
}
return include;
}
download() {
fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
url: this.url,
title: this.mediaTitle,
filename: this.filename,
mediaType: this.mediaType,
imdbId: this.imdbId,
episodeId: this.episodeId
})
})
.then(res => res.json())
.then(json => {
console.log(json)
})
}
#validateResolutions(selectedOptions) {
return this.#validateIntersection(selectedOptions, this.resolution.trim().split(','));
}
#validateCodecs(selectedOptions) {
if (this.#validateIntersection(selectedOptions, Object.keys(this.H264_CODECS))) {
return this.#validateIntersection(
selectedOptions,
[...this.codec.trim().split(','), '', 'n/a']
);
}
if (this.#validateIntersection(selectedOptions, Object.keys(this.H265_CODECS))) {
return this.#validateIntersection(
selectedOptions,
[...this.codec.trim().split(','), '', 'n/a']
);
}
return false;
}
#validateQualities(selectedOptions) {
return this.#validateIntersection(selectedOptions, this.quality.trim().split(','));
}
#validateProviders(selectedOptions) {
return this.#validateIntersection(selectedOptions, this.provider.trim().split(','));
}
#validateLanguages(selectedOptions) {
return this.#validateIntersection(selectedOptions, this.languages);
}
#validateIntersection(selectedOptions, localOptions) {
if (selectedOptions === null || selectedOptions === undefined) {
return true;
}
if (typeof selectedOptions === 'string' || selectedOptions instanceof String) {
selectedOptions = [selectedOptions];
}
if (selectedOptions.length === 0 ||
(selectedOptions.length === 1 && selectedOptions[0] === "") ||
(selectedOptions.length === 1 && selectedOptions[0] === "n/a")
) {
return true;
}
return this.#doesIntersect(localOptions, selectedOptions);
}
#doesIntersect(a, b) {
if (a.length === 0 || b.length === 0) {
return false;
}
return this.#intersect(a, b).length > 0;
}
#intersect(a, b) {
return a.filter(Set.prototype.has, new Set(b));
}
}

View File

@@ -0,0 +1,76 @@
export default class EpisodeContainer extends HTMLElement {
options = [];
showTitle;
#episodeSelectorEl;
#resultsToggleBtnEl;
#resultsTableEl;
#resultsCountBadgeEl;
#resultsCountNumberEl;
constructor() {
super();
this.showTitle = this.getAttribute('show-title');
this.#resultsTableEl = this.querySelector('.results-container');
this.#resultsToggleBtnEl = this.querySelector('.dropdown-button');
this.#resultsCountBadgeEl = this.querySelector('.results-count-badge');
this.#resultsCountNumberEl = this.querySelector('.results-count-number');
this.#episodeSelectorEl = this.querySelector('.episode-selector');
this.#resultsToggleBtnEl.addEventListener('click', () => this.toggleResults());
this.#resultsCountBadgeEl.addEventListener('click', () => this.toggleResults());
document.addEventListener('filterDownloadOptions', this.filter.bind(this));
document.addEventListener('downloadSelectedEpisodes', this.downloadSelectedResults.bind(this));
document.addEventListener('selectEpisodeForDownload', (e) => this.selectEpisodeForDownload(e.detail.select));
}
toggleResults() {
this.#resultsToggleBtnEl.classList.toggle('rotate-90');
this.#resultsToggleBtnEl.classList.toggle('-rotate-90');
this.#resultsTableEl.classList.toggle('hidden');
}
selectEpisodeForDownload(select) {
if (this.#episodeSelectorEl.disabled === false) {
this.#episodeSelectorEl.checked = select;
}
}
downloadSelectedResults() {
if (this.#episodeSelectorEl.disabled === false &&
this.#episodeSelectorEl.checked === true
) {
console.log('episode is selected')
this.options.forEach(option => {
if (option.isSelected === true) {
option.download();
}
option.isSelected = false;
})
}
}
filter({ detail: { activeFilter } }) {
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const include = option.filter({ detail: { activeFilter: activeFilter } });
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
this.#resultsCountNumberEl.innerText = count;
}
}

View File

@@ -0,0 +1,36 @@
export default class MovieContainer extends HTMLElement {
#resultsTableEl;
#resultsCountNumberEl;
constructor() {
super();
this.#resultsTableEl = this.querySelector('.results-container');
this.#resultsCountNumberEl = document.querySelector('.results-count-number');
document.addEventListener('filterDownloadOptions', this.filter.bind(this));
}
filter({ detail: { activeFilter } }) {
const options = this.querySelectorAll('tr.download-option');
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
options.forEach((option) => {
const include = option.filter({ detail: { activeFilter: activeFilter } });
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
this.#resultsCountNumberEl.innerText = count;
}
}

View File

@@ -1,5 +1,34 @@
{
"controllers": {
"@spomky-labs/pwa-bundle": {
"connection-status": {
"enabled": true,
"fetch": "eager"
},
"backgroundsync-form": {
"enabled": true,
"fetch": "eager"
},
"sync-broadcast": {
"enabled": true,
"fetch": "eager"
},
"prefetch-on-demand": {
"enabled": true,
"fetch": "eager"
}
},
"@symfony/ux-autocomplete": {
"autocomplete": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"tom-select/dist/css/tom-select.default.css": true,
"tom-select/dist/css/tom-select.bootstrap4.css": false,
"tom-select/dist/css/tom-select.bootstrap5.css": false
}
}
},
"@symfony/ux-live-component": {
"live": {
"enabled": true,

View 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')
}
}

View File

@@ -12,6 +12,7 @@ export default class extends Controller {
filename: String,
mediaType: String,
imdbId: String,
episodeId: String
}
download() {
@@ -27,6 +28,7 @@ export default class extends Controller {
filename: this.filenameValue,
mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue,
episodeId: this.episodeIdValue
})
})
.then(res => res.json())

View File

@@ -7,7 +7,7 @@ import { getComponent } from '@symfony/ux-live-component';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['download']
static targets = ['download', 'deleteFileInput']
async initialize() {
this.component = await getComponent(this.element);
@@ -29,6 +29,25 @@ export default class extends Controller {
}
}
pauseDownload(data) {
fetch(`/api/download/${data.params.id}/pause`, {method: 'PATCH'})
.then(res => res.json())
.then(json => console.debug(json));
}
resumeDownload(data) {
fetch(`/api/download/${data.params.id}/resume`, {method: 'PATCH'})
.then(res => res.json())
.then(json => console.debug(json));
}
deleteDownload(data) {
const deleteFileInput = document.querySelector(`#delete_file_${data.params.id}`)
fetch(`/api/download/${data.params.id}?deleteFile=${deleteFileInput.checked}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }

View File

@@ -0,0 +1,43 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static outlets = ['navbar']
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)
}
toggleMenu() {
this.navbarOutlet.toggle();
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -14,7 +14,22 @@ export default class extends Controller {
static targets = ['icon']
connect() {
this.element.hideIcon = this.hideIcon.bind(this);
this.element.showIcon = this.showIcon.bind(this);
this.element.toggleIcon = this.toggleIcon.bind(this);
this.element.isVisibile = this.isVisible.bind(this);
}
isVisible() {
return !this.iconTarget.classList.contains('hidden');
}
showIcon() {
this.iconTarget.classList.remove('hidden');
}
hideIcon() {
this.iconTarget.classList.add('hidden');
}
toggleIcon() {

View File

@@ -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() {

View File

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

View File

@@ -0,0 +1,14 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
deleteMonitor(data) {
fetch(`/api/monitor/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}
}

View File

@@ -6,9 +6,6 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = {
title: String,
tmdbId: String,
@@ -16,80 +13,22 @@ export default class extends Controller {
};
static targets = ['list']
static outlets = ['loading-icon']
options = []
optionsLoaded = false
resultCountEl = null
async connect() {
await this.setOptions();
this.resultCountEl = document.querySelector('#movie_results_count');
}
async setOptions() {
if (false === this.optionsLoaded) {
this.optionsLoaded = true;
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
.then(res => res.text())
.then(response => {
this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
});
}
}
// Keeps compatible with Filter & TV Shows
isActive() {
return true;
}
async filter(activeFilter) {
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
async listTargetConnected() {
this.optionsLoaded = true;
this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.resultCountEl.innerText = this.options.length;
this.loadingIconOutlet.toggleIcon();
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}}));
}
}

View File

@@ -10,16 +10,22 @@ export default class extends Controller {
activeStyles = "block rounded-lg bg-orange-500 hover:bg-opacity-70 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-80 px-4 py-2 text-sm font-medium text-gray-50";
connect() {
console.log(window.location.pathname);
this.element.querySelectorAll('a:not(.nav-foot)').forEach(link => {
this.element.querySelectorAll('.nav-list a:not(.nav-foot)').forEach(link => {
link.className = this.inactiveStyles;
if (window.location.pathname === (new URL(link.href)).pathname || window.location.pathname.startsWith( (new URL(link.href)).pathname + '/' ) ) {
link.className = this.activeStyles;
}
});
window.addEventListener("resize", (event) => {
});
}
setActive() {
toggle() {
this.element.parentElement.classList.toggle('hidden');
this.element.classList.toggle('animate__slideInLeft');
this.element.classList.toggle('fixed');
this.element.classList.toggle('z-20');
}
}

View File

@@ -6,11 +6,9 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
languages = []
providers = []
qualities = []
seasons = []
activeFilter = {
@@ -18,133 +16,139 @@ export default class extends Controller {
"codec": "",
"language": "",
"provider": "",
"quality": "",
}
static outlets = ['movie-results', 'tv-results']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected']
defaultOptions = '<option value="">n/a</option><option value="-">-</option>';
static outlets = ['tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'loadingIcon', 'selectAll', 'downloadSelected']
static values = {
'imdbId': String,
'media-type': String,
'episodes': Array,
'reverseMappedQualities': Object,
}
async connect() {
if (this.mediaTypeValue === "tvshows") {
this.activeFilter['season'] = 1;
}
await this.filter();
this.filter();
this.setTimerToStopLoadingIcon();
document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
}
async movieResultsOutletConnected(outlet) {
await this.parseDownloadOptionForFilter(outlet)
setTimerToStopLoadingIcon() {
setTimeout(() => this.loadingIconTarget.hideIcon(), 10000);
}
async tvResultsOutletConnected(outlet) {
await this.parseDownloadOptionForFilter(outlet)
}
async parseDownloadOptionForFilter(outlet) {
if (outlet.options.length === 0) {
await outlet.setOptions();
}
outlet.options.forEach((option) => {
this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset);
// Event is fired from movies/tvshows controllers to populate this data
loadOptions({detail: { options }}) {
options.forEach((option) => {
this.addLanguages(option);
this.addProviders(option);
this.addQualities(option);
})
await this.filter();
this.filter();
}
addLanguages(option, props) {
const languages = Object.assign([], JSON.parse(props['languages']));
languages.forEach((language) => {
selectAllEpisodes() {
document.dispatchEvent(new CustomEvent('selectEpisodeForDownload', {
detail: {
select: this.selectAllTarget.checked,
}
}));
}
downloadSelectedEpisodes() {
document.dispatchEvent(new CustomEvent('downloadSelectedEpisodes', {}));
}
addLanguages(option) {
option.languages.forEach((language) => {
if (!this.languages.includes(language)) {
this.languages.push(language);
}
});
const preferred = this.languageTarget.dataset.preferred;
if (preferred) {
this.languageTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.languageTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.languageTarget.innerHTML = '<option value="">n/a</option>';
}
this.languageTarget.innerHTML += this.languages.sort()
.map((language) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === language) {
return;
}
return '<option value="'+language+'">'+language+'</option>';
})
.join();
const preferred = JSON.parse(this.languageTarget.dataset.preferred) ?? [];
this.languageTarget.innerHTML = this.#serializeSelectOptions(this.languages);
this.languageTarget.tomselect.items = preferred;
}
addProviders(option, props) {
if (!this.providers.includes(props['provider'])) {
this.providers.push(props['provider']);
addProviders(option) {
if (!this.providers.includes(option.provider)) {
this.providers.push(option.provider);
}
const preferred = this.providerTarget.dataset.preferred;
if (preferred) {
this.providerTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.providerTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.providerTarget.innerHTML = '<option value="">n/a</option>';
}
this.providerTarget.innerHTML += this.providers.sort()
.map((provider) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === provider) {
return;
}
return '<option value="' + provider + '">' + provider + '</option>'
})
.join();
const preferred = JSON.parse(this.providerTarget.dataset.preferred) ?? [];
this.providerTarget.innerHTML = this.#serializeSelectOptions(this.providers);
this.providerTarget.tomselect.items = preferred;
}
async filter() {
const currentSeason = this.activeFilter['season'];
addQualities(option) {
if (option.quality.toLowerCase() in this.reverseMappedQualitiesValue) {
let quality = this.reverseMappedQualitiesValue[option.quality.toLowerCase()];
// converts api returned quality with a value the system recognizes
option.quality = quality;
if (!this.qualities.includes(option.quality)) {
this.qualities.push(quality);
}
}
const preferred = JSON.parse(this.qualityTarget.dataset.preferred) ?? [];
this.qualityTarget.innerHTML = this.#serializeSelectOptions(this.qualities);
this.qualityTarget.tomselect.items = preferred;
}
filter() {
const downloadSeasonSpan = document.querySelector("#downloadSeasonModal");
let results = [];
this.activeFilter = {
"resolution": this.resolutionTarget.value,
"codec": this.codecTarget.value,
"language": this.languageTarget.value,
"provider": this.providerTarget.value,
"resolution": this.#fetchValuesFromNodeList(this.resolutionTarget.selectedOptions),
"codec": this.#fetchValuesFromNodeList(this.codecTarget.selectedOptions),
"language": this.#fetchValuesFromNodeList(this.languageTarget.selectedOptions),
"provider": this.#fetchValuesFromNodeList(this.providerTarget.selectedOptions),
"quality": this.#fetchValuesFromNodeList(this.qualityTarget.selectedOptions),
}
if ("movies" === this.mediaTypeValue) {
results = this.movieResultsOutlets;
await results.forEach((list) => list.filter(this.activeFilter));
} else if ("tvshows" === this.mediaTypeValue) {
results = this.tvResultsOutlets;
if ("tvshows" === this.mediaTypeValue) {
downloadSeasonSpan.innerText = this.seasonTarget.value;
this.activeFilter.season = this.seasonTarget.value;
await results.forEach((list) => list.filter(this.activeFilter, currentSeason, this.seasonTarget.value));
}
}
uncheckSelectAllBtn() {
this.selectAllTarget.checked = false;
}
selectAllEpisodes() {
this.tvResultsOutlets.forEach((episode) => {
if (episode.isActive()) {
episode.selectEpisodeForDownload()
const event = new CustomEvent('filterDownloadOptions', {
detail: {
activeFilter: this.activeFilter
}
});
})
// Event is picked up by the episode-container
// or movie-container web components
document.dispatchEvent(event);
}
downloadSelectedEpisodes() {
this.tvResultsOutlets.forEach(episode => {
if (episode.isActive() && episode.isSelected()) {
episode.download();
setSeason(event) {
this.tvEpisodeListOutlet.setSeason(event.target.value);
}
downloadSeason() {
fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, {
headers: {
'Content-Type': 'application/json'
}
});
this.selectAllTarget.checked = false;
})
}
#fetchValuesFromNodeList(nodeList) {
return [...nodeList].map(option => option.value)
}
#serializeSelectOptions(options) {
return this.defaultOptions + options.sort()
.map((option) => {
return '<option value="' + option + '">' + option + '</option>'
})
.join();
}
}

View File

@@ -0,0 +1,64 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
initialize() {
this._onPreConnect = this._onPreConnect.bind(this);
this._onConnect = this._onConnect.bind(this);
}
connect() {
document.querySelector("#search").onsubmit = (event) => {
event.preventDefault();
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
window.location.href = `/search?term=${autocompleteController.tomSelect.lastValue}`
}
document.querySelector("#search-button").addEventListener('click', (event) => {
event.preventDefault();
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
window.location.href = `/search?term=${autocompleteController.tomSelect.lastQuery}`
});
this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect);
this.element.addEventListener('autocomplete:connect', this._onConnect);
}
disconnect() {
// You should always remove listeners when the controller is disconnected to avoid side-effects
this.element.removeEventListener('autocomplete:connect', this._onConnect);
this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect);
}
_onPreConnect(event) {
// TomSelect has not been initialized - options can be changed
// console.log(event.detail); // Options that will be used to initialize TomSelect
event.detail.options.onItemAdd = (value, $item) => {
const params = value.split('|')
window.location.href = `/result/${params[0]}/${params[1]}`
};
event.detail.options.render.loading = (data, escape) => {
return `
<span data-controller="loading-icon" data-loading-icon-total-value="52" data-loading-icon-count-value="20" class="loading-icon">
<svg viewBox="0 0 24 24" fill="currentColor" height="20" width="20" data-loading-icon-target="icon" class="text-end" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 6.99998C9.1747 6.99987 6.99997 9.24998 7 12C7.00003 14.55 9.02119 17 12 17C14.7712 17 17 14.75 17 12"><animateTransform attributeName="transform" attributeType="XML" dur="560ms" from="0,12,12" repeatCount="indefinite" to="360,12,12" type="rotate"></animateTransform></path></svg>
</span>
`;
}
event.detail.options.render.option = (data, escape) => {
if (data.data.description.length > 60) {
data.data.description = data.data.description.substring(0, 107) + "...";
}
return `<div class="flex flex-row">
<img src="${data.data.poster}" class="w-16 rounded-md">
<div class="p-2 flex flex-col">
<h2>${data.data.title}</h2>
<p class="max-w-[60ch] text-wrap">${data.data.description}</p>
</div>
</div>
`
}
}
_onConnect(event) {
// TomSelect has just been initialized and you can access details from the event
// console.log(event.detail.tomSelect); // TomSelect instance
// console.log(event.detail.options); // Options used to initialize TomSelect
}
}

View File

@@ -0,0 +1,59 @@
import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
async initialize() {
this.component = await getComponent(this.element);
this.component.on('render:finished', (component) => {
console.log(component);
});
if (window.location.hash) {
let targetElement = document.querySelector(window.location.hash);
if (targetElement) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster');
}
}
}
setSeason(season) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.set('pageNumber', 1);
this.component.set('season', parseInt(season));
this.component.render();
}
paginate(event) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.set('episodeNumber', null);
this.component.action('paginate', {page: event.params.page});
this.component.render();
}
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)
}
}

View File

@@ -18,151 +18,23 @@ export default class extends Controller {
active: Boolean,
};
static targets = ['list', 'count', 'episodeSelector']
static targets = ['list', 'count', 'episodeSelector',]
static outlets = ['loading-icon']
options = []
optionsLoaded = false
async connect() {
await this.setOptions();
}
async setOptions() {
if (true === this.activeValue && this.optionsLoaded === false) {
this.optionsLoaded = true;
await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
.then(response => {
this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.episodeSelectorTarget.disabled = true;
}
this.loadingIconOutlet.increaseCount();
});
listTargetConnected() {
this.element.options = this.element.querySelectorAll('tbody tr');
if (this.element.options.length > 0) {
this.element.options.forEach((option) =>
option.querySelector('.download-btn').dataset['title'] = this.titleValue
);
this.element.options[0].querySelector('input[type="checkbox"]').checked = true;
this.loadingIconOutlet.increaseCount();
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.element.options}}));
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
}
}
//
// async clearCache() {
// await fetch(`/torrentio/tvshows/clear/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
// .then(res => res.text())
// .then(response => {});
// }
async setActive() {
this.activeValue = true;
this.element.classList.remove('hidden');
if (false === this.optionsLoaded) {
await this.setOptions();
}
}
async setInActive() {
this.activeValue = false;
// if (true === this.hasEpisodeSelectorTarget()) {
this.episodeSelectorTarget.checked = false;
// }
this.element.classList.add('hidden');
}
isActive() {
return this.activeValue;
}
isSelected() {
return this.episodeSelectorTarget.checked;
}
selectEpisodeForDownload() {
if (true === this.isActive() && this.episodeSelectorTarget.disabled === false) {
this.episodeSelectorTarget.checked = !this.episodeSelectorTarget.checked;
}
}
toggleList() {
this.listTarget.classList.toggle('hidden');
}
download() {
this.options.forEach(option => {
const optionSelector = option.querySelector('input[type="checkbox"]');
if (true === optionSelector.checked) {
const downloadBtn = option.querySelector('button.download-btn');
const downloadBtnController = this.application.getControllerForElementAndIdentifier(downloadBtn, 'download-button');
downloadBtnController.download();
optionSelector.checked = false;
this.episodeSelectorTarget.checked = false;
}
})
}
async filter(activeFilter, currentSeason, newSeason) {
if (currentSeason !== activeFilter['season']) {
if (this.seasonValue === newSeason) {
await this.setActive();
} else {
await this.setInActive();
}
}
if (false === this.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
this.countTarget.innerText = count;
});
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M12 4c-4.41 0-8 3.59-8 8s3.59 8 8 8s8-3.59 8-8s-3.59-8-8-8m5 11.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41L8.41 7L12 10.59L15.59 7L17 8.41L13.41 12z" opacity=".3"/><path fill="currentColor" d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m3.59-13L12 10.59L8.41 7L7 8.41L10.59 12L7 15.59L8.41 17L12 13.41L15.59 17L17 15.59L13.41 12L17 8.41z"/></svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><mask id="ipTPauseOne0"><g fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="4"><path fill="#555" d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4S4 12.954 4 24s8.954 20 20 20Z"/><path stroke-linecap="round" d="M19 18v12m10-12v12"/></g></mask></defs><path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTPauseOne0)"/></svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><mask id="ipTPlay0"><g fill="#555" stroke="#fff" stroke-linejoin="round" stroke-width="4"><path d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4S4 12.954 4 24s8.954 20 20 20Z"/><path d="M20 24v-6.928l6 3.464L32 24l-6 3.464l-6 3.464z"/></g></mask></defs><path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTPlay0)"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0" stroke="currentColor" stroke-dasharray="64" stroke-dashoffset="64" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12c0 -4.97 4.03 -9 9 -9c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9c-4.97 0 -9 -4.03 -9 -9Z"><animate fill="freeze" attributeName="fill-opacity" begin="0.6s" dur="0.15s" values="0;0.3"/><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="64;0"/></path></svg>

After

Width:  |  Height:  |  Size: 517 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 0 0"><path fill="currentColor" fill-rule="evenodd" d="M0 3.75A.75.75 0 0 1 .75 3h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 3.75M0 8a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8m.75 3.5a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="currentColor" d="m0 11l2-2l5 5L18 3l2 2L7 18z"/></svg>

After

Width:  |  Height:  |  Size: 127 B

View File

@@ -10,4 +10,182 @@
h2 {
font-size: var(--text-xl);
}
.rounded-ms {
border-radius: 0.275rem;
}
}
@layer components {
.alert {
@apply text-white text-sm min-w-[250px] border px-4 py-3 rounded-md
}
.alert-success {
@apply bg-green-950 hover:bg-green-900 border-green-500
}
.alert-warning {
@apply bg-yellow-500 hover:bg-yellow-600 border-yellow-400 text-black
}
}
/* Prevent scrolling while dialog is open */
body:has(dialog[data-dialog-target="dialog"][open]) {
overflow: hidden;
}
/* Customize the dialog backdrop */
dialog {
box-shadow: 0 0 0 100vw rgb(0 0 0 / 0.5);
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* Add animations */
dialog[data-dialog-target="dialog"][open] {
animation: fade-in 200ms forwards;
}
dialog[data-dialog-target="dialog"][closing] {
animation: fade-out 200ms forwards;
}
.text-input {
@apply bg-gray-50 text-gray-50 px-2 py-1 bg-transparent border-b-2 border-orange-400
}
.text-input[multiple="multiple"] {
@apply bg-transparent backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
}
.text-input option[checked="checked"],
.text-input option[checked],
.text-input option[selected] {
@apply bg-orange-500/60
}
.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;
}
.r-tablerow {
display: flex;
}
@media screen and (min-width: 768px) {
.r-tablecell {
display: table-cell;
}
.r-tablerow {
display: table-row;
}
}
.options-table {
display: flex;
:last-child {
border-bottom: none;
}
}
@media screen and (min-width: 768px) {
.options-table {
display: inline-table;
}
}
#search .ts-wrapper.single .ts-control::after {
display: none !important;
}
#search .ts-control {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: #fff !important;
padding-left: 0;
input {
color: #fff !important;
padding: 0;
}
}
#search .ts-dropdown {
background: unset;
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
}
#search .ts-dropdown .ts-dropdown-content .option.active {
background: unset;
@apply bg-orange-500/80 text-black font-bold rounded-md
}
.progress {
display: grid;
grid-template: 1fr / 1fr;
place-items: center;
}
.progress > * {
grid-column: 1 / 1;
grid-row: 1 / 1;
}
.progress .background {
z-index: 1;
place-self: start;
}
.progress .number {
z-index: 2;
background: transparent;
}
#filter {
.ts-wrapper {
box-shadow: none !important;
padding: 0;
.ts-control {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.item[data-ts-item] {
background-image: none !important;
border: none;
box-shadow: none;
text-shadow: none;
@apply bg-orange-500 rounded-ms font-bold;
}
@apply border-b-2 border-b-orange-600 bg-transparent;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
@apply border-l border-l-orange-600 !important;
}
}
.filter-label {
@apply flex flex-col gap-1 justify-between;
}

View File

@@ -1,6 +1,11 @@
dev.caldwell.digital:443
{
log {
level DEBUG
}
}
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
reverse_proxy app:80
dev.caldwell.digital:443 {
tls /etc/ssl/wildcard.crt /etc/ssl/wildcard.pem
reverse_proxy app:80
}

View File

@@ -2,6 +2,7 @@ services:
caddy:
image: caddy:2.9.1
restart: unless-stopped
tty: true
cap_add:
- NET_ADMIN
ports:
@@ -18,10 +19,12 @@ services:
restart: unless-stopped
volumes:
- $PWD:/app
- $PWD/var/download:/var/download
- mercure_data:/data
- mercure_config:/config
tty: true
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on:
@@ -34,8 +37,11 @@ services:
restart: unless-stopped
volumes:
- $PWD:/app
- $PWD/var/download:/var/download
tty: true
command: php /app/bin/console messenger:consume async -vv
environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume async -vv --time-limit=3600
scheduler:
@@ -43,6 +49,8 @@ services:
restart: unless-stopped
volumes:
- $PWD:/app
environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume scheduler_monitor -vv
tty: true
@@ -53,6 +61,8 @@ services:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
environment:
TZ: America/Chicago
database:
@@ -62,6 +72,7 @@ services:
volumes:
- mysql:/var/lib/mysql
environment:
TZ: America/Chicago
MYSQL_DATABASE: app
MYSQL_USERNAME: app
MYSQL_PASSWORD: password

View File

@@ -4,50 +4,61 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12",
"chrisullyott/php-filesize": "^4.2",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-fixtures-bundle": "^4.1",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4",
"drenso/symfony-oidc-bundle": "^4.2",
"guzzlehttp/guzzle": "^7.9",
"league/pipeline": "^1.1",
"nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-http/cache-plugin": "^2.0",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/filesystem": "7.2.*",
"symfony/finder": "7.2.*",
"spomky-labs/pwa-bundle": "^1.2",
"stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*",
"symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/filesystem": "7.3.*",
"symfony/finder": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/ldap": "7.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.2.*",
"symfony/runtime": "7.2.*",
"symfony/scheduler": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/messenger": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.2.*",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-autocomplete": "^2.27",
"symfony/ux-icons": "^2.24",
"symfony/ux-live-component": "^2.24",
"symfony/ux-turbo": "^2.24",
"symfony/ux-twig-component": "^2.24",
"symfony/yaml": "7.2.*",
"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": {
@@ -55,6 +66,9 @@
"symfony/flex": true,
"symfony/runtime": true
},
"platform": {
"php": "8.4"
},
"bump-after-update": true,
"sort-packages": true
},
@@ -90,6 +104,7 @@
"post-update-cmd": [
"@auto-scripts"
],
"tail": "docker compose exec app ./bin/console tailwind:build --watch",
"sym": "docker compose exec app ./bin/console"
},
"conflict": {
@@ -98,13 +113,13 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.2.*"
"require": "7.3.*"
}
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
}
}

3062
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,4 +18,9 @@ return [
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
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],
SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::class => ['all' => true],
];

View File

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

View 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)%'

View File

@@ -11,7 +11,7 @@ framework:
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
handler_id: '%env(REDIS_HOST)%'
#esi: true
#fragments: true

View 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)%>'

View File

@@ -10,10 +10,22 @@ framework:
options:
use_notify: true
check_delayed_interval: 60000
queue_name: default
retry_strategy:
max_retries: 1
multiplier: 1
media_cache:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
queue_name: media_cache
retry_strategy:
max_retries: 1
multiplier: 1
failed: 'doctrine://default?queue_name=failed'
default_bus: messenger.bus.default
@@ -25,11 +37,12 @@ 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
'App\Monitor\Action\Command\MonitorMovieCommand': async
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': async
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': media_cache
# when@test:
# framework:

View File

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

21
config/packages/pwa.yaml Normal file
View File

@@ -0,0 +1,21 @@
pwa:
manifest:
enabled: true
name: "Torsearch"
short_name: "torsearch"
start_url: "/"
display: "standalone"
id: "/"
background_color: "#f98e44"
theme_color: "#083344"
description: Torsearch provides a simple and intuitive way to manage your personal media library.
icons:
- src: "/icon.png"
sizes: [ 192 ]
- src: "/icon.png"
sizes: [ 192 ]
purpose: maskable
categories:
- entertainment
- multimedia
- utilities

View File

@@ -0,0 +1,2 @@
symfonycasts_reset_password:
request_password_repository: App\User\Framework\Repository\ResetPasswordRequestRepository

View File

@@ -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,20 @@ 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
http_basic:
realm: Secured Area
entry_point: form_login
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
@@ -36,6 +45,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 }

View File

@@ -0,0 +1,7 @@
# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
# See the official DoctrineExtensions documentation for more details: https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
timestampable: true

View File

@@ -1,5 +1,10 @@
twig:
globals:
version: '%app.version%'
file_name_pattern: '*.twig'
date:
format: 'm/d/Y'
timezone: '%env(default:app.default.timezone:TZ)%'
when@test:
twig:

View File

@@ -1,7 +1,23 @@
controllersIndex:
controllersBase:
resource:
path: ../src/Controller/
namespace: App\Controller
path: ../src/Base/Framework/Controller/
namespace: App\Base\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersLibrary:
resource:
path: ../src/Library/Framework/Controller/
namespace: App\Library\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersSearch:
resource:
path: ../src/Search/Framework/Controller/
namespace: App\Search\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
@@ -29,3 +45,19 @@ controllersMonitor:
type: attribute
defaults:
schemes: ['https']
controllersTorrentio:
resource:
path: ../src/Torrentio/Framework/Controller
namespace: App\Torrentio\Framework\Controller
type: attribute
defaults:
schemes: ['https']
controllersTmdb:
resource:
path: ../src/Tmdb/Framework/Controller
namespace: App\Tmdb\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

@@ -0,0 +1,3 @@
ux_autocomplete:
resource: '@AutocompleteBundle/config/routes.php'
prefix: '/autocomplete'

View File

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

View File

@@ -4,10 +4,21 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
# 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)%'
# TMDB Key
app.meta_provider.tmdb.key: '%env(TMDB_API)%'
# Media
media.base_path: '/var/download'
media.default_movies_dir: movies
media.default_tvshows_dir: tvshows
media.movies_path: '%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%'
# Mercure
@@ -20,6 +31,19 @@ parameters:
app.cache.adapter.default: 'filesystem'
app.cache.redis.host.default: 'redis://redis'
# Various configs
app.default.version: '0.dev'
app.default.timezone: 'America/Chicago'
# 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
_defaults:

View File

@@ -6,6 +6,7 @@ services:
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
tty: true
deploy:
replicas: 2
volumes:
@@ -23,7 +24,7 @@ services:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
restart: always
command: -vv
command: -vv --time-limit=3600 --limit=10
deploy:
replicas: 2
depends_on:

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
@@ -11,7 +14,7 @@ RUN install-php-extensions \
zip \
opcache
#COPY . /app
COPY . /app
COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
@@ -11,6 +14,8 @@ RUN install-php-extensions \
zip \
opcache
RUN apk add --no-cache wget
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]

View File

@@ -3,13 +3,6 @@
frankenphp {
{$FRANKENPHP_CONFIG}
worker {
file ./public/index.php
num 20
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
{$FRANKENPHP_WORKER_CONFIG}
}
}
}

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Torsearch media cache warming services
[Service]
ExecStart=php /app/bin/console messenger:consume media_cache --time-limit=3600
# for Redis, set a custom consumer name for each instance
Environment="MESSENGER_CONSUMER_NAME=symfony-%n-%i"
Restart=always
RestartSec=30
[Install]
WantedBy=default.target

View 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="QYYBR7OSQ4VEFKWASDEZ2B4VO67KHUJY6IWOT7HHA7ATXO7QCYDQ"
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

View File

@@ -1,16 +1,17 @@
services:
# The "entrypoint" into the application. This reverse proxy
# proxies traffic back to their respective services. If not
# running behind a reverse proxy inject your SSL certificates
# into this container.
# This container runs the actual web app in a php:8.4-fpm
# base container.
app:
image: code.caldwell.digital/home/torsearch-app:latest
ports:
- '8006:80'
env_file:
- .env
volumes:
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on:
database:
condition: service_healthy
@@ -25,9 +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
command: -vvv
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
command: -vv --time-limit=3600 --limit=10
env_file:
- .env
restart: always
@@ -43,35 +46,18 @@ services:
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest
volumes:
- ./downloads:/var/download
- ./downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows
env_file:
- .env
command: -vv
environment:
TZ: America/Chicago
restart: always
depends_on:
app:
condition: service_healthy
# This container facilitates viewing the progress of downloads
# in realtime. It also handles sending alerts and notifications.
# The MERCURE_PUBLISHER_JWT key & MERCURE_SUBSCRIBER_JWT_KEY should
# match the MERCURE_JWT_SECRET environment variable.
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3001:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
database:
image: mariadb:10.11.2
volumes:

View File

@@ -28,4 +28,43 @@ return [
'@hotwired/turbo' => [
'version' => '7.3.0',
],
'@stimulus-components/popover' => [
'version' => '7.0.0',
],
'@stimulus-components/dialog' => [
'version' => '1.0.1',
],
'@stimulus-components/dropdown' => [
'version' => '3.0.0',
],
'stimulus-use' => [
'version' => '0.52.2',
],
'animate.css' => [
'version' => '4.1.1',
],
'animate.css/animate.min.css' => [
'version' => '4.1.1',
'type' => 'css',
],
'tom-select' => [
'version' => '2.4.3',
],
'@orchidjs/sifter' => [
'version' => '1.1.0',
],
'@orchidjs/unicode-variants' => [
'version' => '1.1.2',
],
'tom-select/dist/css/tom-select.default.min.css' => [
'version' => '2.4.3',
'type' => 'css',
],
'tom-select/dist/css/tom-select.default.css' => [
'version' => '2.4.3',
'type' => 'css',
],
'pulltorefreshjs' => [
'version' => '0.1.22',
],
];

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250610195448 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE download ADD created_at DATETIME NULL, ADD updated_at DATETIME 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 DROP created_at, DROP updated_at
SQL);
}
}

View File

@@ -0,0 +1,50 @@
<?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 Version20250610222503 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 parent_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD CONSTRAINT FK_E1159985727ACA70 FOREIGN KEY (parent_id) REFERENCES monitor (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_E1159985727ACA70 ON monitor (parent_id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP FOREIGN KEY FK_E1159985727ACA70
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_E1159985727ACA70 ON monitor
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP parent_id
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL
SQL);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250724042107 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE preference_option DROP FOREIGN KEY FK_607C52FD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference_option
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE preference_option (id INT AUTO_INCREMENT NOT NULL, preference_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, value VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, enabled TINYINT(1) NOT NULL, INDEX IDX_607C52FD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE preference_option ADD CONSTRAINT FK_607C52FD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id)
SQL);
}
}

129
nomad.deploy.hcl Normal file
View File

@@ -0,0 +1,129 @@
variable "image_tag" {
type = string
description = "Docker image tag to deploy."
default = "latest"
}
job "torsearch" {
datacenters = [ "home" ]
type = "service"
group "app" {
count = 2
update {
max_parallel = 4
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
network {
port "app" {
to = 80
}
}
task "app" {
driver = "docker"
config {
image = "registry.caldwell.digital/home/torsearch-app:${var.image_tag}"
ports = ["app"]
}
env {
MERCURE_PUBLISHER_JWT_KEY = "!ChangeThisMercureHubJWTSecretKey!"
MERCURE_SUBSCRIBER_JWT_KEY = "!ChangeThisMercureHubJWTSecretKey!"
}
service {
name = "torsearch-app"
provider = "nomad"
port = "app"
meta {
nomad_ingress_enabled = true
nomad_ingress_hostname = "torsearch-nomad.caldwell.digital"
}
}
}
}
group "worker" {
count = 2
update {
max_parallel = 4
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
volume "media" {
type = "host"
source = "media"
read_only = false
}
task "worker" {
driver = "docker"
volume_mount {
volume = "media"
destination = "/var/download"
read_only = false
}
config {
image = "registry.caldwell.digital/home/torsearch-worker:${var.image_tag}"
args = [
"-vv"
]
}
service {
name = "torsearch-worker"
provider = "nomad"
}
}
}
group "scheduler" {
count = 1
update {
max_parallel = 2
min_healthy_time = "30s"
healthy_deadline = "3m"
auto_revert = true
}
volume "media" {
type = "host"
source = "media"
read_only = false
}
task "scheduler" {
driver = "docker"
volume_mount {
volume = "media"
destination = "/var/download"
read_only = false
}
config {
image = "registry.caldwell.digital/home/torsearch-scheduler:${var.image_tag}"
args = [
"-vv"
]
}
service {
name = "torsearch-scheduler"
provider = "nomad"
}
}
}
}

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Base;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class ConfigResolver
{
private array $messages = [];
public function __construct(
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
#[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null,
#[Autowire(param: 'app.meta_provider.tmdb.key')]
private readonly ?string $tmdbApiKey = null,
#[Autowire(param: 'media.movies_path')]
private readonly ?string $moviesPath = null,
#[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
{
$valid = true;
if (null === $this->realDebridApiKey || "" === $this->realDebridApiKey) {
$this->messages[] = "Your Real Debrid API key is missing. Please set it to the 'REAL_DEBRID_KEY' environment variable.";
$valid = false;
}
if (null === $this->tmdbApiKey || "" === $this->tmdbApiKey) {
$this->messages[] = "Your TMDB API key is missing. Please set it to the 'TMDB_API' environment variable.";
$valid = false;
}
return $valid;
}
public function getMessages(): array
{
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,
]
];
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Enum;
namespace App\Base\Enum;
enum MediaType: string
{

View File

@@ -1,18 +1,17 @@
<?php
namespace App\Command;
namespace App\Base\Framework\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'config:set',
description: 'Add a short description for your command',
description: '[deprecated] This command currently serves no use. It may be re-purposed or removed in the future.',
)]
class ConfigSetCommand extends Command
{

View File

@@ -1,9 +1,12 @@
<?php
namespace App\Command;
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;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -12,20 +15,21 @@ 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
{
private PreferencesRepository $preferenceRepository;
private PreferenceOptionRepository $preferenceOptionRepository;
private UserRepository $userRepository;
public function __construct(
PreferencesRepository $preferenceRepository,
PreferenceOptionRepository $preferenceOptionRepository,
UserRepository $userRepository,
) {
parent::__construct();
$this->preferenceRepository = $preferenceRepository;
$this->preferenceOptionRepository = $preferenceOptionRepository;
$this->userRepository = $userRepository;
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -33,7 +37,7 @@ class SeedDatabaseCommand extends Command
$io = new SymfonyStyle($input, $output);
$this->seedPreferences($io);
$this->seedPreferenceOptions($io);
$this->updateUserPreferences($io);
return Command::SUCCESS;
}
@@ -44,22 +48,48 @@ 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();
}
private function updateUserPreferences(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Updating user preferences...');
$users = $this->userRepository->findAll();
$preferences = $this->preferenceRepository->findAll();
foreach ($users as $user) {
foreach ($preferences as $preference) {
if (false === $user->hasUserPreference($preference->getId())) {
$user->addUserPreference(
new UserPreference()
->setPreference($preference)
->setUser($user)
->setPreferenceValue(null)
);
}
}
}
$this->userRepository->getEntityManager()->flush();
}
private function getPreferences(): array
{
return [
@@ -91,6 +121,13 @@ class SeedDatabaseCommand extends Command
'enabled' => true,
'type' => 'media',
],
[
'id' => 'quality',
'name' => 'Quality',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'movie_folder',
'name' => 'Create new folder for Movies',
@@ -100,72 +137,4 @@ class SeedDatabaseCommand extends Command
],
];
}
private function seedPreferenceOptions(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Seeding preference options...');
$options = $this->getPreferenceOptions();
foreach ($options as $option) {
if ($this->preferenceOptionRepository->findBy([
'preference' => $option['preference_id'],
'name' => $option['name'],
'value' => $option['value'],
'enabled' => $option['enabled'],
])) {
continue;
}
$this->preferenceOptionRepository->getEntityManager()->persist(
(new \App\User\Framework\Entity\PreferenceOption())
->setPreference($this->preferenceRepository->find($option['preference_id']))
->setName($option['name'])
->setValue($option['value'])
->setEnabled($option['enabled'])
);
}
$this->preferenceOptionRepository->getEntityManager()->flush();
}
private function getPreferenceOptions(): array
{
return [
[
'preference_id' => 'resolution',
'name' => '720p',
'value' => '720p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '1080p',
'value' => '1080p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '2160p',
'value' => '2160p',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => '-',
'value' => '-',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h264',
'value' => 'h264',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h265/HEVC',
'value' => 'h265',
'enabled' => true
]
];
}
}

View File

@@ -1,19 +1,17 @@
<?php
namespace App\Command;
namespace App\Base\Framework\Command;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'startup:status',
description: 'Add a short description for your command',
description: 'Used by the Docker healthcheck system to signal when the container is healthy.',
)]
class StartupStatusCommand extends Command
{

View 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();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Base\Framework\Controller;
use App\Base\Service\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class AlertController extends AbstractController
{
public function __construct(
private readonly Broadcaster $broadcaster,
) {}
#[Route('/alert', name: 'app_alert')]
public function index(): Response
{
$this->broadcaster->alert(
'Added to queue',
'This is a testy test!'
);
return $this->json([
'Success' => 'Published'
]);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Base\Framework\Controller;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\Tmdb;
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
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly MonitorTvShowHandler $monitorTvShowHandler,
) {}
#[Route('/', name: 'app_index')]
public function index(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(),
'popular_movies' => $this->tmdb->popularMovies(1, 6),
'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!'
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Base\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Twig\Environment;
readonly class Broadcaster
{
public function __construct(
#[Autowire(service: 'twig')]
private Environment $renderer,
private HubInterface $hub,
private RequestStack $requestStack,
) {}
public function alert(string $title, string $message, string $type = "success"): void
{
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Base\Service;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use Nihilarr\PTN;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class MediaFiles
{
private Finder $finder;
private string $basePath;
private string $moviesPath;
private string $tvShowsPath;
private Filesystem $filesystem;
private LoggerInterface $logger;
public function __construct(
#[Autowire(param: 'media.base_path')]
string $basePath,
#[Autowire(param: 'media.movies_path')]
string $moviesPath,
#[Autowire(param: 'media.tvshows_path')]
string $tvShowsPath,
Filesystem $filesystem,
LoggerInterface $logger,
) {
$this->finder = new Finder();
$this->basePath = $basePath;
$this->moviesPath = $moviesPath;
$this->tvShowsPath = $tvShowsPath;
$this->filesystem = $filesystem;
$this->logger = $logger;
}
public function getPathByType(string $mediaType): string
{
if ('movies' === $mediaType) {
return $this->moviesPath;
} elseif ('tvshows' === $mediaType) {
return $this->tvShowsPath;
}
throw new \Exception(sprintf('A path for media type %s does not exist.', $mediaType));
}
public function getBasePath(): string
{
return $this->basePath;
}
public function getMoviesPath(): string
{
return $this->moviesPath;
}
public function getTvShowsPath(): string
{
return $this->tvShowsPath;
}
public function getMovieDirs(): Map
{
$results = [];
foreach ($this->finder->in($this->moviesPath)->directories() as $file) {
$results[] = $file;
}
return Map::from($results);
}
public function getTvShowDirs(): Map
{
$results = [];
foreach ($this->finder->in($this->tvShowsPath)->directories() as $file) {
$results[] = $file;
}
return Map::from($results);
}
public function getEpisodes(string $path, bool $onlyFilenames = true): Map
{
if (!str_starts_with($path, $this->tvShowsPath)) {
$path = $this->tvShowsPath . DIRECTORY_SEPARATOR . $path;
}
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
$results = [];
foreach ($this->finder->in($path)->files() as $file) {
if ($onlyFilenames) {
$results[] = $file->getRelativePathname();
} else {
$results[] = $file;
}
}
return Map::from($results);
}
public function createMovieDirectory(string $path): string
{
$path = $this->moviesPath . DIRECTORY_SEPARATOR . $path;
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
return $path;
}
public function createTvShowDirectory(string $path): string
{
$path = $this->tvShowsPath . DIRECTORY_SEPARATOR . $path;
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
return $path;
}
public function createDirectory(string $path): string
{
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
return $path;
}
public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber): SplFileInfo|false
{
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);
if ($existingEpisodes->isEmpty()) {
return false;
}
/** @var SplFileInfo $episode */
foreach ($existingEpisodes as $episode) {
$ptn = (object) (new PTN())->parse($episode->getFilename());
if (!property_exists($ptn, 'season') || !property_exists($ptn, 'episode')) {
continue;
}
if ($ptn->season === $seasonNumber && $ptn->episode === $episodeNumber) {
return $episode;
}
}
return false;
}
public function movieExists(string $title)
{
$filepath = $this->moviesPath . DIRECTORY_SEPARATOR . $title;
$directoryExists = $this->filesystem->exists($filepath);
if (false === $directoryExists) {
return false;
}
if (false === $this->finder->in($filepath)->files()->hasResults()) {
return false;
}
$files = Map::from($this->finder->in($filepath)->files())->filter(function (SplFileInfo $file) {
$validExtensions = ['mkv', 'mp4', 'mpeg'];
return in_array($file->getExtension(), $validExtensions);
})->values();
if (false === $files->isEmpty()) {
return $files[0];
}
return false;
}
public function getDownloadPath(Download $download): string
{
$basePath = $this->getBasePath();
if ($download->getMediaType() === 'movies') {
$basePath = $this->getMoviesPath();
if ($download->getUser()->hasUserPreference('movie_folder')) {
$basePath .= DIRECTORY_SEPARATOR . $download->getTitle();
}
} elseif ($download->getMediaType() === 'tvshows') {
$basePath = $this->getTvShowsPath() . DIRECTORY_SEPARATOR . $download->getTitle();
}
$filepath = $basePath . DIRECTORY_SEPARATOR . $download->getFilename();
if (false === $this->filesystem->exists($filepath)) {
throw new \Exception(sprintf('File %s does not exist.', $filepath));
}
return $filepath;
}
public function renameFile(string $oldFile, string $newFile)
{
$this->filesystem->rename($oldFile, $newFile);
}
public function setFilePermissions(string $filepath, int $permissions)
{
$this->filesystem->chmod($filepath, $permissions);
}
/**
* @param string $filepath
* @return bool
* Returns true if file was deleted
* Returns false is file not found or was not deleted
*/
public function removeFile(string $filepath): bool
{
if (true === $this->filesystem->exists($filepath)) {
try {
$this->filesystem->remove($filepath);
return true;
} catch (\Throwable $exception) {
$this->logger->error($exception->getMessage(), ['file' => $filepath]);
return false;
}
}
$this->logger->warning('> [MediaFiles] Attempted to remove file, but it did not exist.', ['file' => $filepath]);
return false;
}
}

View 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);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Base\Util;
class ImdbMatcher
{
public static function isMatch(string $imdbId): bool
{
return preg_match('/^tt\d{7}$/', $imdbId);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Util;
namespace App\Base\Util;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
@@ -22,6 +22,8 @@ class Paginator
public $currentPage = 1;
public $limit = 5;
/**
* @param QueryBuilder|Query $query
* @param int $page
@@ -41,6 +43,7 @@ class Paginator
$this->lastPage = (int) ceil($paginator->count() / $paginator->getQuery()->getMaxResults());
$this->items = $paginator;
$this->currentPage = $page;
$this->limit = $limit;
return $this;
}
@@ -59,4 +62,11 @@ class Paginator
{
return $this->items;
}
public function getShowing()
{
$showingStart = ($this->currentPage - 1) * $this->limit;
$showingEnd = $showingStart + $this->limit;
return sprintf("Showing %d - %d of %d results.", $showingStart, $showingEnd, $this->total);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DeleteDownloadCommand>
*/
class DeleteDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
public bool $deleteFile = false,
) {}
}

View 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',
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<PauseDownloadCommand>
*/
class PauseDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<ResumeDownloadCommand>
*/
class ResumeDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\DeleteDownloadCommand;
use App\Download\Action\Result\DeleteDownloadResult;
use App\Download\Framework\Repository\DownloadRepository;
use App\Library\Action\Command\DeleteMediaFileCommand;
use App\Library\Action\Handler\DeleteMediaFileHandler;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<DeleteDownloadCommand, DeleteDownloadResult> */
readonly class DeleteDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private DeleteMediaFileHandler $deleteMediaFileHandler,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$download = $this->downloadRepository->find($command->downloadId);
if (true === $command->deleteFile) {
$deletedFileResult = $this->deleteMediaFileHandler->handle(new DeleteMediaFileCommand(
filename: $download->getFilename(),
downloadId: $command->downloadId
));
}
$this->downloadRepository->delete($command->downloadId);
return new DeleteDownloadResult(
status: 200,
message: 'Success',
download: $download,
deleteMediaFileResult: $deletedFileResult ?? null
);
}
}

View File

@@ -38,7 +38,9 @@ readonly class DownloadMediaHandler implements HandlerInterface
}
try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
}
$this->downloader->download(
$command->mediaType,
@@ -47,7 +49,9 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download->getId()
);
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
}
} catch (\Throwable $exception) {
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);

View 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());
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\PauseDownloadCommand;
use App\Download\Action\Result\PauseDownloadResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Contracts\Cache\CacheInterface;
/** @implements HandlerInterface<PauseDownloadCommand, PauseDownloadResult> */
readonly class PauseDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private CacheInterface $cache,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $download */
$download = $this->downloadRepository->find($command->downloadId);
$this->cache->get('download.pause.' . $download->getId(), function () {
return true;
});
return new PauseDownloadResult(200, 'Success', $download);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\ResumeDownloadCommand;
use App\Download\Action\Result\ResumeDownloadResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<ResumeDownloadCommand, ResumeDownloadResult> */
readonly class ResumeDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $messageBus,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $download */
$download = $this->downloadRepository->find($command->downloadId);
$this->messageBus->dispatch(new DownloadMediaCommand(
$download->getUrl(),
$download->getTitle(),
$download->getFilename(),
$download->getMediaType(),
$download->getImdbId(),
$download->getUser()->getId(),
$download->getId()
));
return new ResumeDownloadResult(200, 'Success', $download);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\DeleteDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DeleteDownloadInput> */
class DeleteDownloadInput implements InputInterface
{
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
#[SourceQuery('deleteFile')]
public bool $deleteFile = false,
) {}
public function toCommand(): CommandInterface
{
return new DeleteDownloadCommand(
$this->downloadId,
$this->deleteFile,
);
}
}

View File

@@ -26,6 +26,9 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('episodeId', nullify: true)]
public ?string $episodeId = null,
public ?int $userId = null,
public ?int $downloadId = null,

View 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,
);
}
}

View File

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

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\PauseDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<PauseDownloadInput, PauseDownloadCommand> */
class ResumeDownloadInput implements InputInterface
{
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
) {}
public function toCommand(): CommandInterface
{
return new PauseDownloadCommand(
$this->downloadId,
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Download\Action\Result;
use App\Download\Framework\Entity\Download;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<DownloadMediaResult> */
class DeleteDownloadResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
public Download $download,
public ?DeleteMediaFileResult $deleteMediaFileResult = null,
) {}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class DeleteMediaFileResult implements ResultInterface
{
public function __construct(
public string $message,
public string $filepath,
public bool $isDeleted,
) {}
}

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