├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── config └── index.ts ├── docker-compose.yml ├── dummy.mp4 ├── example.env ├── index.ts ├── package-lock.json ├── package.json ├── scripts └── maintenance.ts ├── systems ├── plex.ts ├── radarr.ts ├── sonarr.ts └── tautulli.ts ├── tsconfig.json └── utils └── index.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | **/dist 3 | .git 4 | npm-debug.log 5 | .coverage 6 | .coverage.* 7 | .env 8 | .aws -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Gebruik een lichte Node.js versie als basis 2 | FROM node:18-alpine 3 | 4 | # Stel de werkdirectory in 5 | WORKDIR /app 6 | 7 | # Kopieer package.json en package-lock.json naar de container 8 | COPY package*.json ./ 9 | 10 | # Installeer de afhankelijkheden 11 | RUN npm install 12 | 13 | RUN mkdir -p /.npm && chmod -R 777 /.npm 14 | 15 | # Kopieer de rest van de applicatiecode naar de container 16 | COPY . . 17 | 18 | # Expose de poort die door het script wordt gebruikt 19 | EXPOSE 3000 20 | 21 | # Start het script 22 | CMD ["npm", "start"] 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Infinite Plex Library (BETA!) 2 | 3 | Demo video: https://imgur.com/a/xADw6ut 4 | ![image](https://github.com/user-attachments/assets/a6919ef5-7348-4c9d-984e-a4e548f2d1f4) 5 | 6 | 7 | ## Overview 8 | 9 | This project allows you to create an infinite Plex library without pre-downloading movies from your Debrid provider. The script sets up a "dummy" MP4 file of 1 second for each movie requested in Radarr with the "dummy" tag. When you play a movie in Plex, Tautulli sends a webhook notification to the script, which then triggers the actual movie request in Radarr. 10 | 11 | Using this approach, you can leverage Radarr to populate your Plex with lists of movies marked with a "dummy" tag without actively monitoring them. When a movie is played, the script modifies its status and begins the download process. 12 | 13 | This script can also be used in combination with Kometa. Use Kometa to create collections like 'Today Popular on x' or 'Most Downloaded Movies This Week' Kometa can add these movies to Radarr with the dummy tag, allowing you to display them in Plex without actually requesting them upfront. You can showcase these collections on your Plex Home for all users, providing them with a 'Netflix-like' experience. 14 | 15 | With this script, you can manage a large Plex library without overloading your Debrid Providers or Indexers. Movies are typically available within two minutes if they are cached by the provider! 16 | 17 | > **Note**: This script is currently in BETA. 18 | 19 | ## Features 20 | 21 | - **Infinite Plex Library**: Seamlessly maintain a large Plex library without needing to add all the movies to your Debrid provider in advance. 22 | - **Dynamic Movie Retrieval**: Automatically request movies through Radarr upon playback in Plex. 23 | - **Tautulli Integration**: Tautulli webhooks trigger movie downloads, minimizing resource usage until a movie is actually played. 24 | - **Kometa Compatibility**: Use this script alongside Kometa to create popular movie categories that are added in an unmonitored state. 25 | - **Efficient Debrid Utilization**: Avoid overloading Debrid Providers and Indexers, ensuring availability only when needed. 26 | 27 | ## How It Works 28 | 29 | 1. **Dummy File Setup**: When a movie is added to Radarr (via Kometa, Radarr lists or manually), with tag "dummy" and not monitored, the script places a 1-second dummy MP4 file in the Plex directory for that movie. 30 | 2. **Playback Detection**: When you play a movie in Plex, Tautulli sends a webhook notification to the script. 31 | 3. **Movie Request**: The script checks the library and, if the movie is only a dummy, it requests the full movie via Radarr. 32 | 4. **Fast Availability**: If the movie is cached by the Debrid provider, it becomes available in Plex within approximately one or two minutes. 33 | 34 | ## Installation & Usage 35 | Ensure that your Tautulli is set up to send playback start events to the webhook URL configured for this script. 36 | 37 | ### Infinite Plex Library 38 | Place the dummy.mp4 file in /infiniteplexlibrary/dummy.mp4 39 | 40 | For manual maintenance, run `npx ts-node /scripts/maintenance.ts` inside the Docker console. This will verify if every Radarr movie with the "dummy" tag still has a `dummy.mp4` file. It will also check if downloaded movies contain a `dummy.mp4` file, and the script will delete it if found. After running this, you will need to manually refresh the Plex library. 41 | 42 | ### Tautulli 43 | Create a new Notification Agent in the Tautulli settings. 44 | 45 | Movies: 46 | 47 | **Webhook URL**: http://ip:port/webhook 48 | 49 | **Webhook method**: POST 50 | 51 | **Trigger**: Playback Start 52 | 53 | **Condition**: Filename is dummy.mp4 54 | 55 | **JSON Data for playback start**: 56 | ``` 57 | { 58 | "event": "playback.start", 59 | "file": "{file}", 60 | "imdb_id": "{imdb_id}", 61 | "tmdb_id": "{themoviedb_id}", 62 | "user": "{user}", 63 | "title": "{title}", 64 | "rating_key": "{rating_key}", 65 | "media_type": "{media_type}", 66 | "player": "{player}" 67 | } 68 | ``` 69 | 70 | Series: 71 | 72 | **Webhook URL**: http://ip:port/webhook 73 | 74 | **Webhook method**: POST 75 | 76 | **Trigger**: Playback Start 77 | 78 | **Condition**: Filename ends with (dummy).mp4 79 | 80 | **JSON Data for playback start**: 81 | ``` 82 | { 83 | "event": "playback.start", 84 | "file": "{file}", 85 | "imdb_id": "{imdb_id}", 86 | "tmdb_id": "{themoviedb_id}", 87 | "user": "{user}", 88 | "title": "{title}", 89 | "rating_key": "{rating_key}", 90 | "media_type": "{media_type}", 91 | "player": "{player}", 92 | "season_num": "{season_num}", 93 | "thetvdb_id": "{thetvdb_id}" 94 | } 95 | ``` 96 | 97 | ### Radarr 98 | Radarr list example: 99 | ![image](https://github.com/user-attachments/assets/f1e939cf-31b3-4752-9a30-f6a9ae7f7800) 100 | 101 | Create this Connect webhook in Radarr to communicate with the script if a new movie with the tag 'dummy' is added: 102 | ![image](https://github.com/user-attachments/assets/ad4c87f1-accd-4026-81d2-cf329f026508) 103 | 104 | > **Note**: Don't map MOVIE_FOLDER_DUMMY to Radarr in Docker. This is necassery so Radarr doesn't mark the movie as downloaded!! 105 | 106 | ### Sonarrr 107 | Create this Connect webhook in Sonarr to communicate with the script if a new series with the tag 'dummy' is added: 108 | image 109 | 110 | ### Kometa 111 | 112 | config.yml 113 | ``` 114 | radarr: 115 | url: http://192.168.1.237:7878 116 | token: xxxxxx 117 | add_missing: true 118 | add_existing: false 119 | upgrade_existing: false 120 | monitor_existing: false 121 | root_folder_path: /plex/Movies 122 | monitor: false 123 | availability: announced 124 | quality_profile: Radarr 125 | tag: dummy 126 | search: false 127 | plex_path: /media/Movies 128 | ``` 129 | 130 | ## Prerequisites 131 | 132 | - **Plex Media Server** 133 | - **Tautulli** for playback detection 134 | - **Radarr** for managing and requesting movies 135 | 136 | ## Example Use Cases 137 | 138 | - **Manage Large Libraries**: Add thousands of movies to Plex without overwhelming your Debrid provider by using dummy placeholders. 139 | - **Kometa Integration**: Combine with Kometa collections to add trending movies automatically to Radarr with tag "dummy". 140 | - **Efficient Storage Use**: Keep a vast Plex library without using extensive storage or network resources until the content is actually played. 141 | 142 | ## Known Limitations 143 | 144 | - **BETA Version**: The script is still in beta, so there are cases that aren't fully handled. 145 | 146 | ## To-do 147 | 148 | - Sonarr support 149 | - Better code 150 | - A scheduler that checks if there are movies that should have a dummy file but do not have one yet. 151 | - Make a better Docker image 152 | - Some ideas from the community 153 | 154 | ## Contributing 155 | 156 | Contributions are welcome! Please submit a pull request or raise an issue to discuss improvements or report bugs. 157 | 158 | ## Disclaimer 159 | 160 | Use this script at your own risk. This is a beta version and may contain bugs or limitations that could affect library management or playback experience. 161 | 162 | --- 163 | 164 | If you have any questions or need further assistance, feel free to reach out or create an issue on the GitHub repository. 165 | Discord: spinix3845 166 | -------------------------------------------------------------------------------- /config/index.ts: -------------------------------------------------------------------------------- 1 | // src/config/index.ts 2 | 3 | export const config = { 4 | RADARR_URL: process.env.RADARR_URL as string, 5 | RADARR_API_KEY: process.env.RADARR_API_KEY as string, 6 | RADARR_MOVIE_FOLDER: process.env.RADARR_MOVIE_FOLDER as string, 7 | RADARR_MONITOR_TAG_NAME: process.env.RADARR_MONITOR_TAG_NAME as string, 8 | 9 | TAUTULLI_URL: process.env.TAUTULLI_URL as string, 10 | TAUTULLI_API_KEY: process.env.TAUTULLI_API_KEY as string, 11 | TAUTULLI_STREAM_TERMINATED_MESSAGE: process.env.TAUTULLI_STREAM_TERMINATED_MESSAGE as string, 12 | 13 | PLEX_MOVIES_LIBRARY_ID: process.env.PLEX_MOVIES_LIBRARY_ID as string, 14 | PLEX_SERIES_LIBRARY_ID: process.env.PLEX_SERIES_LIBRARY_ID as string, 15 | PLEX_TOKEN: process.env.PLEX_TOKEN as string, 16 | PLEX_URL: process.env.PLEX_URL as string, 17 | PLEX_MOVIE_FOLDER: process.env.PLEX_MOVIE_FOLDER as string, 18 | PLEX_SERIES_FOLDER: process.env.PLEX_SERIES_FOLDER as string, 19 | 20 | MOVIE_FOLDER_DUMMY: process.env.MOVIE_FOLDER_DUMMY as string, 21 | DUMMY_FILE_LOCATION: process.env.DUMMY_FILE_LOCATION as string, 22 | SERIES_FOLDER_DUMMY: process.env.SERIES_FOLDER_DUMMY as string, 23 | 24 | // Radarr 4K (optional) 25 | RADARR_4K_URL: process.env.RADARR_4K_URL as string | undefined, 26 | RADARR_4K_API_KEY: process.env.RADARR_4K_API_KEY as string | undefined, 27 | RADARR_4K_MOVIE_FOLDER: process.env.RADARR_4K_MOVIE_FOLDER as string | undefined, 28 | RADARR_4K_QUALITY_PROFILE_ID: process.env.RADARR_4K_QUALITY_PROFILE_ID as string | undefined, 29 | 30 | SONARR_URL: process.env.SONARR_URL as string, 31 | SONARR_API_KEY: process.env.SONARR_API_KEY as string, 32 | SONARR_MONITOR_TAG_NAME: process.env.SONARR_MONITOR_TAG_NAME as string 33 | }; 34 | 35 | const requiredKeys = [ 36 | "RADARR_URL", 37 | "RADARR_API_KEY", 38 | "RADARR_MOVIE_FOLDER", 39 | "RADARR_MONITOR_TAG_NAME", 40 | "TAUTULLI_URL", 41 | "TAUTULLI_API_KEY", 42 | "TAUTULLI_STREAM_TERMINATED_MESSAGE", 43 | "PLEX_MOVIES_LIBRARY_ID", 44 | "PLEX_SERIES_LIBRARY_ID", 45 | "PLEX_TOKEN", 46 | "PLEX_URL", 47 | "PLEX_MOVIE_FOLDER", 48 | "PLEX_SERIES_FOLDER", 49 | "MOVIE_FOLDER_DUMMY", 50 | "DUMMY_FILE_LOCATION", 51 | "SERIES_FOLDER_DUMMY", 52 | "SONARR_URL", 53 | "SONARR_API_KEY", 54 | "SONARR_MONITOR_TAG_NAME" 55 | ]; 56 | 57 | const missingRequiredKeys = requiredKeys.filter(key => !config[key as keyof typeof config]); 58 | 59 | if (missingRequiredKeys.length > 0) { 60 | throw new Error(`❌ Config is missing the following keys: ${missingRequiredKeys.join(", ")}`); 61 | } 62 | 63 | if (config.RADARR_4K_URL) { 64 | const radarr4kRequiredKeys = ["RADARR_4K_API_KEY", "RADARR_4K_MOVIE_FOLDER", "RADARR_4K_QUALITY_PROFILE_ID"]; 65 | const missing4kKeys = radarr4kRequiredKeys.filter(key => !config[key as keyof typeof config]); 66 | 67 | if (missing4kKeys.length > 0) { 68 | throw new Error( 69 | `❌ Config is missing the following keys for 4K support: ${missing4kKeys.join(", ")}. ` + 70 | `These are required when RADARR_4K_URL is provided.` 71 | ); 72 | } 73 | 74 | if (isNaN(Number(config.RADARR_4K_QUALITY_PROFILE_ID))) { 75 | throw new Error("❌ Config RADARR_4K_QUALITY_PROFILE_ID does not contain a valid numeric value."); 76 | } 77 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | infiniteplexlibrary: 5 | image: arjanterheegde/infiniteplexlibrary:latest 6 | container_name: infiniteplexlibrary 7 | user: "99:100" 8 | ports: 9 | - "3000:3000" 10 | volumes: 11 | - /mnt/user/realdebrid/data:/mount #Should be the same as Plex 12 | - /mnt/user/data/plex:/media # Radarr movie path 13 | - /mnt/user/data/plex:/plex # Plex movie path (could be the same as above) 14 | - /mnt/user/appdata/infiniteplexlibrary:/infiniteplexlibrary # Dummy folder 15 | environment: 16 | PUID: 99 17 | PGID: 100 18 | UMASK: 002 19 | TZ: Europe/Amsterdam 20 | env_file: 21 | - .env 22 | restart: unless-stopped -------------------------------------------------------------------------------- /dummy.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arjanterheegde/InfinitePlexLibrary/d9ad479da94a89f171ce95a36414a14a86dd59b2/dummy.mp4 -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | # InfinitePlexLibrary 2 | MOVIE_FOLDER_DUMMY=/infiniteplexlibrary/plex/movies 3 | SERIES_FOLDER_DUMMY=/infiniteplexlibrary/plex/series 4 | DUMMY_FILE_LOCATION=/infiniteplexlibrary/dummy.mp4 5 | 6 | # Radarr 7 | RADARR_URL=http://192.168.1.237:7878/api/v3 8 | RADARR_API_KEY=apikey 9 | RADARR_MOVIE_FOLDER=/plex/Movies 10 | RADARR_MONITOR_TAG_NAME=dummy 11 | 12 | # Radarr 4K | Leave empty if not used 13 | RADARR_4K_URL=http://192.168.1.237:7879/api/v3 14 | RADARR_4K_API_KEY=apikey 15 | RADARR_4K_MOVIE_FOLDER=/plex/Movies - 4K 16 | RADARR_4K_QUALITY_PROFILE_ID=0 # To find the quality profile go to the Profiles page in Radarr and open the developers console on the Network tab and search for qualityprofile. 17 | 18 | # Sonarr 19 | SONARR_URL=http://localhost:8989/api/v3 20 | SONARR_API_KEY=your_sonarr_api_key 21 | SONARR_SERIES_FOLDER=/plex/Series 22 | SONARR_MONITOR_TAG_NAME=dummy 23 | 24 | # Tautulli 25 | TAUTULLI_URL=http://192.168.1.237:8181/api/v2 26 | TAUTULLI_API_KEY=apikey 27 | TAUTULLI_STREAM_TERMINATED_MESSAGE=The movie is now availble to play 28 | 29 | # Plex 30 | PLEX_MOVIES_LIBRARY_ID=7 #Library ID to refresh movies 31 | PLEX_SERIES_LIBRARY_ID=8 #Library ID to refresh series 32 | PLEX_TOKEN=PlexToken 33 | PLEX_URL=http://192.168.1.237:32400 34 | PLEX_MOVIE_FOLDER=/media/Movies 35 | PLEX_SERIES_FOLDER=/media/Movies -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from "express"; 2 | import axios from "axios"; 3 | import path from "path"; 4 | import dotenv from "dotenv"; 5 | import { addMovieToRadarr, addTagForMovie, checkMovieInRadarr, getMovieStatus, getTagId, isMovieDownloading, searchMovieInRadarr } from "./systems/radarr"; 6 | import { config } from "./config"; 7 | import { notifyPlexFolderRefresh, updatePlexDescription } from "./systems/plex"; 8 | import { cleanUpDummyFile, createDummyFile, createSymlink, ensureDirectoryExists, removeDummyFolder } from "./utils"; 9 | import { terminateStreamByFile } from "./systems/tautulli"; 10 | import { getEpisodesBySeriesId, groupEpisodesBySeason, searchSeriesInSonarr, getSeriesByTvdbId, monitorAllSeasons, monitorSeries } from "./systems/sonarr"; 11 | 12 | const app = express(); 13 | const PORT = 3000; 14 | 15 | dotenv.config(); 16 | 17 | app.use(express.json()); 18 | 19 | 20 | 21 | // Function to check season availability every 5 seconds 22 | async function monitorSeasonAvailability( 23 | seriesId: number, 24 | seasonNumber: number, 25 | ratingKey: string, 26 | seasonDescription: string 27 | ) { 28 | console.log(`🔵 Monitoring availability for Season ${seasonNumber} of Series ID ${seriesId} for the next 5 minutes...`); 29 | 30 | const maxRetries = 60; 31 | let attempts = 0; 32 | 33 | return new Promise((resolve) => { 34 | const interval = setInterval(async () => { 35 | attempts++; 36 | 37 | let requestStatus = `Checking availability for Season ${seasonNumber} (attempt ${attempts}/${maxRetries})...`; 38 | 39 | console.log(`🟡 ${requestStatus}`); 40 | 41 | try { 42 | // Get episodes for the specified season from Sonarr 43 | const episodes = await getEpisodesBySeriesId(seriesId, seasonNumber); 44 | 45 | // Filter episodes to check for availability 46 | const unavailableEpisodes = episodes.filter( 47 | (ep: any) => !ep.hasFile || (ep.episodeFile && ep.episodeFile.relativePath === "dummy.mp4") 48 | ); 49 | 50 | if (unavailableEpisodes.length === 0) { 51 | console.log(`🎉 All episodes for Season ${seasonNumber} of Series ID ${seriesId} are now available!`); 52 | 53 | clearInterval(interval); 54 | resolve(); 55 | } else { 56 | requestStatus = `Waiting for ${unavailableEpisodes.length} episodes to be available in Season ${seasonNumber}...`; 57 | 58 | // Update the season description with the current status 59 | await updatePlexDescription(ratingKey, seasonDescription, requestStatus); 60 | } 61 | } catch (error: any) { 62 | console.error(`❌ Error checking availability for Season ${seasonNumber}:`, error.message); 63 | } 64 | 65 | if (attempts >= maxRetries) { 66 | console.log(`⏰ Time limit exceeded. Not all episodes in Season ${seasonNumber} are available yet.`); 67 | 68 | // Update the season description with a timeout message 69 | await updatePlexDescription( 70 | ratingKey, 71 | seasonDescription, 72 | `Time limit exceeded. Not all episodes in Season ${seasonNumber} are available yet. Please try again.` 73 | ); 74 | 75 | clearInterval(interval); 76 | resolve(); 77 | } 78 | }, 5000); // Check every 5 seconds 79 | }); 80 | } 81 | 82 | 83 | 84 | // Function to check movie status every 5 seconds 85 | async function monitorAvailability(movieId: number, ratingKey: string, originalFilePath: string, movieDescription: string) { 86 | console.log("🔵 Monitoring availability for ID " + movieId + " the next 5 minutes..."); 87 | 88 | const maxRetries = 60; 89 | let attempts = 0; 90 | 91 | return new Promise((resolve) => { 92 | const interval = setInterval(async () => { 93 | attempts++; 94 | 95 | let requestStatus = `Checking availability for movie (attempt ${attempts}/${maxRetries})...`; 96 | 97 | console.log(`🟡 Checking availability (attempt ${attempts}/${maxRetries})...`); 98 | 99 | const movie = await getMovieStatus(movieId); 100 | 101 | const downloading = await isMovieDownloading(movieId); 102 | 103 | if (downloading) { 104 | console.log(`⏳ Movie ID ${movieId} is currently downloading. Waiting for completion...`); 105 | requestStatus = `Movie is currently downloading. Waiting for completion...`; 106 | } 107 | 108 | // await checkFailedSearches(movieId); // Check for failed searches TEST 109 | 110 | if (movie) { 111 | // Check if the file is a dummy 112 | if (movie.movieFile && movie.movieFile.relativePath === "dummy.mp4") { 113 | console.log("❌ Dummy file detected, ignoring availability and continuing search."); 114 | //await searchMovieInRadarr(movieId); // Force a search 115 | } else if (movie.hasFile) { 116 | console.log(`🎉 Movie "${movie.title}" is now available!`); 117 | 118 | // Terminate the stream for the file 119 | if (originalFilePath) { 120 | await terminateStreamByFile(originalFilePath); 121 | } 122 | 123 | clearInterval(interval); 124 | resolve(); 125 | } else { 126 | await updatePlexDescription(ratingKey, movieDescription, requestStatus); 127 | } 128 | } 129 | 130 | if (attempts >= maxRetries) { 131 | console.log("⏰ Time limit exceeded. The movie is not available yet."); 132 | 133 | // Determine if a torrent has been downloaded; is the movie actually available? We might know this sooner. 134 | 135 | clearInterval(interval); 136 | resolve(); 137 | } 138 | }, 5000); // Check every 5 seconds 139 | }); 140 | } 141 | 142 | const activeRequests = new Set(); // Track active movie requests 143 | 144 | // Route for the Tautulli webhook 145 | app.post("/webhook", async (req: Request, res: Response, next: express.NextFunction): Promise => { 146 | try { 147 | const event = req.body; 148 | 149 | console.log("📩 Event received:", event); 150 | 151 | // Check if it is a playback.start event 152 | if (event && event.event === "playback.start") { 153 | console.log("▶️ Playback started!"); 154 | console.log("📋 Event details:", JSON.stringify(event, null, 2)); 155 | 156 | if (event.media_type === "movie") { 157 | const tmdbId = event.tmdb_id; 158 | const ratingKey = event.rating_key; 159 | 160 | if (!ratingKey) { 161 | console.log("⚠️ No ratingKey received in the request."); 162 | res.status(400).send("Invalid request: missing ratingKey."); 163 | } 164 | 165 | // Check if there is already a request for this ratingKey 166 | if (activeRequests.has(ratingKey)) { 167 | console.log(`🔁 Request for movie with ratingKey ${ratingKey} is already active.`); 168 | res.status(200).send("Request already in progress."); 169 | } 170 | 171 | // Add ratingKey to the set 172 | activeRequests.add(ratingKey); 173 | 174 | try { 175 | if (tmdbId) { 176 | console.log(`🎬 TMDb ID received: ${tmdbId}`); 177 | 178 | // Retrieve movie details from Radarr 179 | const response = await axios.get(`${config.RADARR_URL}/movie?tmdbId=${tmdbId}`, { 180 | headers: { "X-Api-Key": config.RADARR_API_KEY }, 181 | }); 182 | 183 | const movies = response.data; 184 | 185 | if (movies && movies.length > 0) { 186 | const movie = movies[0]; // only and first object 187 | if (movie) { 188 | console.log("✅ Movie found in Radarr:"); 189 | console.log(JSON.stringify(movie, null, 2)); 190 | 191 | const originalFilePath = event.file; 192 | 193 | // Check availability 194 | if (!movie.hasFile || (movie.movieFile && movie.movieFile.relativePath === "dummy.mp4")) { 195 | console.log("❌ Dummy file detected or movie not available. Initiating search..."); 196 | const movieDescription = movie.overview; 197 | 198 | let requestStatus = "The movie is being requested. Please wait a few moments while it becomes available."; 199 | updatePlexDescription(ratingKey, movieDescription, requestStatus); 200 | 201 | searchMovieInRadarr(movie.id, config.RADARR_URL, config.RADARR_API_KEY); 202 | console.log("Movie ID:", movie.id) 203 | // Start monitoring for availability 204 | await monitorAvailability(movie.id, ratingKey, originalFilePath, movieDescription); 205 | } else { 206 | console.log(`🎉 Movie "${movie.title}" is already available!`); 207 | } 208 | } else { 209 | console.log("❌ No movie found in Radarr with the given TMDb ID."); 210 | } 211 | } else { 212 | console.log("❌ Movie not found in Radarr."); 213 | } 214 | } else { 215 | console.log("⚠️ No IMDb ID received in the request."); 216 | } 217 | } catch (error: any) { 218 | console.error(`❌ Error processing the request: ${error.message}`); 219 | } finally { 220 | // Remove ratingKey from the set after completion 221 | activeRequests.delete(ratingKey); 222 | console.log(`✅ Request for movie with ratingKey ${ratingKey} completed.`); 223 | } 224 | } else if ( 225 | event.media_type === "show" || 226 | event.media_type === "season" || 227 | event.media_type === "episode" 228 | ) { 229 | const filePath = event.file; 230 | const ratingKey = event.rating_key; 231 | const seasonNumber = event.season_num; 232 | const tvdbId = event.thetvdb_id; // Tautulli provides thetvdb_id 233 | 234 | if (!filePath || !ratingKey || !tvdbId) { 235 | console.log("⚠️ Missing file path, ratingKey, or tvdbId in the request."); 236 | res.status(400).send("Invalid request: missing required parameters."); 237 | return; 238 | } 239 | 240 | if (!filePath.endsWith("(dummy).mp4")) { 241 | console.log("ℹ️ This is not a dummy file playback. Ignoring."); 242 | res.status(200).send("Not a dummy file playback."); 243 | return; 244 | } 245 | 246 | if (activeRequests.has(ratingKey)) { 247 | console.log(`🔁 Request for series with ratingKey ${ratingKey} is already active.`); 248 | res.status(200).send("Request already in progress."); 249 | return; 250 | } 251 | 252 | activeRequests.add(ratingKey); 253 | 254 | try { 255 | console.log(`📺 Series playback detected with TVDB ID: ${tvdbId}`); 256 | 257 | // Get series information from Sonarr using tvdbId 258 | const series = await getSeriesByTvdbId(tvdbId, config.SONARR_URL, config.SONARR_API_KEY); 259 | 260 | if (!series) { 261 | console.log(`❌ No series found in Sonarr for TVDB ID: ${tvdbId}`); 262 | res.status(404).send("Series not found in Sonarr."); 263 | return; 264 | } 265 | 266 | console.log(`✅ Found series in Sonarr: ${series.title}`); 267 | 268 | // To-do; don't monitor the specials! (season 0) 269 | // Current code doesn't work and still monitors the specials. 270 | await monitorAllSeasons(series.id, config.SONARR_URL, config.SONARR_API_KEY); 271 | 272 | console.log(`🔍 Searching for season ${seasonNumber} in Sonarr...`); 273 | await searchSeriesInSonarr(series.id, seasonNumber, config.SONARR_URL, config.SONARR_API_KEY); 274 | 275 | // To-do; make monitoring all seasons optional! 276 | console.log("🔄 Monitoring the entire series in Sonarr..."); 277 | // Eerst de aflevering die gevraagd wordt; deze wil de gebruiker afspelen en ook de status voor terugkrijgen. Scheelt weer wat seconden voor de gebruiker. 278 | await monitorSeasonAvailability(series.id, seasonNumber, ratingKey, "Checking availability for Season..."); 279 | 280 | 281 | // To-do; make this optional! 282 | console.log("🔍 Searching for the rest of the seasons in Sonarr..."); 283 | await searchSeriesInSonarr(series.id, null, config.SONARR_URL, config.SONARR_API_KEY); 284 | 285 | 286 | } catch (error: any) { 287 | console.error(`❌ Error processing the series request: ${error.message}`); 288 | } finally { 289 | activeRequests.delete(ratingKey); 290 | console.log(`✅ Request for series with ratingKey ${ratingKey} completed.`); 291 | } 292 | } 293 | } else { 294 | console.log("⚠️ Received event is not playback.start:", event.event); 295 | } 296 | 297 | res.status(200).send("Webhook received."); 298 | } catch (error) { 299 | console.error("❌ Error processing the webhook:", error); 300 | res.status(500).send("Internal Server Error"); 301 | } 302 | }); 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | app.post("/sonarr-webhook", async (req: Request, res: Response) => { 333 | const event = req.body; 334 | 335 | console.log("📩 Sonarr Webhook received:", event); 336 | 337 | if (event && event.eventType === "SeriesAdd" && event.series) { 338 | const series = event.series; 339 | const seriesId = series.id; 340 | const seriesTitle = series.title; 341 | const seriesPath = series.path; 342 | const dummySeriesFolder = path.join(config.SERIES_FOLDER_DUMMY, path.basename(seriesPath)); 343 | const sonarrTag = config.SONARR_MONITOR_TAG_NAME; 344 | 345 | try { 346 | // Check if the series contains the required tag 347 | if (!series.tags.includes(sonarrTag)) { 348 | console.log(`❌ Series "${seriesTitle}" does not contain the required tag "${sonarrTag}".`); 349 | res.status(200).send("Series does not contain required tag."); 350 | } 351 | 352 | console.log(`🎬 New series added with tag "${sonarrTag}": ${seriesTitle}`); 353 | 354 | // Ensure the series folder exists in both locations 355 | await ensureDirectoryExists(dummySeriesFolder); 356 | await ensureDirectoryExists(seriesPath); 357 | 358 | // Get all episodes for the series 359 | const episodes = await getEpisodesBySeriesId(seriesId); 360 | 361 | // Group episodes by season 362 | const episodesBySeason = groupEpisodesBySeason(episodes); 363 | 364 | // Loop through seasons and create dummy files for released seasons 365 | for (const [seasonNumber, seasonEpisodes] of Object.entries(episodesBySeason)) { 366 | if (seasonNumber === "0") { 367 | console.log(`⏭️ Skipping specials (Season 0) for "${seriesTitle}".`); 368 | continue; // Skip specials 369 | } 370 | 371 | const releasedEpisodes = seasonEpisodes.filter( 372 | (episode: any) => new Date(episode.airDate) <= new Date() 373 | ); 374 | 375 | if (releasedEpisodes.length > 0) { 376 | console.log(`📁 Creating dummy file for Season ${seasonNumber} of "${seriesTitle}".`); 377 | 378 | // Construct paths for season folders 379 | const dummySeasonFolder = path.join(dummySeriesFolder, `Season ${seasonNumber}`); 380 | const plexSeasonFolder = path.join(seriesPath, `Season ${seasonNumber}`); 381 | 382 | // Ensure season folders exist 383 | await ensureDirectoryExists(dummySeasonFolder); 384 | await ensureDirectoryExists(plexSeasonFolder); 385 | 386 | // Create dummy file path 387 | // https://support.plex.tv/articles/naming-and-organizing-your-tv-show-files/ (Multiple Episodes in a Single File) 388 | const dummyFileName = `${seriesTitle} – s${String(seasonNumber).padStart(2, "0")}e01-e${String( 389 | releasedEpisodes.length 390 | ).padStart(2, "0")} (dummy).mp4`; // To-do: where to add dummy tag in file name? Plex wants the serie name in the file. 391 | 392 | const dummyFilePath = path.join(dummySeasonFolder, dummyFileName); 393 | const plexLinkPath = path.join(plexSeasonFolder, dummyFileName); 394 | 395 | // Create dummy file 396 | await createDummyFile(config.DUMMY_FILE_LOCATION, dummyFilePath); 397 | 398 | // Create symlink in Plex folder 399 | await createSymlink(dummyFilePath, plexLinkPath); 400 | 401 | console.log(`✅ Dummy file and symlink created for Season ${seasonNumber} of "${seriesTitle}".`); 402 | } else { 403 | console.log(`⏳ Season ${seasonNumber} of "${seriesTitle}" has no released episodes yet.`); 404 | } 405 | } 406 | 407 | // Refresh Plex library for the new series folder 408 | const seriesFolderName = path.basename(seriesPath); // Get series folder name 409 | const seriesFolder = path.join(config.PLEX_SERIES_FOLDER, seriesFolderName); // Plex series path 410 | await notifyPlexFolderRefresh(seriesFolder, config.PLEX_SERIES_LIBRARY_ID); 411 | 412 | res.status(200).send("Series processed and dummy files created."); 413 | } catch (error: any) { 414 | console.error(`❌ Error processing series "${seriesTitle}":`, error.message); 415 | res.status(500).send("Error processing series."); 416 | } 417 | } else if (event.eventType === "Download" && event.series && event.episodes && event.episodeFile) { 418 | const series = event.series; 419 | const episode = event.episodes[0]; 420 | const episodeFile = event.episodeFile; 421 | 422 | const seasonNumber = episode.seasonNumber; 423 | const seriesFolder = series.path; 424 | const dummySeasonFolder = path.join( 425 | config.SERIES_FOLDER_DUMMY, 426 | path.basename(seriesFolder), 427 | `Season ${seasonNumber}` 428 | ); 429 | 430 | console.log( 431 | `🎬 File imported for series: ${series.title} (ID: ${series.id}, Season: ${seasonNumber}, Episode: ${episode.episodeNumber}).` 432 | ); 433 | 434 | console.log(`📁 Dummy folder for cleanup: ${dummySeasonFolder}`); 435 | 436 | // Cleanup the dummy file for the season 437 | await cleanUpDummyFile(dummySeasonFolder); 438 | 439 | // Remove the dummy folder for the season if it exists 440 | await removeDummyFolder(dummySeasonFolder); 441 | 442 | // Notify Plex to refresh the series folder 443 | await notifyPlexFolderRefresh(seriesFolder, config.PLEX_SERIES_LIBRARY_ID); 444 | 445 | console.log(`✅ Successfully processed import for series: ${series.title}, Season: ${seasonNumber}, Episode: ${episode.episodeNumber}.`); 446 | 447 | res.status(200).send("Sonarr Download event processed successfully."); 448 | 449 | } else { 450 | console.log("⚠️ No valid Sonarr event received."); 451 | res.status(200).send("Invalid Sonarr event."); 452 | } 453 | }); 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | app.post("/radarr-webhook", async (req: Request, res: Response) => { 487 | const event = req.body; 488 | 489 | console.log("📩 Radarr Webhook received:", event); 490 | 491 | // Check if it is an event for a new movie 492 | if (event && event.eventType === "MovieAdded" && event.movie && event.movie.folderPath) { 493 | let movieFolder = event.movie.folderPath; 494 | const movieFolderDummy = path.join(config.MOVIE_FOLDER_DUMMY, path.basename(movieFolder)); 495 | const movieFolderPlex = path.join(config.PLEX_MOVIE_FOLDER, path.basename(movieFolder)); 496 | 497 | const dummySource = config.DUMMY_FILE_LOCATION; // Path to the dummy file 498 | const dummyLink = path.join(movieFolderDummy, "dummy.mp4"); // Symlink target 499 | 500 | const plexLink = path.join(movieFolderPlex, "dummy.mp4"); // Symlink target 501 | 502 | const radarrTag = config.RADARR_MONITOR_TAG_NAME; 503 | 504 | if (event.movie.tags.includes(radarrTag)) { 505 | 506 | console.log(`🎬 New movie added with ${radarrTag} tag. Folder: ${movieFolderDummy}`); 507 | 508 | try { 509 | // Ensure the directory exists 510 | await ensureDirectoryExists(movieFolderDummy); 511 | await ensureDirectoryExists(movieFolder); 512 | 513 | // Create dummy file 514 | await createDummyFile(dummySource, dummyLink); 515 | 516 | // Create the symlink 517 | await createSymlink(dummyLink, plexLink); 518 | 519 | movieFolder = path.join(config.PLEX_MOVIE_FOLDER, path.basename(movieFolder)); // Temporary because my Plex has a different path 520 | await notifyPlexFolderRefresh(movieFolder, config.PLEX_MOVIES_LIBRARY_ID); 521 | 522 | res.status(200).send("Symlink created and Plex folder notified successfully."); 523 | } catch (error) { 524 | res.status(500).send("Error while processing the webhook."); 525 | } 526 | } else { 527 | const receivedTags = event.movie.tags.join(", "); 528 | console.log(`❌ Movie "${event.movie.title}" does not contain the required tag "${radarrTag}". Received tags: [${receivedTags}]`); 529 | } 530 | } else if (event && event.eventType === "Download" && event.movie && event.movie.folderPath) { 531 | console.log(event); 532 | let movieFolder = event.movie.folderPath; 533 | const imdbId = event.movie.imdbId; 534 | const tmdbId = event.movie.tmdbId; 535 | 536 | const movieFolderDummy = path.join(config.MOVIE_FOLDER_DUMMY, path.basename(movieFolder)); 537 | 538 | console.log(`🎬 File imported for movie: ${event.movie.title} (${event.movie.year}). Folder: ${movieFolder}`); 539 | 540 | // Call the function to clean up dummy.mp4 541 | await cleanUpDummyFile(movieFolder); 542 | await removeDummyFolder(movieFolderDummy); 543 | 544 | // Add movie to 4K Radarr instance if configured 545 | if (config.RADARR_4K_URL) { 546 | (async () => { 547 | try { 548 | const [exists, movieDetails] = await checkMovieInRadarr(tmdbId, config.RADARR_4K_URL!, config.RADARR_4K_API_KEY!); 549 | 550 | if (exists) { 551 | if (!movieDetails.hasFile || (movieDetails.movieFile && movieDetails.movieFile.relativePath === "dummy.mp4")) { 552 | // Movie not available in 4K instance yet 553 | await searchMovieInRadarr(movieDetails.id, config.RADARR_4K_URL!, config.RADARR_4K_API_KEY!); 554 | } 555 | 556 | console.log(`✅ Movie already exists in Radarr: ${movieDetails.title}`); 557 | } else { 558 | console.log(`❌ Movie not found in Radarr. Adding...`); 559 | 560 | // Add the movie with default parameters 561 | const newMovie = await addMovieToRadarr(tmdbId, config.RADARR_4K_MOVIE_FOLDER!, 562 | Number(config.RADARR_4K_QUALITY_PROFILE_ID), true, true, config.RADARR_4K_URL!, config.RADARR_4K_API_KEY!, ["infiniteplexlibrary"]); 563 | 564 | console.log(`🎥 Movie added to Radarr 4K: ${newMovie.title}`); 565 | } 566 | } catch (error: any) { 567 | console.error(`❌ Error processing the movie: ${error.message}`); 568 | } 569 | })(); 570 | } 571 | 572 | // Notify Plex folder refresh 573 | movieFolder = path.join(config.PLEX_MOVIE_FOLDER, path.basename(movieFolder)); // Temporary because my Plex has a different path 574 | await notifyPlexFolderRefresh(movieFolder, config.PLEX_MOVIES_LIBRARY_ID); 575 | 576 | res.status(200).send("File import processed successfully."); 577 | } else { 578 | console.log("⚠️ No valid event received."); 579 | res.status(200).send("Invalid event."); // should be 500 // rewrite the webhook part for sonarr test webhook 580 | } 581 | }); 582 | 583 | // Start the server 584 | app.listen(PORT, () => { 585 | console.log(`🚀 Webhook server is listening on port ${PORT}`); 586 | }); -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "infiniteplexlibrarry", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "infiniteplexlibrarry", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "axios": "^1.7.7", 13 | "body-parser": "^1.20.3", 14 | "chalk": "^5.3.0", 15 | "dayjs": "^1.11.13", 16 | "dotenv": "^16.4.5", 17 | "express": "^4.21.1", 18 | "node-cron": "^3.0.3" 19 | }, 20 | "devDependencies": { 21 | "@types/axios": "^0.14.4", 22 | "@types/express": "^5.0.0", 23 | "@types/node": "^22.9.3", 24 | "@types/node-cron": "^3.0.11", 25 | "typescript": "^5.7.2" 26 | } 27 | }, 28 | "node_modules/@types/axios": { 29 | "version": "0.14.4", 30 | "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz", 31 | "integrity": "sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==", 32 | "deprecated": "This is a stub types definition. axios provides its own type definitions, so you do not need this installed.", 33 | "dev": true, 34 | "dependencies": { 35 | "axios": "*" 36 | } 37 | }, 38 | "node_modules/@types/body-parser": { 39 | "version": "1.19.5", 40 | "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", 41 | "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", 42 | "dev": true, 43 | "dependencies": { 44 | "@types/connect": "*", 45 | "@types/node": "*" 46 | } 47 | }, 48 | "node_modules/@types/connect": { 49 | "version": "3.4.38", 50 | "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", 51 | "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", 52 | "dev": true, 53 | "dependencies": { 54 | "@types/node": "*" 55 | } 56 | }, 57 | "node_modules/@types/express": { 58 | "version": "5.0.0", 59 | "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", 60 | "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", 61 | "dev": true, 62 | "dependencies": { 63 | "@types/body-parser": "*", 64 | "@types/express-serve-static-core": "^5.0.0", 65 | "@types/qs": "*", 66 | "@types/serve-static": "*" 67 | } 68 | }, 69 | "node_modules/@types/express-serve-static-core": { 70 | "version": "5.0.1", 71 | "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.1.tgz", 72 | "integrity": "sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==", 73 | "dev": true, 74 | "dependencies": { 75 | "@types/node": "*", 76 | "@types/qs": "*", 77 | "@types/range-parser": "*", 78 | "@types/send": "*" 79 | } 80 | }, 81 | "node_modules/@types/http-errors": { 82 | "version": "2.0.4", 83 | "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", 84 | "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", 85 | "dev": true 86 | }, 87 | "node_modules/@types/mime": { 88 | "version": "1.3.5", 89 | "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", 90 | "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", 91 | "dev": true 92 | }, 93 | "node_modules/@types/node": { 94 | "version": "22.9.3", 95 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.3.tgz", 96 | "integrity": "sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==", 97 | "dev": true, 98 | "dependencies": { 99 | "undici-types": "~6.19.8" 100 | } 101 | }, 102 | "node_modules/@types/node-cron": { 103 | "version": "3.0.11", 104 | "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", 105 | "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", 106 | "dev": true 107 | }, 108 | "node_modules/@types/qs": { 109 | "version": "6.9.17", 110 | "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", 111 | "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", 112 | "dev": true 113 | }, 114 | "node_modules/@types/range-parser": { 115 | "version": "1.2.7", 116 | "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", 117 | "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", 118 | "dev": true 119 | }, 120 | "node_modules/@types/send": { 121 | "version": "0.17.4", 122 | "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", 123 | "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", 124 | "dev": true, 125 | "dependencies": { 126 | "@types/mime": "^1", 127 | "@types/node": "*" 128 | } 129 | }, 130 | "node_modules/@types/serve-static": { 131 | "version": "1.15.7", 132 | "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", 133 | "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", 134 | "dev": true, 135 | "dependencies": { 136 | "@types/http-errors": "*", 137 | "@types/node": "*", 138 | "@types/send": "*" 139 | } 140 | }, 141 | "node_modules/accepts": { 142 | "version": "1.3.8", 143 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", 144 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 145 | "dependencies": { 146 | "mime-types": "~2.1.34", 147 | "negotiator": "0.6.3" 148 | }, 149 | "engines": { 150 | "node": ">= 0.6" 151 | } 152 | }, 153 | "node_modules/array-flatten": { 154 | "version": "1.1.1", 155 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 156 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" 157 | }, 158 | "node_modules/asynckit": { 159 | "version": "0.4.0", 160 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 161 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 162 | }, 163 | "node_modules/axios": { 164 | "version": "1.7.7", 165 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", 166 | "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", 167 | "dependencies": { 168 | "follow-redirects": "^1.15.6", 169 | "form-data": "^4.0.0", 170 | "proxy-from-env": "^1.1.0" 171 | } 172 | }, 173 | "node_modules/body-parser": { 174 | "version": "1.20.3", 175 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", 176 | "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", 177 | "dependencies": { 178 | "bytes": "3.1.2", 179 | "content-type": "~1.0.5", 180 | "debug": "2.6.9", 181 | "depd": "2.0.0", 182 | "destroy": "1.2.0", 183 | "http-errors": "2.0.0", 184 | "iconv-lite": "0.4.24", 185 | "on-finished": "2.4.1", 186 | "qs": "6.13.0", 187 | "raw-body": "2.5.2", 188 | "type-is": "~1.6.18", 189 | "unpipe": "1.0.0" 190 | }, 191 | "engines": { 192 | "node": ">= 0.8", 193 | "npm": "1.2.8000 || >= 1.4.16" 194 | } 195 | }, 196 | "node_modules/bytes": { 197 | "version": "3.1.2", 198 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 199 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", 200 | "engines": { 201 | "node": ">= 0.8" 202 | } 203 | }, 204 | "node_modules/call-bind": { 205 | "version": "1.0.7", 206 | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", 207 | "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", 208 | "dependencies": { 209 | "es-define-property": "^1.0.0", 210 | "es-errors": "^1.3.0", 211 | "function-bind": "^1.1.2", 212 | "get-intrinsic": "^1.2.4", 213 | "set-function-length": "^1.2.1" 214 | }, 215 | "engines": { 216 | "node": ">= 0.4" 217 | }, 218 | "funding": { 219 | "url": "https://github.com/sponsors/ljharb" 220 | } 221 | }, 222 | "node_modules/chalk": { 223 | "version": "5.3.0", 224 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", 225 | "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", 226 | "engines": { 227 | "node": "^12.17.0 || ^14.13 || >=16.0.0" 228 | }, 229 | "funding": { 230 | "url": "https://github.com/chalk/chalk?sponsor=1" 231 | } 232 | }, 233 | "node_modules/combined-stream": { 234 | "version": "1.0.8", 235 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 236 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 237 | "dependencies": { 238 | "delayed-stream": "~1.0.0" 239 | }, 240 | "engines": { 241 | "node": ">= 0.8" 242 | } 243 | }, 244 | "node_modules/content-disposition": { 245 | "version": "0.5.4", 246 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 247 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 248 | "dependencies": { 249 | "safe-buffer": "5.2.1" 250 | }, 251 | "engines": { 252 | "node": ">= 0.6" 253 | } 254 | }, 255 | "node_modules/content-type": { 256 | "version": "1.0.5", 257 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", 258 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", 259 | "engines": { 260 | "node": ">= 0.6" 261 | } 262 | }, 263 | "node_modules/cookie": { 264 | "version": "0.7.1", 265 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", 266 | "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", 267 | "engines": { 268 | "node": ">= 0.6" 269 | } 270 | }, 271 | "node_modules/cookie-signature": { 272 | "version": "1.0.6", 273 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 274 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" 275 | }, 276 | "node_modules/dayjs": { 277 | "version": "1.11.13", 278 | "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", 279 | "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" 280 | }, 281 | "node_modules/debug": { 282 | "version": "2.6.9", 283 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 284 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 285 | "dependencies": { 286 | "ms": "2.0.0" 287 | } 288 | }, 289 | "node_modules/define-data-property": { 290 | "version": "1.1.4", 291 | "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", 292 | "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 293 | "dependencies": { 294 | "es-define-property": "^1.0.0", 295 | "es-errors": "^1.3.0", 296 | "gopd": "^1.0.1" 297 | }, 298 | "engines": { 299 | "node": ">= 0.4" 300 | }, 301 | "funding": { 302 | "url": "https://github.com/sponsors/ljharb" 303 | } 304 | }, 305 | "node_modules/delayed-stream": { 306 | "version": "1.0.0", 307 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 308 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", 309 | "engines": { 310 | "node": ">=0.4.0" 311 | } 312 | }, 313 | "node_modules/depd": { 314 | "version": "2.0.0", 315 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 316 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", 317 | "engines": { 318 | "node": ">= 0.8" 319 | } 320 | }, 321 | "node_modules/destroy": { 322 | "version": "1.2.0", 323 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", 324 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", 325 | "engines": { 326 | "node": ">= 0.8", 327 | "npm": "1.2.8000 || >= 1.4.16" 328 | } 329 | }, 330 | "node_modules/dotenv": { 331 | "version": "16.4.5", 332 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", 333 | "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", 334 | "engines": { 335 | "node": ">=12" 336 | }, 337 | "funding": { 338 | "url": "https://dotenvx.com" 339 | } 340 | }, 341 | "node_modules/ee-first": { 342 | "version": "1.1.1", 343 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 344 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 345 | }, 346 | "node_modules/encodeurl": { 347 | "version": "2.0.0", 348 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", 349 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", 350 | "engines": { 351 | "node": ">= 0.8" 352 | } 353 | }, 354 | "node_modules/es-define-property": { 355 | "version": "1.0.0", 356 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", 357 | "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", 358 | "dependencies": { 359 | "get-intrinsic": "^1.2.4" 360 | }, 361 | "engines": { 362 | "node": ">= 0.4" 363 | } 364 | }, 365 | "node_modules/es-errors": { 366 | "version": "1.3.0", 367 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 368 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 369 | "engines": { 370 | "node": ">= 0.4" 371 | } 372 | }, 373 | "node_modules/escape-html": { 374 | "version": "1.0.3", 375 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 376 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 377 | }, 378 | "node_modules/etag": { 379 | "version": "1.8.1", 380 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 381 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", 382 | "engines": { 383 | "node": ">= 0.6" 384 | } 385 | }, 386 | "node_modules/express": { 387 | "version": "4.21.1", 388 | "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", 389 | "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", 390 | "dependencies": { 391 | "accepts": "~1.3.8", 392 | "array-flatten": "1.1.1", 393 | "body-parser": "1.20.3", 394 | "content-disposition": "0.5.4", 395 | "content-type": "~1.0.4", 396 | "cookie": "0.7.1", 397 | "cookie-signature": "1.0.6", 398 | "debug": "2.6.9", 399 | "depd": "2.0.0", 400 | "encodeurl": "~2.0.0", 401 | "escape-html": "~1.0.3", 402 | "etag": "~1.8.1", 403 | "finalhandler": "1.3.1", 404 | "fresh": "0.5.2", 405 | "http-errors": "2.0.0", 406 | "merge-descriptors": "1.0.3", 407 | "methods": "~1.1.2", 408 | "on-finished": "2.4.1", 409 | "parseurl": "~1.3.3", 410 | "path-to-regexp": "0.1.10", 411 | "proxy-addr": "~2.0.7", 412 | "qs": "6.13.0", 413 | "range-parser": "~1.2.1", 414 | "safe-buffer": "5.2.1", 415 | "send": "0.19.0", 416 | "serve-static": "1.16.2", 417 | "setprototypeof": "1.2.0", 418 | "statuses": "2.0.1", 419 | "type-is": "~1.6.18", 420 | "utils-merge": "1.0.1", 421 | "vary": "~1.1.2" 422 | }, 423 | "engines": { 424 | "node": ">= 0.10.0" 425 | } 426 | }, 427 | "node_modules/finalhandler": { 428 | "version": "1.3.1", 429 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", 430 | "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", 431 | "dependencies": { 432 | "debug": "2.6.9", 433 | "encodeurl": "~2.0.0", 434 | "escape-html": "~1.0.3", 435 | "on-finished": "2.4.1", 436 | "parseurl": "~1.3.3", 437 | "statuses": "2.0.1", 438 | "unpipe": "~1.0.0" 439 | }, 440 | "engines": { 441 | "node": ">= 0.8" 442 | } 443 | }, 444 | "node_modules/follow-redirects": { 445 | "version": "1.15.9", 446 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 447 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", 448 | "funding": [ 449 | { 450 | "type": "individual", 451 | "url": "https://github.com/sponsors/RubenVerborgh" 452 | } 453 | ], 454 | "engines": { 455 | "node": ">=4.0" 456 | }, 457 | "peerDependenciesMeta": { 458 | "debug": { 459 | "optional": true 460 | } 461 | } 462 | }, 463 | "node_modules/form-data": { 464 | "version": "4.0.1", 465 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", 466 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 467 | "dependencies": { 468 | "asynckit": "^0.4.0", 469 | "combined-stream": "^1.0.8", 470 | "mime-types": "^2.1.12" 471 | }, 472 | "engines": { 473 | "node": ">= 6" 474 | } 475 | }, 476 | "node_modules/forwarded": { 477 | "version": "0.2.0", 478 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 479 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", 480 | "engines": { 481 | "node": ">= 0.6" 482 | } 483 | }, 484 | "node_modules/fresh": { 485 | "version": "0.5.2", 486 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 487 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", 488 | "engines": { 489 | "node": ">= 0.6" 490 | } 491 | }, 492 | "node_modules/function-bind": { 493 | "version": "1.1.2", 494 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 495 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 496 | "funding": { 497 | "url": "https://github.com/sponsors/ljharb" 498 | } 499 | }, 500 | "node_modules/get-intrinsic": { 501 | "version": "1.2.4", 502 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", 503 | "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", 504 | "dependencies": { 505 | "es-errors": "^1.3.0", 506 | "function-bind": "^1.1.2", 507 | "has-proto": "^1.0.1", 508 | "has-symbols": "^1.0.3", 509 | "hasown": "^2.0.0" 510 | }, 511 | "engines": { 512 | "node": ">= 0.4" 513 | }, 514 | "funding": { 515 | "url": "https://github.com/sponsors/ljharb" 516 | } 517 | }, 518 | "node_modules/gopd": { 519 | "version": "1.0.1", 520 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", 521 | "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", 522 | "dependencies": { 523 | "get-intrinsic": "^1.1.3" 524 | }, 525 | "funding": { 526 | "url": "https://github.com/sponsors/ljharb" 527 | } 528 | }, 529 | "node_modules/has-property-descriptors": { 530 | "version": "1.0.2", 531 | "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", 532 | "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 533 | "dependencies": { 534 | "es-define-property": "^1.0.0" 535 | }, 536 | "funding": { 537 | "url": "https://github.com/sponsors/ljharb" 538 | } 539 | }, 540 | "node_modules/has-proto": { 541 | "version": "1.0.3", 542 | "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", 543 | "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", 544 | "engines": { 545 | "node": ">= 0.4" 546 | }, 547 | "funding": { 548 | "url": "https://github.com/sponsors/ljharb" 549 | } 550 | }, 551 | "node_modules/has-symbols": { 552 | "version": "1.0.3", 553 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", 554 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", 555 | "engines": { 556 | "node": ">= 0.4" 557 | }, 558 | "funding": { 559 | "url": "https://github.com/sponsors/ljharb" 560 | } 561 | }, 562 | "node_modules/hasown": { 563 | "version": "2.0.2", 564 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 565 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 566 | "dependencies": { 567 | "function-bind": "^1.1.2" 568 | }, 569 | "engines": { 570 | "node": ">= 0.4" 571 | } 572 | }, 573 | "node_modules/http-errors": { 574 | "version": "2.0.0", 575 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 576 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 577 | "dependencies": { 578 | "depd": "2.0.0", 579 | "inherits": "2.0.4", 580 | "setprototypeof": "1.2.0", 581 | "statuses": "2.0.1", 582 | "toidentifier": "1.0.1" 583 | }, 584 | "engines": { 585 | "node": ">= 0.8" 586 | } 587 | }, 588 | "node_modules/iconv-lite": { 589 | "version": "0.4.24", 590 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 591 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 592 | "dependencies": { 593 | "safer-buffer": ">= 2.1.2 < 3" 594 | }, 595 | "engines": { 596 | "node": ">=0.10.0" 597 | } 598 | }, 599 | "node_modules/inherits": { 600 | "version": "2.0.4", 601 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 602 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 603 | }, 604 | "node_modules/ipaddr.js": { 605 | "version": "1.9.1", 606 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 607 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", 608 | "engines": { 609 | "node": ">= 0.10" 610 | } 611 | }, 612 | "node_modules/media-typer": { 613 | "version": "0.3.0", 614 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 615 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", 616 | "engines": { 617 | "node": ">= 0.6" 618 | } 619 | }, 620 | "node_modules/merge-descriptors": { 621 | "version": "1.0.3", 622 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", 623 | "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", 624 | "funding": { 625 | "url": "https://github.com/sponsors/sindresorhus" 626 | } 627 | }, 628 | "node_modules/methods": { 629 | "version": "1.1.2", 630 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 631 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", 632 | "engines": { 633 | "node": ">= 0.6" 634 | } 635 | }, 636 | "node_modules/mime": { 637 | "version": "1.6.0", 638 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 639 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 640 | "bin": { 641 | "mime": "cli.js" 642 | }, 643 | "engines": { 644 | "node": ">=4" 645 | } 646 | }, 647 | "node_modules/mime-db": { 648 | "version": "1.52.0", 649 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 650 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 651 | "engines": { 652 | "node": ">= 0.6" 653 | } 654 | }, 655 | "node_modules/mime-types": { 656 | "version": "2.1.35", 657 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 658 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 659 | "dependencies": { 660 | "mime-db": "1.52.0" 661 | }, 662 | "engines": { 663 | "node": ">= 0.6" 664 | } 665 | }, 666 | "node_modules/ms": { 667 | "version": "2.0.0", 668 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 669 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" 670 | }, 671 | "node_modules/negotiator": { 672 | "version": "0.6.3", 673 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 674 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", 675 | "engines": { 676 | "node": ">= 0.6" 677 | } 678 | }, 679 | "node_modules/node-cron": { 680 | "version": "3.0.3", 681 | "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", 682 | "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", 683 | "dependencies": { 684 | "uuid": "8.3.2" 685 | }, 686 | "engines": { 687 | "node": ">=6.0.0" 688 | } 689 | }, 690 | "node_modules/object-inspect": { 691 | "version": "1.13.3", 692 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", 693 | "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", 694 | "engines": { 695 | "node": ">= 0.4" 696 | }, 697 | "funding": { 698 | "url": "https://github.com/sponsors/ljharb" 699 | } 700 | }, 701 | "node_modules/on-finished": { 702 | "version": "2.4.1", 703 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", 704 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 705 | "dependencies": { 706 | "ee-first": "1.1.1" 707 | }, 708 | "engines": { 709 | "node": ">= 0.8" 710 | } 711 | }, 712 | "node_modules/parseurl": { 713 | "version": "1.3.3", 714 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 715 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", 716 | "engines": { 717 | "node": ">= 0.8" 718 | } 719 | }, 720 | "node_modules/path-to-regexp": { 721 | "version": "0.1.10", 722 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", 723 | "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" 724 | }, 725 | "node_modules/proxy-addr": { 726 | "version": "2.0.7", 727 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 728 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 729 | "dependencies": { 730 | "forwarded": "0.2.0", 731 | "ipaddr.js": "1.9.1" 732 | }, 733 | "engines": { 734 | "node": ">= 0.10" 735 | } 736 | }, 737 | "node_modules/proxy-from-env": { 738 | "version": "1.1.0", 739 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 740 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 741 | }, 742 | "node_modules/qs": { 743 | "version": "6.13.0", 744 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 745 | "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 746 | "dependencies": { 747 | "side-channel": "^1.0.6" 748 | }, 749 | "engines": { 750 | "node": ">=0.6" 751 | }, 752 | "funding": { 753 | "url": "https://github.com/sponsors/ljharb" 754 | } 755 | }, 756 | "node_modules/range-parser": { 757 | "version": "1.2.1", 758 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 759 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", 760 | "engines": { 761 | "node": ">= 0.6" 762 | } 763 | }, 764 | "node_modules/raw-body": { 765 | "version": "2.5.2", 766 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", 767 | "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", 768 | "dependencies": { 769 | "bytes": "3.1.2", 770 | "http-errors": "2.0.0", 771 | "iconv-lite": "0.4.24", 772 | "unpipe": "1.0.0" 773 | }, 774 | "engines": { 775 | "node": ">= 0.8" 776 | } 777 | }, 778 | "node_modules/safe-buffer": { 779 | "version": "5.2.1", 780 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 781 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 782 | "funding": [ 783 | { 784 | "type": "github", 785 | "url": "https://github.com/sponsors/feross" 786 | }, 787 | { 788 | "type": "patreon", 789 | "url": "https://www.patreon.com/feross" 790 | }, 791 | { 792 | "type": "consulting", 793 | "url": "https://feross.org/support" 794 | } 795 | ] 796 | }, 797 | "node_modules/safer-buffer": { 798 | "version": "2.1.2", 799 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 800 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 801 | }, 802 | "node_modules/send": { 803 | "version": "0.19.0", 804 | "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", 805 | "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", 806 | "dependencies": { 807 | "debug": "2.6.9", 808 | "depd": "2.0.0", 809 | "destroy": "1.2.0", 810 | "encodeurl": "~1.0.2", 811 | "escape-html": "~1.0.3", 812 | "etag": "~1.8.1", 813 | "fresh": "0.5.2", 814 | "http-errors": "2.0.0", 815 | "mime": "1.6.0", 816 | "ms": "2.1.3", 817 | "on-finished": "2.4.1", 818 | "range-parser": "~1.2.1", 819 | "statuses": "2.0.1" 820 | }, 821 | "engines": { 822 | "node": ">= 0.8.0" 823 | } 824 | }, 825 | "node_modules/send/node_modules/encodeurl": { 826 | "version": "1.0.2", 827 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 828 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", 829 | "engines": { 830 | "node": ">= 0.8" 831 | } 832 | }, 833 | "node_modules/send/node_modules/ms": { 834 | "version": "2.1.3", 835 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 836 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 837 | }, 838 | "node_modules/serve-static": { 839 | "version": "1.16.2", 840 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", 841 | "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", 842 | "dependencies": { 843 | "encodeurl": "~2.0.0", 844 | "escape-html": "~1.0.3", 845 | "parseurl": "~1.3.3", 846 | "send": "0.19.0" 847 | }, 848 | "engines": { 849 | "node": ">= 0.8.0" 850 | } 851 | }, 852 | "node_modules/set-function-length": { 853 | "version": "1.2.2", 854 | "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", 855 | "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", 856 | "dependencies": { 857 | "define-data-property": "^1.1.4", 858 | "es-errors": "^1.3.0", 859 | "function-bind": "^1.1.2", 860 | "get-intrinsic": "^1.2.4", 861 | "gopd": "^1.0.1", 862 | "has-property-descriptors": "^1.0.2" 863 | }, 864 | "engines": { 865 | "node": ">= 0.4" 866 | } 867 | }, 868 | "node_modules/setprototypeof": { 869 | "version": "1.2.0", 870 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 871 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 872 | }, 873 | "node_modules/side-channel": { 874 | "version": "1.0.6", 875 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", 876 | "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", 877 | "dependencies": { 878 | "call-bind": "^1.0.7", 879 | "es-errors": "^1.3.0", 880 | "get-intrinsic": "^1.2.4", 881 | "object-inspect": "^1.13.1" 882 | }, 883 | "engines": { 884 | "node": ">= 0.4" 885 | }, 886 | "funding": { 887 | "url": "https://github.com/sponsors/ljharb" 888 | } 889 | }, 890 | "node_modules/statuses": { 891 | "version": "2.0.1", 892 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 893 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", 894 | "engines": { 895 | "node": ">= 0.8" 896 | } 897 | }, 898 | "node_modules/toidentifier": { 899 | "version": "1.0.1", 900 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 901 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", 902 | "engines": { 903 | "node": ">=0.6" 904 | } 905 | }, 906 | "node_modules/type-is": { 907 | "version": "1.6.18", 908 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 909 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 910 | "dependencies": { 911 | "media-typer": "0.3.0", 912 | "mime-types": "~2.1.24" 913 | }, 914 | "engines": { 915 | "node": ">= 0.6" 916 | } 917 | }, 918 | "node_modules/typescript": { 919 | "version": "5.7.2", 920 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", 921 | "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", 922 | "dev": true, 923 | "bin": { 924 | "tsc": "bin/tsc", 925 | "tsserver": "bin/tsserver" 926 | }, 927 | "engines": { 928 | "node": ">=14.17" 929 | } 930 | }, 931 | "node_modules/undici-types": { 932 | "version": "6.19.8", 933 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", 934 | "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", 935 | "dev": true 936 | }, 937 | "node_modules/unpipe": { 938 | "version": "1.0.0", 939 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 940 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", 941 | "engines": { 942 | "node": ">= 0.8" 943 | } 944 | }, 945 | "node_modules/utils-merge": { 946 | "version": "1.0.1", 947 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 948 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", 949 | "engines": { 950 | "node": ">= 0.4.0" 951 | } 952 | }, 953 | "node_modules/uuid": { 954 | "version": "8.3.2", 955 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", 956 | "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", 957 | "bin": { 958 | "uuid": "dist/bin/uuid" 959 | } 960 | }, 961 | "node_modules/vary": { 962 | "version": "1.1.2", 963 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 964 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", 965 | "engines": { 966 | "node": ">= 0.8" 967 | } 968 | } 969 | }, 970 | "dependencies": { 971 | "@types/axios": { 972 | "version": "0.14.4", 973 | "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.4.tgz", 974 | "integrity": "sha512-9JgOaunvQdsQ/qW2OPmE5+hCeUB52lQSolecrFrthct55QekhmXEwT203s20RL+UHtCQc15y3VXpby9E7Kkh/g==", 975 | "dev": true, 976 | "requires": { 977 | "axios": "*" 978 | } 979 | }, 980 | "@types/body-parser": { 981 | "version": "1.19.5", 982 | "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", 983 | "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", 984 | "dev": true, 985 | "requires": { 986 | "@types/connect": "*", 987 | "@types/node": "*" 988 | } 989 | }, 990 | "@types/connect": { 991 | "version": "3.4.38", 992 | "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", 993 | "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", 994 | "dev": true, 995 | "requires": { 996 | "@types/node": "*" 997 | } 998 | }, 999 | "@types/express": { 1000 | "version": "5.0.0", 1001 | "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", 1002 | "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", 1003 | "dev": true, 1004 | "requires": { 1005 | "@types/body-parser": "*", 1006 | "@types/express-serve-static-core": "^5.0.0", 1007 | "@types/qs": "*", 1008 | "@types/serve-static": "*" 1009 | } 1010 | }, 1011 | "@types/express-serve-static-core": { 1012 | "version": "5.0.1", 1013 | "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.1.tgz", 1014 | "integrity": "sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==", 1015 | "dev": true, 1016 | "requires": { 1017 | "@types/node": "*", 1018 | "@types/qs": "*", 1019 | "@types/range-parser": "*", 1020 | "@types/send": "*" 1021 | } 1022 | }, 1023 | "@types/http-errors": { 1024 | "version": "2.0.4", 1025 | "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", 1026 | "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", 1027 | "dev": true 1028 | }, 1029 | "@types/mime": { 1030 | "version": "1.3.5", 1031 | "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", 1032 | "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", 1033 | "dev": true 1034 | }, 1035 | "@types/node": { 1036 | "version": "22.9.3", 1037 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.3.tgz", 1038 | "integrity": "sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==", 1039 | "dev": true, 1040 | "requires": { 1041 | "undici-types": "~6.19.8" 1042 | } 1043 | }, 1044 | "@types/node-cron": { 1045 | "version": "3.0.11", 1046 | "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", 1047 | "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", 1048 | "dev": true 1049 | }, 1050 | "@types/qs": { 1051 | "version": "6.9.17", 1052 | "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", 1053 | "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", 1054 | "dev": true 1055 | }, 1056 | "@types/range-parser": { 1057 | "version": "1.2.7", 1058 | "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", 1059 | "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", 1060 | "dev": true 1061 | }, 1062 | "@types/send": { 1063 | "version": "0.17.4", 1064 | "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", 1065 | "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", 1066 | "dev": true, 1067 | "requires": { 1068 | "@types/mime": "^1", 1069 | "@types/node": "*" 1070 | } 1071 | }, 1072 | "@types/serve-static": { 1073 | "version": "1.15.7", 1074 | "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", 1075 | "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", 1076 | "dev": true, 1077 | "requires": { 1078 | "@types/http-errors": "*", 1079 | "@types/node": "*", 1080 | "@types/send": "*" 1081 | } 1082 | }, 1083 | "accepts": { 1084 | "version": "1.3.8", 1085 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", 1086 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 1087 | "requires": { 1088 | "mime-types": "~2.1.34", 1089 | "negotiator": "0.6.3" 1090 | } 1091 | }, 1092 | "array-flatten": { 1093 | "version": "1.1.1", 1094 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 1095 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" 1096 | }, 1097 | "asynckit": { 1098 | "version": "0.4.0", 1099 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 1100 | "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 1101 | }, 1102 | "axios": { 1103 | "version": "1.7.7", 1104 | "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", 1105 | "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", 1106 | "requires": { 1107 | "follow-redirects": "^1.15.6", 1108 | "form-data": "^4.0.0", 1109 | "proxy-from-env": "^1.1.0" 1110 | } 1111 | }, 1112 | "body-parser": { 1113 | "version": "1.20.3", 1114 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", 1115 | "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", 1116 | "requires": { 1117 | "bytes": "3.1.2", 1118 | "content-type": "~1.0.5", 1119 | "debug": "2.6.9", 1120 | "depd": "2.0.0", 1121 | "destroy": "1.2.0", 1122 | "http-errors": "2.0.0", 1123 | "iconv-lite": "0.4.24", 1124 | "on-finished": "2.4.1", 1125 | "qs": "6.13.0", 1126 | "raw-body": "2.5.2", 1127 | "type-is": "~1.6.18", 1128 | "unpipe": "1.0.0" 1129 | } 1130 | }, 1131 | "bytes": { 1132 | "version": "3.1.2", 1133 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", 1134 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" 1135 | }, 1136 | "call-bind": { 1137 | "version": "1.0.7", 1138 | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", 1139 | "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", 1140 | "requires": { 1141 | "es-define-property": "^1.0.0", 1142 | "es-errors": "^1.3.0", 1143 | "function-bind": "^1.1.2", 1144 | "get-intrinsic": "^1.2.4", 1145 | "set-function-length": "^1.2.1" 1146 | } 1147 | }, 1148 | "chalk": { 1149 | "version": "5.3.0", 1150 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", 1151 | "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==" 1152 | }, 1153 | "combined-stream": { 1154 | "version": "1.0.8", 1155 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 1156 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 1157 | "requires": { 1158 | "delayed-stream": "~1.0.0" 1159 | } 1160 | }, 1161 | "content-disposition": { 1162 | "version": "0.5.4", 1163 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 1164 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 1165 | "requires": { 1166 | "safe-buffer": "5.2.1" 1167 | } 1168 | }, 1169 | "content-type": { 1170 | "version": "1.0.5", 1171 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", 1172 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" 1173 | }, 1174 | "cookie": { 1175 | "version": "0.7.1", 1176 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", 1177 | "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" 1178 | }, 1179 | "cookie-signature": { 1180 | "version": "1.0.6", 1181 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 1182 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" 1183 | }, 1184 | "dayjs": { 1185 | "version": "1.11.13", 1186 | "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", 1187 | "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" 1188 | }, 1189 | "debug": { 1190 | "version": "2.6.9", 1191 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 1192 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 1193 | "requires": { 1194 | "ms": "2.0.0" 1195 | } 1196 | }, 1197 | "define-data-property": { 1198 | "version": "1.1.4", 1199 | "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", 1200 | "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", 1201 | "requires": { 1202 | "es-define-property": "^1.0.0", 1203 | "es-errors": "^1.3.0", 1204 | "gopd": "^1.0.1" 1205 | } 1206 | }, 1207 | "delayed-stream": { 1208 | "version": "1.0.0", 1209 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 1210 | "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" 1211 | }, 1212 | "depd": { 1213 | "version": "2.0.0", 1214 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", 1215 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" 1216 | }, 1217 | "destroy": { 1218 | "version": "1.2.0", 1219 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", 1220 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" 1221 | }, 1222 | "dotenv": { 1223 | "version": "16.4.5", 1224 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", 1225 | "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==" 1226 | }, 1227 | "ee-first": { 1228 | "version": "1.1.1", 1229 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 1230 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 1231 | }, 1232 | "encodeurl": { 1233 | "version": "2.0.0", 1234 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", 1235 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" 1236 | }, 1237 | "es-define-property": { 1238 | "version": "1.0.0", 1239 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", 1240 | "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", 1241 | "requires": { 1242 | "get-intrinsic": "^1.2.4" 1243 | } 1244 | }, 1245 | "es-errors": { 1246 | "version": "1.3.0", 1247 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 1248 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 1249 | }, 1250 | "escape-html": { 1251 | "version": "1.0.3", 1252 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 1253 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 1254 | }, 1255 | "etag": { 1256 | "version": "1.8.1", 1257 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 1258 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" 1259 | }, 1260 | "express": { 1261 | "version": "4.21.1", 1262 | "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", 1263 | "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", 1264 | "requires": { 1265 | "accepts": "~1.3.8", 1266 | "array-flatten": "1.1.1", 1267 | "body-parser": "1.20.3", 1268 | "content-disposition": "0.5.4", 1269 | "content-type": "~1.0.4", 1270 | "cookie": "0.7.1", 1271 | "cookie-signature": "1.0.6", 1272 | "debug": "2.6.9", 1273 | "depd": "2.0.0", 1274 | "encodeurl": "~2.0.0", 1275 | "escape-html": "~1.0.3", 1276 | "etag": "~1.8.1", 1277 | "finalhandler": "1.3.1", 1278 | "fresh": "0.5.2", 1279 | "http-errors": "2.0.0", 1280 | "merge-descriptors": "1.0.3", 1281 | "methods": "~1.1.2", 1282 | "on-finished": "2.4.1", 1283 | "parseurl": "~1.3.3", 1284 | "path-to-regexp": "0.1.10", 1285 | "proxy-addr": "~2.0.7", 1286 | "qs": "6.13.0", 1287 | "range-parser": "~1.2.1", 1288 | "safe-buffer": "5.2.1", 1289 | "send": "0.19.0", 1290 | "serve-static": "1.16.2", 1291 | "setprototypeof": "1.2.0", 1292 | "statuses": "2.0.1", 1293 | "type-is": "~1.6.18", 1294 | "utils-merge": "1.0.1", 1295 | "vary": "~1.1.2" 1296 | } 1297 | }, 1298 | "finalhandler": { 1299 | "version": "1.3.1", 1300 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", 1301 | "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", 1302 | "requires": { 1303 | "debug": "2.6.9", 1304 | "encodeurl": "~2.0.0", 1305 | "escape-html": "~1.0.3", 1306 | "on-finished": "2.4.1", 1307 | "parseurl": "~1.3.3", 1308 | "statuses": "2.0.1", 1309 | "unpipe": "~1.0.0" 1310 | } 1311 | }, 1312 | "follow-redirects": { 1313 | "version": "1.15.9", 1314 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", 1315 | "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" 1316 | }, 1317 | "form-data": { 1318 | "version": "4.0.1", 1319 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", 1320 | "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", 1321 | "requires": { 1322 | "asynckit": "^0.4.0", 1323 | "combined-stream": "^1.0.8", 1324 | "mime-types": "^2.1.12" 1325 | } 1326 | }, 1327 | "forwarded": { 1328 | "version": "0.2.0", 1329 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", 1330 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" 1331 | }, 1332 | "fresh": { 1333 | "version": "0.5.2", 1334 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 1335 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" 1336 | }, 1337 | "function-bind": { 1338 | "version": "1.1.2", 1339 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 1340 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 1341 | }, 1342 | "get-intrinsic": { 1343 | "version": "1.2.4", 1344 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", 1345 | "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", 1346 | "requires": { 1347 | "es-errors": "^1.3.0", 1348 | "function-bind": "^1.1.2", 1349 | "has-proto": "^1.0.1", 1350 | "has-symbols": "^1.0.3", 1351 | "hasown": "^2.0.0" 1352 | } 1353 | }, 1354 | "gopd": { 1355 | "version": "1.0.1", 1356 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", 1357 | "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", 1358 | "requires": { 1359 | "get-intrinsic": "^1.1.3" 1360 | } 1361 | }, 1362 | "has-property-descriptors": { 1363 | "version": "1.0.2", 1364 | "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", 1365 | "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", 1366 | "requires": { 1367 | "es-define-property": "^1.0.0" 1368 | } 1369 | }, 1370 | "has-proto": { 1371 | "version": "1.0.3", 1372 | "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", 1373 | "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==" 1374 | }, 1375 | "has-symbols": { 1376 | "version": "1.0.3", 1377 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", 1378 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" 1379 | }, 1380 | "hasown": { 1381 | "version": "2.0.2", 1382 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", 1383 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 1384 | "requires": { 1385 | "function-bind": "^1.1.2" 1386 | } 1387 | }, 1388 | "http-errors": { 1389 | "version": "2.0.0", 1390 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", 1391 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 1392 | "requires": { 1393 | "depd": "2.0.0", 1394 | "inherits": "2.0.4", 1395 | "setprototypeof": "1.2.0", 1396 | "statuses": "2.0.1", 1397 | "toidentifier": "1.0.1" 1398 | } 1399 | }, 1400 | "iconv-lite": { 1401 | "version": "0.4.24", 1402 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 1403 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 1404 | "requires": { 1405 | "safer-buffer": ">= 2.1.2 < 3" 1406 | } 1407 | }, 1408 | "inherits": { 1409 | "version": "2.0.4", 1410 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 1411 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 1412 | }, 1413 | "ipaddr.js": { 1414 | "version": "1.9.1", 1415 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", 1416 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" 1417 | }, 1418 | "media-typer": { 1419 | "version": "0.3.0", 1420 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 1421 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" 1422 | }, 1423 | "merge-descriptors": { 1424 | "version": "1.0.3", 1425 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", 1426 | "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" 1427 | }, 1428 | "methods": { 1429 | "version": "1.1.2", 1430 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 1431 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" 1432 | }, 1433 | "mime": { 1434 | "version": "1.6.0", 1435 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 1436 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 1437 | }, 1438 | "mime-db": { 1439 | "version": "1.52.0", 1440 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 1441 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 1442 | }, 1443 | "mime-types": { 1444 | "version": "2.1.35", 1445 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 1446 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 1447 | "requires": { 1448 | "mime-db": "1.52.0" 1449 | } 1450 | }, 1451 | "ms": { 1452 | "version": "2.0.0", 1453 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 1454 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" 1455 | }, 1456 | "negotiator": { 1457 | "version": "0.6.3", 1458 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 1459 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" 1460 | }, 1461 | "node-cron": { 1462 | "version": "3.0.3", 1463 | "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", 1464 | "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", 1465 | "requires": { 1466 | "uuid": "8.3.2" 1467 | } 1468 | }, 1469 | "object-inspect": { 1470 | "version": "1.13.3", 1471 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", 1472 | "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==" 1473 | }, 1474 | "on-finished": { 1475 | "version": "2.4.1", 1476 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", 1477 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 1478 | "requires": { 1479 | "ee-first": "1.1.1" 1480 | } 1481 | }, 1482 | "parseurl": { 1483 | "version": "1.3.3", 1484 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 1485 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 1486 | }, 1487 | "path-to-regexp": { 1488 | "version": "0.1.10", 1489 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", 1490 | "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" 1491 | }, 1492 | "proxy-addr": { 1493 | "version": "2.0.7", 1494 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", 1495 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 1496 | "requires": { 1497 | "forwarded": "0.2.0", 1498 | "ipaddr.js": "1.9.1" 1499 | } 1500 | }, 1501 | "proxy-from-env": { 1502 | "version": "1.1.0", 1503 | "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", 1504 | "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 1505 | }, 1506 | "qs": { 1507 | "version": "6.13.0", 1508 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", 1509 | "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", 1510 | "requires": { 1511 | "side-channel": "^1.0.6" 1512 | } 1513 | }, 1514 | "range-parser": { 1515 | "version": "1.2.1", 1516 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 1517 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 1518 | }, 1519 | "raw-body": { 1520 | "version": "2.5.2", 1521 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", 1522 | "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", 1523 | "requires": { 1524 | "bytes": "3.1.2", 1525 | "http-errors": "2.0.0", 1526 | "iconv-lite": "0.4.24", 1527 | "unpipe": "1.0.0" 1528 | } 1529 | }, 1530 | "safe-buffer": { 1531 | "version": "5.2.1", 1532 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 1533 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 1534 | }, 1535 | "safer-buffer": { 1536 | "version": "2.1.2", 1537 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 1538 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 1539 | }, 1540 | "send": { 1541 | "version": "0.19.0", 1542 | "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", 1543 | "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", 1544 | "requires": { 1545 | "debug": "2.6.9", 1546 | "depd": "2.0.0", 1547 | "destroy": "1.2.0", 1548 | "encodeurl": "~1.0.2", 1549 | "escape-html": "~1.0.3", 1550 | "etag": "~1.8.1", 1551 | "fresh": "0.5.2", 1552 | "http-errors": "2.0.0", 1553 | "mime": "1.6.0", 1554 | "ms": "2.1.3", 1555 | "on-finished": "2.4.1", 1556 | "range-parser": "~1.2.1", 1557 | "statuses": "2.0.1" 1558 | }, 1559 | "dependencies": { 1560 | "encodeurl": { 1561 | "version": "1.0.2", 1562 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 1563 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" 1564 | }, 1565 | "ms": { 1566 | "version": "2.1.3", 1567 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1568 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 1569 | } 1570 | } 1571 | }, 1572 | "serve-static": { 1573 | "version": "1.16.2", 1574 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", 1575 | "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", 1576 | "requires": { 1577 | "encodeurl": "~2.0.0", 1578 | "escape-html": "~1.0.3", 1579 | "parseurl": "~1.3.3", 1580 | "send": "0.19.0" 1581 | } 1582 | }, 1583 | "set-function-length": { 1584 | "version": "1.2.2", 1585 | "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", 1586 | "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", 1587 | "requires": { 1588 | "define-data-property": "^1.1.4", 1589 | "es-errors": "^1.3.0", 1590 | "function-bind": "^1.1.2", 1591 | "get-intrinsic": "^1.2.4", 1592 | "gopd": "^1.0.1", 1593 | "has-property-descriptors": "^1.0.2" 1594 | } 1595 | }, 1596 | "setprototypeof": { 1597 | "version": "1.2.0", 1598 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", 1599 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 1600 | }, 1601 | "side-channel": { 1602 | "version": "1.0.6", 1603 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", 1604 | "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", 1605 | "requires": { 1606 | "call-bind": "^1.0.7", 1607 | "es-errors": "^1.3.0", 1608 | "get-intrinsic": "^1.2.4", 1609 | "object-inspect": "^1.13.1" 1610 | } 1611 | }, 1612 | "statuses": { 1613 | "version": "2.0.1", 1614 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", 1615 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" 1616 | }, 1617 | "toidentifier": { 1618 | "version": "1.0.1", 1619 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", 1620 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" 1621 | }, 1622 | "type-is": { 1623 | "version": "1.6.18", 1624 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 1625 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 1626 | "requires": { 1627 | "media-typer": "0.3.0", 1628 | "mime-types": "~2.1.24" 1629 | } 1630 | }, 1631 | "typescript": { 1632 | "version": "5.7.2", 1633 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", 1634 | "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", 1635 | "dev": true 1636 | }, 1637 | "undici-types": { 1638 | "version": "6.19.8", 1639 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", 1640 | "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", 1641 | "dev": true 1642 | }, 1643 | "unpipe": { 1644 | "version": "1.0.0", 1645 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 1646 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" 1647 | }, 1648 | "utils-merge": { 1649 | "version": "1.0.1", 1650 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 1651 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" 1652 | }, 1653 | "uuid": { 1654 | "version": "8.3.2", 1655 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", 1656 | "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" 1657 | }, 1658 | "vary": { 1659 | "version": "1.1.2", 1660 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 1661 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" 1662 | } 1663 | } 1664 | } 1665 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "infiniteplexlibrarry", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "npx ts-node index.ts" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "@types/axios": "^0.14.4", 14 | "@types/express": "^5.0.0", 15 | "@types/node": "^22.9.3", 16 | "@types/node-cron": "^3.0.11", 17 | "typescript": "^5.7.2" 18 | }, 19 | "dependencies": { 20 | "axios": "^1.7.7", 21 | "body-parser": "^1.20.3", 22 | "chalk": "^5.3.0", 23 | "dayjs": "^1.11.13", 24 | "dotenv": "^16.4.5", 25 | "express": "^4.21.1", 26 | "node-cron": "^3.0.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scripts/maintenance.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { config } from "../config"; 3 | import { getMovieIdsByTag, getMovieStatus, getTagId } from "../systems/radarr"; 4 | import { cleanUpDummyFile, createDummyFile, ensureDirectoryExists, removeDummyFolder } from "../utils"; 5 | 6 | /** 7 | * Main function for movie maintenance. 8 | * Ensures dummy files are created for movies not downloaded and cleans up dummy files for downloaded movies. 9 | */ 10 | export async function movieMaintenance() { 11 | const radarrUrl = config.RADARR_URL; 12 | const radarrApiKey = config.RADARR_API_KEY; 13 | const dummyTagName = config.RADARR_MONITOR_TAG_NAME; 14 | const dummySource = config.DUMMY_FILE_LOCATION; 15 | const dummyBaseFolder = config.MOVIE_FOLDER_DUMMY; 16 | 17 | try { 18 | // Fetch the tag ID for the dummy tag 19 | const tagId = await getTagId(dummyTagName, radarrUrl, radarrApiKey); 20 | if (!tagId) { 21 | console.error(`❌ Tag "${dummyTagName}" not found in Radarr. Exiting.`); 22 | return; 23 | } 24 | 25 | // Get movie IDs associated with the dummy tag 26 | const movieIds = await getMovieIdsByTag(tagId, radarrUrl, radarrApiKey); 27 | 28 | console.log(`✅ Found ${movieIds.length} movies with the "${dummyTagName}" tag.`); 29 | 30 | for (const movieId of movieIds) { 31 | const movieStatus = await getMovieStatus(movieId); 32 | 33 | if (movieStatus) { 34 | const movieFolder = movieStatus.path; 35 | const dummyFolder = path.join(dummyBaseFolder, path.basename(movieFolder)); 36 | const dummyFile = path.join(movieFolder, "dummy.mp4"); 37 | 38 | if (movieStatus.hasFile) { 39 | console.log(`🎬 Movie "${movieStatus.title}" is downloaded. Cleaning up dummy files.`); 40 | // Clean up dummy files for downloaded movies 41 | await cleanUpDummyFile(movieFolder); 42 | await removeDummyFolder(dummyFolder); 43 | } else { 44 | console.log(`🎬 Movie "${movieStatus.title}" is not downloaded. Ensuring dummy file exists.`); 45 | // Ensure dummy file exists for not downloaded movies 46 | await ensureDirectoryExists(movieFolder); 47 | await ensureDirectoryExists(dummyFolder); 48 | await createDummyFile(dummySource, dummyFile); 49 | 50 | // For now you have to do a manually library scan. 51 | 52 | // movieFolder = path.join(config.PLEX_MOVIE_FOLDER, path.basename(movieFolder)); // Temporary because my Plex has a different path 53 | // await notifyPlexFolderRefresh(movieFolder); 54 | 55 | } 56 | } else { 57 | console.log(`❌ Unable to retrieve status for movie ID ${movieId}.`); 58 | } 59 | } 60 | 61 | console.log(`✅ Daily maintenance completed for ${movieIds.length} movies.`); 62 | } catch (error: any) { 63 | console.error(`❌ Error during daily movie maintenance: ${error.message}`); 64 | } 65 | } 66 | 67 | movieMaintenance(); -------------------------------------------------------------------------------- /systems/plex.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { config } from "../config"; 3 | 4 | /** 5 | * Updates the Plex description for an episode. 6 | * @param ratingKey - The Plex rating key for the episode. 7 | * @param episodeDescription - The current description of the episode. 8 | * @param newDescription - The new status to add to the description. 9 | */ 10 | export async function updatePlexEpisodeDescription( 11 | ratingKey: string, 12 | episodeDescription: string, 13 | newDescription: string 14 | ): Promise { 15 | try { 16 | const currentDate = new Intl.DateTimeFormat("nl-NL", { 17 | dateStyle: "short", 18 | timeStyle: "short", 19 | }).format(new Date()); 20 | const combinedDescription = `[${currentDate}]: ${newDescription}\n${episodeDescription}`; 21 | 22 | const url = `${config.PLEX_URL}/library/metadata/${ratingKey}?summary.value=${encodeURIComponent( 23 | combinedDescription 24 | )}&X-Plex-Token=${config.PLEX_TOKEN}`; 25 | await axios.put(url); 26 | 27 | console.log(`✅ Episode description updated for Plex ID ${ratingKey}.`); 28 | } catch (error: any) { 29 | console.error(`❌ Error updating description for Plex ID ${ratingKey}:`, error.message); 30 | } 31 | } 32 | 33 | export async function notifyPlexFolderRefresh(folderPath: string, libraryId: string): Promise { 34 | try { 35 | console.log(`🔄 Starting Plex folder scan for folder: ${folderPath}`); 36 | 37 | const url = `${config.PLEX_URL}/library/sections/${libraryId}/refresh?X-Plex-Token=${config.PLEX_TOKEN}&path=${encodeURIComponent( 38 | folderPath 39 | )}`; 40 | const response = await axios.get(url); 41 | 42 | if (response.status === 200) { 43 | console.log(`✅ Plex folder scan started for folder: ${folderPath}`); 44 | } else { 45 | console.error(`❌ Error starting Plex folder scan: Status ${response.status}`); 46 | } 47 | } catch (error) { 48 | console.error("❌ Error communicating with the Plex API:", error); 49 | } 50 | } 51 | 52 | 53 | export async function updatePlexDescription(ratingKey: string, movieDescription: string, newDescription: string): Promise { 54 | try { 55 | 56 | var currentDate = new Intl.DateTimeFormat("nl-NL", { dateStyle: "short", timeStyle: "short" }).format(new Date()); 57 | const combinedDescription = `[${currentDate}]: ${newDescription}\n${movieDescription}`; 58 | 59 | const url = `${config.PLEX_URL}/library/metadata/${ratingKey}?summary.value=${encodeURIComponent(combinedDescription)}&X-Plex-Token=${config.PLEX_TOKEN}`; // Don't know if this is the official way but the webclient does it like this 60 | const response = await axios.put(url); 61 | 62 | console.log(`✅ Description successfully updated for Plex ID ${ratingKey}.`, response.data); 63 | 64 | // Refresh metadata 65 | //await refreshPlexMetadata(ratingKey); 66 | } catch (error: any) { 67 | console.error(`❌ Error updating the description for Plex ID ${ratingKey}:`, error.message); 68 | } 69 | } 70 | 71 | export async function refreshPlexMetadata(ratingKey: string): Promise { 72 | try { 73 | // Plex API endpoint for refreshing metadata 74 | const refreshUrl = `${config.PLEX_URL}/library/metadata/${ratingKey}/refresh?X-Plex-Token=${config.PLEX_TOKEN}`; 75 | 76 | // Send the POST request to refresh metadata 77 | await axios.put(refreshUrl); 78 | console.log(`✅ Metadata successfully refreshed for Plex ID ${ratingKey}.`); 79 | } catch (error: any) { 80 | console.error(`❌ Error refreshing metadata for Plex ID ${ratingKey}:`, error.message); 81 | } 82 | } -------------------------------------------------------------------------------- /systems/radarr.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { config } from "../config"; 3 | 4 | /** 5 | * Fetches all movie IDs associated with a specific tag from Radarr. 6 | * @param tagId - The ID of the tag. 7 | * @param radarrUrl - The Radarr API URL. 8 | * @param radarrApiKey - The Radarr API key. 9 | * @returns An array of movie IDs associated with the tag. 10 | */ 11 | export async function getMovieIdsByTag( 12 | tagId: number, 13 | radarrUrl: string, 14 | radarrApiKey: string 15 | ): Promise { 16 | try { 17 | // Fetch tag details from Radarr 18 | const { data: tagDetail } = await axios.get(`${radarrUrl}/tag/detail/${tagId}`, { 19 | headers: { "X-Api-Key": radarrApiKey }, 20 | }); 21 | 22 | if (tagDetail && tagDetail.movieIds) { 23 | //console.log(`✅ Found ${tagDetail.movieIds.length} movies with tag ID ${tagId}.`); 24 | return tagDetail.movieIds; 25 | } 26 | 27 | console.log(`❌ No movies found for tag ID ${tagId}.`); 28 | return []; 29 | } catch (error: any) { 30 | console.error(`❌ Error fetching movies by tag ID ${tagId}: ${error.message}`); 31 | throw error; 32 | } 33 | } 34 | 35 | 36 | /** 37 | * Checks if a movie exists in Radarr and returns the details if available. 38 | * @param tmdbId - TMDb ID of the movie. 39 | * @param radarrUrl - Radarr API URL. 40 | * @param radarrApiKey - Radarr API key. 41 | * @returns Tuple with a boolean and the movie details (or null). 42 | */ 43 | export async function checkMovieInRadarr(tmdbId: string, radarrUrl: string, radarrApiKey: string): Promise<[boolean, any | null]> { 44 | try { 45 | // Fetch the movie via Radarr API 46 | const response = await axios.get(`${radarrUrl}/movie?tmdbId=${tmdbId}`, { 47 | headers: { "X-Api-Key": radarrApiKey }, 48 | }); 49 | 50 | // Radarr returns an array, check if it is empty 51 | const movies = response.data; 52 | 53 | if (movies.length > 0) { 54 | const movie = movies[0]; // Take the first movie from the results 55 | console.log(`✅ Movie found in Radarr: ${movie.title}`); 56 | return [true, movie]; 57 | } else { 58 | console.log(`❌ Movie not found in Radarr for TMDb ID: ${tmdbId}`); 59 | return [false, null]; 60 | } 61 | } catch (error: any) { 62 | console.error(`❌ Error fetching movies from Radarr: ${error.message}`); 63 | return [false, null]; 64 | } 65 | } 66 | 67 | /** 68 | * Adds a movie to Radarr. 69 | * @param tmdbId - The TMDb ID of the movie. 70 | * @param rootFolderPath - The path where the movie should be stored. 71 | * @param qualityProfileId - The ID of the quality profile. 72 | * @param monitored - Whether the movie should be monitored (default: false). 73 | * @param searchForMovie - Whether to search for the movie immediately (default: false). 74 | * @param radarrUrl - Radarr URL 75 | * @param radarrApiKey - Radarr API key 76 | * @param tags - Optional: An array of tags (e.g., ["action", "4k"]). 77 | * @returns An object with details of the added movie. 78 | */ 79 | export async function addMovieToRadarr( 80 | tmdbId: string, 81 | rootFolderPath: string, 82 | qualityProfileId: number, 83 | monitored: boolean = false, 84 | searchForMovie: boolean = false, 85 | radarrUrl: string, 86 | radarrApiKey: string, 87 | tags?: string[] 88 | ): Promise { 89 | try { 90 | let tagIds: number[] = []; 91 | if (tags && tags.length > 0) { 92 | 93 | // Fetch the available tags from Radarr 94 | const { data: availableTags } = await axios.get(`${radarrUrl}/tag`, { 95 | headers: { "X-Api-Key": radarrApiKey }, 96 | }); 97 | 98 | // Find IDs for the provided tag names 99 | tagIds = tags.map((tag) => { 100 | const tagEntry = availableTags.find((t: any) => t.label.toLowerCase() === tag.toLowerCase()); 101 | if (tagEntry) { 102 | return tagEntry.id; 103 | } else { 104 | console.warn(`⚠️ Tag not found: ${tag}`); 105 | return null; 106 | } 107 | }).filter((id): id is number => id !== null); 108 | } 109 | 110 | // Add movie 111 | const newMovie = { 112 | tmdbId, 113 | rootFolderPath, 114 | qualityProfileId, 115 | monitored, 116 | tags: tagIds, 117 | addOptions: { 118 | searchForMovie, 119 | }, 120 | }; 121 | 122 | const addResponse = await axios.post(`${radarrUrl}/movie`, newMovie, { 123 | headers: { "X-Api-Key": radarrApiKey }, 124 | }); 125 | 126 | console.log(`🎥 Movie successfully added to Radarr: ${addResponse.data.title}`); 127 | return addResponse.data; 128 | } catch (error: any) { 129 | console.error(`❌ Error adding movie to Radarr: ${error.message}`); 130 | throw error; 131 | } 132 | } 133 | 134 | /** 135 | * Sets a movie to monitored and starts a search in Radarr. 136 | * @param movieId - The unique ID of the movie in Radarr. 137 | * @param radarrUrl - Radarr URL 138 | * @param radarrApiKey - Radarr API key 139 | */ 140 | export async function searchMovieInRadarr(movieId: number, radarrUrl: string, radarrApiKey: string): Promise { 141 | try { 142 | // Fetch the movie data from Radarr 143 | const { data: movie } = await axios.get(`${radarrUrl}/movie/${movieId}`, { 144 | headers: { 145 | "X-Api-Key": radarrApiKey, 146 | }, 147 | }); 148 | 149 | // Check if the movie is already monitored 150 | if (!movie.monitored) { 151 | console.log(`🔄 Setting movie "${movie.title}" to monitored...`); 152 | 153 | // Set the movie to "monitored" 154 | const updatedMovie = { ...movie, monitored: true }; 155 | 156 | // Send the update to Radarr 157 | await axios.put(`${radarrUrl}/movie`, updatedMovie, { 158 | headers: { 159 | "X-Api-Key": radarrApiKey, 160 | }, 161 | }); 162 | 163 | console.log(`🎬 Movie "${movie.title}" is now monitored.`); 164 | } else { 165 | console.log(`✅ Movie "${movie.title}" is already monitored.`); 166 | } 167 | 168 | // Start a search for the movie 169 | console.log(`🔍 Starting search for movie "${movie.title}"...`); 170 | await axios.post( 171 | `${radarrUrl}/command`, 172 | { name: "MoviesSearch", movieIds: [movieId] }, 173 | { 174 | headers: { 175 | "X-Api-Key": radarrApiKey, 176 | }, 177 | } 178 | ); 179 | 180 | console.log(`✅ Search started for movie "${movie.title}" (ID: ${movieId}).`); 181 | } catch (error: any) { 182 | console.error(`❌ Error searching for movie in Radarr: ${error.message}`); 183 | } 184 | } 185 | 186 | export async function isMovieDownloading(movieId: number): Promise { 187 | try { 188 | console.log(`🔍 Checking if movie ID ${movieId} is in the download queue...`); 189 | const response = await axios.get(`${config.RADARR_URL}/queue`, { 190 | headers: { 191 | "X-Api-Key": config.RADARR_API_KEY, 192 | }, 193 | }); 194 | 195 | const queue = response.data.records; 196 | 197 | // Search for the movie in the queue 198 | const downloadingMovie = queue.find((item: any) => item.movieId === movieId); 199 | 200 | if (downloadingMovie) { 201 | console.log( 202 | `🚀 Movie "${downloadingMovie.title}" is currently downloading. Progress: ${downloadingMovie.sizeleft} bytes left.` 203 | ); 204 | return true; 205 | } 206 | 207 | console.log(`ℹ️ Movie ID ${movieId} is not in the download queue.`); 208 | return false; 209 | } catch (error: any) { 210 | console.error("❌ Error checking Radarr download queue:", error.message); 211 | return false; 212 | } 213 | } 214 | 215 | 216 | export async function getMovieStatus(movieId: number) { 217 | try { 218 | const { data: movie } = await axios.get(`${config.RADARR_URL}/movie/${movieId}`, { 219 | headers: { 220 | "X-Api-Key": config.RADARR_API_KEY, 221 | }, 222 | }); 223 | return movie; 224 | } catch (error: any) { 225 | console.error("Error fetching movie status from Radarr:", error.message); 226 | return null; 227 | } 228 | } 229 | 230 | 231 | /** 232 | * Adds a tag to an existing movie in Radarr. 233 | * @param movieId - The unique ID of the movie in Radarr. 234 | * @param tagId - The unique ID of the tag in Radarr. 235 | * @param radarrUrl - The Radarr API URL. 236 | * @param radarrApiKey - The Radarr API key. 237 | */ 238 | export async function addTagForMovie(movieId: number, tagId: number, radarrUrl: string, radarrApiKey: string): Promise { 239 | try { 240 | // Fetch the current movie data 241 | const { data: movie } = await axios.get(`${radarrUrl}/movie/${movieId}`, { 242 | headers: { "X-Api-Key": radarrApiKey }, 243 | }); 244 | 245 | // Check if the tag already exists for the movie 246 | if (movie.tags && movie.tags.includes(tagId)) { 247 | console.log(`🏷️ Tag ${tagId} already exists for movie "${movie.title}".`); 248 | return; 249 | } 250 | 251 | // Add the new tag to the existing list of tags 252 | const updatedTags = movie.tags ? [...movie.tags, tagId] : [tagId]; 253 | 254 | // Update the movie with the new tags 255 | const updatedMovie = { ...movie, tags: updatedTags }; 256 | 257 | await axios.put(`${radarrUrl}/movie`, updatedMovie, { 258 | headers: { "X-Api-Key": radarrApiKey }, 259 | }); 260 | 261 | console.log(`✅ Tag ${tagId} successfully added to movie "${movie.title}".`); 262 | } catch (error: any) { 263 | console.error(`❌ Error adding tag ${tagId} to movie ID ${movieId}: ${error.message}`); 264 | throw error; 265 | } 266 | } 267 | 268 | export async function getTagId(tagName: string, radarrUrl: string, radarrApiKey: string): Promise { 269 | try { 270 | const { data: tags } = await axios.get(`${radarrUrl}/tag`, { 271 | headers: { 272 | "X-Api-Key": radarrApiKey, 273 | }, 274 | }); 275 | 276 | const tag = tags.find((t: any) => t.label === tagName); 277 | return tag ? tag.id : null; 278 | } catch (error: any) { 279 | console.error("❌ Error fetching tags from Radarr:", error.message); 280 | return null; 281 | } 282 | } 283 | 284 | /// check 285 | 286 | export async function removeTagFromMovie(movieId: number, tagId: number) { 287 | try { 288 | const { data: movie } = await axios.get(`${config.RADARR_URL}/movie/${movieId}`, { 289 | headers: { 290 | "X-Api-Key": config.RADARR_API_KEY, 291 | }, 292 | }); 293 | 294 | // Filter the tag out of the tag list 295 | const updatedTags = movie.tags.filter((id: number) => id !== tagId); 296 | 297 | // Update the movie with the modified tags 298 | const updatedMovie = { ...movie, tags: updatedTags }; 299 | await axios.put(`${config.RADARR_URL}/movie`, updatedMovie, { 300 | headers: { 301 | "X-Api-Key": config.RADARR_API_KEY, 302 | }, 303 | }); 304 | 305 | console.log(`🏷️ Successfully removed tag ID ${tagId} from movie ID ${movieId}.`); 306 | } catch (error: any) { 307 | console.error("❌ Error removing tag from movie:", error.message); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /systems/sonarr.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { config } from "../config"; 3 | import { createDummyFile, ensureDirectoryExists } from "../utils"; 4 | import { updatePlexDescription } from "./plex"; 5 | 6 | /** 7 | * Retrieves a series from Sonarr using thetvdb_id. 8 | * @param tvdbId - The TVDB ID of the series. 9 | * @param sonarrUrl - The Sonarr API URL. 10 | * @param sonarrApiKey - The Sonarr API key. 11 | * @returns The series details from Sonarr or null if not found. 12 | */ 13 | export async function getSeriesByTvdbId( 14 | tvdbId: number, 15 | sonarrUrl: string, 16 | sonarrApiKey: string 17 | ): Promise { 18 | try { 19 | const response = await axios.get(`${sonarrUrl}/series`, { 20 | headers: { "X-Api-Key": sonarrApiKey }, 21 | params: { tvdbId }, // Pass tvdbId as a query parameter 22 | }); 23 | 24 | if (response.data && response.data.length > 0) { 25 | const series = response.data[0]; 26 | console.log(`✅ Found series in Sonarr: ${series.title} (TVDB ID: ${tvdbId})`); 27 | return series; 28 | } 29 | 30 | console.log(`❌ No series found in Sonarr for TVDB ID: ${tvdbId}`); 31 | return null; 32 | } catch (error: any) { 33 | console.error(`❌ Error fetching series by TVDB ID ${tvdbId}: ${error.message}`); 34 | return null; 35 | } 36 | } 37 | /** 38 | * Fetches all episodes of a series by its series ID from Sonarr, optionally filtered by season. 39 | * @param seriesId - The ID of the series in Sonarr. 40 | * @param seasonNumber - The specific season number to fetch (optional). 41 | * @returns Array of episodes belonging to the series or season. 42 | */ 43 | export async function getEpisodesBySeriesId(seriesId: number, seasonNumber?: number): Promise { 44 | try { 45 | const params: any = { 46 | seriesId, 47 | includeEpisodeFile: true, 48 | }; 49 | 50 | if (seasonNumber !== undefined) { 51 | params.seasonNumber = seasonNumber; 52 | } 53 | 54 | const { data: episodes } = await axios.get(`${config.SONARR_URL}/episode`, { 55 | params, 56 | headers: { "X-Api-Key": config.SONARR_API_KEY }, 57 | }); 58 | 59 | const seasonText = seasonNumber !== undefined ? `Season ${seasonNumber}` : "all seasons"; 60 | console.log(`✅ Retrieved ${episodes.length} episodes for series ID ${seriesId}, ${seasonText}.`); 61 | return episodes; 62 | } catch (error: any) { 63 | console.error( 64 | `❌ Error fetching episodes for series ID ${seriesId}${seasonNumber !== undefined ? `, Season ${seasonNumber}` : ""}:`, 65 | error.message 66 | ); 67 | throw error; 68 | } 69 | } 70 | 71 | /** 72 | * Groups episodes by season number. 73 | * @param episodes - Array of episodes. 74 | * @returns Object with seasons as keys and arrays of episodes as values. 75 | */ 76 | export function groupEpisodesBySeason(episodes: any[]): Record { 77 | return episodes.reduce((acc: Record, episode: any) => { 78 | if (!acc[episode.seasonNumber]) { 79 | acc[episode.seasonNumber] = []; 80 | } 81 | acc[episode.seasonNumber].push(episode); 82 | return acc; 83 | }, {}); 84 | } 85 | 86 | /** 87 | * Creates a dummy file for a specific season in the series folder. 88 | * @param seriesTitle - The title of the series. 89 | * @param seasonNumber - The season number. 90 | * @param episodes - Array of episodes for the season. 91 | * @param seriesFolder - The path to the series folder. 92 | * @param dummySource - Path to the source dummy file. 93 | */ 94 | export async function createSeasonDummyFile( 95 | seriesTitle: string, 96 | seasonNumber: number, 97 | episodes: any[], 98 | seriesFolder: string, 99 | dummySource: string 100 | ): Promise { 101 | try { 102 | const seasonFolder = `${seriesFolder}/Season ${seasonNumber}`; 103 | await ensureDirectoryExists(seasonFolder); 104 | 105 | const dummyFileName = `${seriesTitle} - s${String(seasonNumber).padStart(2, "0")}e01-e${String(episodes.length).padStart(2, "0")}.mp4`; 106 | const dummyFilePath = `${seasonFolder}/${dummyFileName}`; 107 | 108 | await createDummyFile(dummySource, dummyFilePath); 109 | 110 | console.log(`✅ Created dummy file for season ${seasonNumber}: ${dummyFilePath}`); 111 | } catch (error: any) { 112 | console.error(`❌ Error creating dummy file for season ${seasonNumber}:`, error.message); 113 | throw error; 114 | } 115 | } 116 | 117 | /** 118 | * Fetches all tags from Sonarr. 119 | * @returns Array of tags in Sonarr. 120 | */ 121 | export async function getSonarrTags(): Promise { 122 | try { 123 | const { data: tags } = await axios.get(`${config.SONARR_URL}/tag`, { 124 | headers: { "X-Api-Key": config.SONARR_API_KEY }, 125 | }); 126 | 127 | console.log(`✅ Retrieved ${tags.length} tags from Sonarr.`); 128 | return tags; 129 | } catch (error: any) { 130 | console.error("❌ Error fetching tags from Sonarr:", error.message); 131 | throw error; 132 | } 133 | } 134 | 135 | /** 136 | * Retrieves the ID of a tag by its name. 137 | * @param tagName - The name of the tag. 138 | * @returns The tag ID or null if not found. 139 | */ 140 | export async function getSonarrTagId(tagName: string): Promise { 141 | try { 142 | const tags = await getSonarrTags(); 143 | const tag = tags.find((t: any) => t.label === tagName); 144 | return tag ? tag.id : null; 145 | } catch (error: any) { 146 | console.error(`❌ Error fetching tag ID for "${tagName}":`, error.message); 147 | return null; 148 | } 149 | } 150 | 151 | 152 | /** 153 | * Monitors a series and its episodes in Sonarr. 154 | * @param seriesId - The ID of the series in Sonarr. 155 | */ 156 | export async function monitorSeries(seriesId: number): Promise { 157 | try { 158 | const { data: series } = await axios.get(`${config.SONARR_URL}/series/${seriesId}`, { 159 | headers: { "X-Api-Key": config.SONARR_API_KEY }, 160 | }); 161 | 162 | if (series.monitored) { 163 | console.log(`✅ Series "${series.title}" is already monitored.`); 164 | } else { 165 | const updatedSeries = { ...series, monitored: true }; 166 | await axios.put(`${config.SONARR_URL}/series`, updatedSeries, { 167 | headers: { "X-Api-Key": config.SONARR_API_KEY }, 168 | }); 169 | 170 | console.log(`🎬 Series "${series.title}" is now monitored.`); 171 | } 172 | } catch (error: any) { 173 | console.error(`❌ Error monitoring series ID ${seriesId}:`, error.message); 174 | } 175 | } 176 | 177 | /** 178 | * Monitors all seasons and episodes of a series in Sonarr, excluding specials (Season 0). 179 | * @param seriesId - The Sonarr series ID. 180 | * @param sonarrUrl - The Sonarr API URL. 181 | * @param sonarrApiKey - The Sonarr API key. 182 | */ 183 | export async function monitorAllSeasons( 184 | seriesId: number, 185 | sonarrUrl: string, 186 | sonarrApiKey: string 187 | ): Promise { 188 | try { 189 | console.log(`🔄 Fetching series details for ID: ${seriesId} to monitor all seasons (excluding specials)...`); 190 | 191 | // Fetch series details from Sonarr 192 | const { data: series } = await axios.get(`${sonarrUrl}/series/${seriesId}`, { 193 | headers: { "X-Api-Key": sonarrApiKey }, 194 | }); 195 | 196 | // Update the series to monitor all seasons and episodes, excluding Season 0 (specials) 197 | const updatedSeries = { 198 | ...series, 199 | monitored: true, // Set the entire series as monitored 200 | seasons: series.seasons.map((season: any) => ({ 201 | ...season, 202 | monitored: season.seasonNumber !== 0, // Monitor all seasons except Season 0 203 | })), 204 | }; 205 | 206 | await axios.put(`${sonarrUrl}/series`, updatedSeries, { 207 | headers: { "X-Api-Key": sonarrApiKey }, 208 | }); 209 | 210 | console.log(`✅ All seasons (excluding specials) of series "${series.title}" are now monitored.`); 211 | } catch (error: any) { 212 | console.error(`❌ Error monitoring all seasons of series ID ${seriesId}: ${error.message}`); 213 | } 214 | } 215 | /** 216 | * Searches for a specific season or all seasons of a series in Sonarr, excluding Season 0. 217 | * @param seriesId - The unique ID of the series in Sonarr. 218 | * @param seasonNumber - The specific season number to search (optional, null for all seasons). 219 | * @param sonarrUrl - Sonarr API URL. 220 | * @param sonarrApiKey - Sonarr API key. 221 | */ 222 | export async function searchSeriesInSonarr( 223 | seriesId: number, 224 | seasonNumber: number | null, 225 | sonarrUrl: string, 226 | sonarrApiKey: string 227 | ): Promise { 228 | try { 229 | if (seasonNumber !== null) { 230 | // Search for a specific season 231 | const payload = { 232 | name: "SeasonSearch", 233 | seriesId, 234 | seasonNumber: Number(seasonNumber), // Ensure seasonNumber is a number 235 | }; 236 | 237 | console.log("🔍 Sending SeasonSearch payload to Sonarr:", JSON.stringify(payload, null, 2)); 238 | 239 | const response = await axios.post(`${sonarrUrl}/command`, payload, { 240 | headers: { "X-Api-Key": sonarrApiKey }, 241 | }); 242 | 243 | console.log(`✅ Search started successfully for series ID ${seriesId}, season ${seasonNumber}.`); 244 | console.log("Response:", response.data); 245 | } else { 246 | // Fetch series details to get all seasons 247 | console.log(`🔄 Fetching series details for ID: ${seriesId} to search all seasons...`); 248 | 249 | const { data: series } = await axios.get(`${sonarrUrl}/series/${seriesId}`, { 250 | headers: { "X-Api-Key": sonarrApiKey }, 251 | }); 252 | 253 | // Filter out Season 0 254 | const validSeasons = series.seasons.filter( 255 | (season: any) => season.seasonNumber !== 0 256 | ); 257 | 258 | console.log(`📋 Found ${validSeasons.length} valid seasons to search (excluding Season 0).`); 259 | 260 | // Search for each season individually 261 | for (const season of validSeasons) { 262 | const payload = { 263 | name: "SeasonSearch", 264 | seriesId, 265 | seasonNumber: season.seasonNumber, 266 | }; 267 | 268 | console.log("🔍 Sending SeasonSearch payload to Sonarr:", JSON.stringify(payload, null, 2)); 269 | 270 | const response = await axios.post(`${sonarrUrl}/command`, payload, { 271 | headers: { "X-Api-Key": sonarrApiKey }, 272 | }); 273 | 274 | console.log( 275 | `✅ Search started successfully for series ID ${seriesId}, season ${season.seasonNumber}.` 276 | ); 277 | console.log("Response:", response.data); 278 | } 279 | } 280 | } catch (error: any) { 281 | console.error(`❌ Error searching for series in Sonarr: ${error.message}`); 282 | throw error; 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /systems/tautulli.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { config } from "../config"; 3 | 4 | export async function terminateStreamByFile(originalFilePath: string) { 5 | try { 6 | // Retrieve all active sessions from Tautulli 7 | const { data } = await axios.get(`${config.TAUTULLI_URL}`, { 8 | params: { 9 | cmd: "get_activity", 10 | apikey: config.TAUTULLI_API_KEY, 11 | }, 12 | }); 13 | 14 | if (data && data.response && data.response.data && data.response.data.sessions) { 15 | const sessions = data.response.data.sessions; 16 | 17 | // Search for a session with the provided original file path 18 | const session = sessions.find((s: any) => s.file === originalFilePath); 19 | 20 | if (session) { 21 | console.log(`🎬 Active stream found: Session ID ${session.session_id}, File: ${originalFilePath}`); 22 | 23 | // Terminate the session 24 | await axios.get(`${config.TAUTULLI_URL}`, { 25 | params: { 26 | cmd: "terminate_session", 27 | apikey: config.TAUTULLI_API_KEY, 28 | session_id: session.session_id, 29 | message: config.TAUTULLI_STREAM_TERMINATED_MESSAGE, 30 | }, 31 | }); 32 | 33 | console.log(`✅ Stream terminated for file: ${originalFilePath}`); 34 | } else { 35 | console.log(`❌ No active stream found for file: ${originalFilePath}`); 36 | } 37 | } else { 38 | console.log("❌ No active sessions found."); 39 | } 40 | } catch (error: any) { 41 | console.error("❌ Error while terminating stream:", error.message); 42 | } 43 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", // Zorg dat TypeScript modules kan vinden zoals Node.js dat doet 4 | "esModuleInterop": true, // Maakt ESModule-imports eenvoudiger 5 | "allowSyntheticDefaultImports": true, // Laat gebruik van default imports toe 6 | "strict": true, // Optioneel, voor strikte TypeScript controle 7 | "target": "ES2020", // Voor moderne JavaScript 8 | "module": "CommonJS" // Of "ESNext" als je ESModules gebruikt 9 | } 10 | } -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export async function cleanUpDummyFile(directory: string): Promise { 5 | try { 6 | // Read files in the directory 7 | const files = await fs.promises.readdir(directory); 8 | 9 | // Check if there are other files than dummy.mp4 10 | const hasOtherFiles = files.some((file) => file !== "dummy.mp4"); 11 | 12 | if (hasOtherFiles) { 13 | const dummyPath = path.join(directory, "dummy.mp4"); 14 | 15 | try { 16 | await fs.promises.access(dummyPath, fs.constants.F_OK); 17 | console.log("✅ Dummy file is accessible!"); 18 | } catch { 19 | console.log("❌ Dummy file is not accessible!"); 20 | } 21 | 22 | // Check if dummy.mp4 exists before attempting to delete 23 | console.log(`🗂️ Current Working Directory: ${process.cwd()}`); 24 | 25 | console.log(`🔍 Checking path: ${dummyPath}`); 26 | console.log(files); 27 | 28 | if (fs.existsSync(dummyPath)) { 29 | console.log(`🔍 Other file found in ${directory}. Removing dummy.mp4...`); 30 | await fs.promises.unlink(dummyPath); 31 | console.log("✅ dummy.mp4 successfully removed."); 32 | } else { 33 | console.log("ℹ️ dummy.mp4 does not exist, no action needed."); 34 | } 35 | } else { 36 | console.log(`❌ No other files found in ${directory}. dummy.mp4 will remain.`); 37 | } 38 | } catch (error) { 39 | console.error("❌ Error cleaning up dummy.mp4:", error); 40 | } 41 | } 42 | 43 | 44 | export async function removeDummyFolder(directory: string): Promise { 45 | try { 46 | // Check if the directory exists 47 | await fs.promises.access(directory); // Checks if the path is accessible 48 | 49 | // Remove the directory and its contents 50 | console.log(`🗂️ Dummy folder found: ${directory}. Removing...`); 51 | await fs.promises.rm(directory, { recursive: true, force: true }); // Removes the directory and its contents 52 | console.log("✅ Dummy folder and contents successfully removed."); 53 | } catch (error: any) { 54 | if (error.code === "ENOENT") { 55 | console.log("ℹ️ Dummy folder does not exist, no action needed."); 56 | } else { 57 | console.error("❌ Error removing dummy folder:", error); 58 | } 59 | } 60 | } 61 | 62 | export async function createDummyFile(source: string, target: string): Promise { 63 | 64 | try { 65 | // Check if the symlink already exists 66 | if (!fs.existsSync(target)) { 67 | console.log(`🔗 Creating dummy file: ${target} -> ${source}`); 68 | 69 | await fs.promises.copyFile(source, target); 70 | 71 | console.log("✅ Dummy file successfully created."); 72 | } else { 73 | console.log("ℹ️ Dummy file already exists."); 74 | } 75 | } catch (error) { 76 | console.error("❌ Error creating the dummy link:", error); 77 | throw error; 78 | } 79 | } 80 | 81 | export async function createSymlink(source: string, target: string): Promise { 82 | 83 | try { 84 | // Check if the symlink already exists 85 | if (!fs.existsSync(target)) { 86 | console.log(`🔗 Creating symlink: ${target} -> ${source}`); 87 | 88 | await fs.promises.symlink(source, target); 89 | 90 | // Plex is too smart and recognizes if you link to the same sym/hardlink for multiple movies in your library. 91 | // That's why we have to copy the whole file. Make sure your dummy file doesn't take up too much space! 92 | // Need to find out if other methods are possible. Perhaps creating a kind of dynamic file at the OS level? 93 | // It would know from where a file is opened and would behave like a separate file in each location... Just some brainfarts. 94 | 95 | console.log("✅ Symlink successfully created."); 96 | } else { 97 | console.log("ℹ️ Symlink already exists."); 98 | } 99 | } catch (error) { 100 | console.error("❌ Error creating the symlink:", error); 101 | throw error; 102 | } 103 | } 104 | 105 | export async function ensureDirectoryExists(directory: string): Promise { 106 | try { 107 | if (!fs.existsSync(directory)) { 108 | console.log(`📁 Directory not found. Creating: ${directory}`); 109 | await fs.promises.mkdir(directory, { recursive: true, mode: 0o777 }); 110 | 111 | console.log("✅ Directory successfully created."); 112 | } else { 113 | console.log("ℹ️ Directory already exists."); 114 | } 115 | } catch (error) { 116 | console.error("❌ Error checking/creating the directory:", error); 117 | throw error; 118 | } 119 | } --------------------------------------------------------------------------------