├── .node-version ├── .github ├── FUNDING.yml ├── workflows │ ├── build.yml │ ├── lint.yml │ ├── test.yml │ └── docker-publish.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── frontend ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── iplayarr.png │ ├── img │ │ ├── icons │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── mstile-150x150.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ ├── apple-touch-icon-180x180.png │ │ │ ├── msapplication-icon-144x144.png │ │ │ ├── android-chrome-maskable-192x192.png │ │ │ └── android-chrome-maskable-512x512.png │ │ ├── sabnzbd.svg │ │ ├── radarr.svg │ │ └── sonarr.svg │ ├── shortcuts │ │ └── Download With iPlayarr.shortcut │ └── index.html ├── babel.config.js ├── src │ ├── assets │ │ ├── logo.png │ │ └── styles │ │ │ └── variables.less │ ├── lib │ │ ├── ipFetch.js │ │ ├── dialogService.js │ │ └── utils.js │ ├── main.js │ ├── components │ │ ├── common │ │ │ ├── NavLink.vue │ │ │ ├── InfoBar.vue │ │ │ ├── ProgressBar.vue │ │ │ ├── LoadingIndicator.vue │ │ │ ├── form │ │ │ │ └── CheckInput.vue │ │ │ └── ListEditor.vue │ │ ├── modals │ │ │ ├── AlertDialog.vue │ │ │ ├── IPlayarrModal.vue │ │ │ ├── AppSelectDialog.vue │ │ │ └── OffScheduleForm.vue │ │ ├── charts │ │ │ ├── PieChart.vue │ │ │ ├── PolarArea.vue │ │ │ └── LineChart.vue │ │ ├── log │ │ │ └── LogPanel.vue │ │ └── apps │ │ │ └── AppTestButton.vue │ ├── registerServiceWorker.js │ ├── views │ │ ├── QueueInfoPage.vue │ │ ├── DownloadPage.vue │ │ ├── LogsPage.vue │ │ ├── QueuePage.vue │ │ └── AboutPage.vue │ └── router │ │ └── router.js ├── jsconfig.json ├── .gitignore ├── README.md ├── vue.config.mjs └── package.json ├── src ├── config │ └── version.json ├── types │ ├── User.ts │ ├── responses │ │ ├── sabnzbd │ │ │ ├── TrueFalseResponse.ts │ │ │ ├── ConfigResponse.ts │ │ │ ├── HistoryResponse.ts │ │ │ └── QueueResponse.ts │ │ ├── arr │ │ │ ├── IndexerResponse.ts │ │ │ ├── DownloadClientResponse.ts │ │ │ └── ArrLookupResponse.ts │ │ ├── EpisodeCacheTypes.ts │ │ ├── iplayer │ │ │ └── IPlayerNewSearchResponse.ts │ │ ├── ApiResponse.ts │ │ ├── newznab │ │ │ ├── NZBFileResponse.ts │ │ │ ├── NewzNabSearchResponse.ts │ │ │ └── CapsResponse.ts │ │ └── IPlayerMetadataResponse.ts │ ├── data │ │ ├── AbstractHistoryEntry.ts │ │ ├── SearchHistoryEntry.ts │ │ └── GrabHistoryEntry.ts │ ├── requests │ │ ├── arr │ │ │ └── AbstractCreateIndexerRequest.ts │ │ ├── nzbget │ │ │ └── NZBGetAppendRequest.ts │ │ └── form │ │ │ ├── CreateDownloadClientForm.ts │ │ │ └── CreateIndexerForm.ts │ ├── GetIplayer │ │ └── SpawnExecutable.ts │ ├── enums │ │ └── DownloadClient.ts │ ├── Synonym.ts │ ├── FilenameTemplateContext.ts │ ├── DownloadDetails.ts │ ├── LogLine.ts │ ├── utils │ │ ├── AbstractFIFOQueue.ts │ │ ├── FixedFIFOQueue.ts │ │ └── RedisFIFOQueue.ts │ ├── IPlayerDetails.ts │ ├── QueueEntry.ts │ ├── QualityProfiles.ts │ ├── IPlayerSearchResult.ts │ ├── App.ts │ ├── IplayarrParameters.ts │ ├── AppType.ts │ └── QueuedStorage.ts ├── endpoints │ ├── sabnzbd │ │ ├── VersionEndpoint.ts │ │ ├── AbstractSabNZBDActionEndpoint.ts │ │ ├── ConfigEndpoint.ts │ │ ├── DownloadNZBEndpoint.ts │ │ └── QueueEndpoint.ts │ ├── generic │ │ └── DownloadEndpoint.ts │ └── newznab │ │ └── CapsEndpoint.ts ├── service │ ├── schedule │ │ ├── AbstractScheduleService.ts │ │ └── GetIplayerShceduleService.ts │ ├── download │ │ ├── AbstractDownloadService.ts │ │ └── YTDLPDownloadService.ts │ ├── nzb │ │ ├── AbstractNZBService.ts │ │ ├── SabNZBDService.ts │ │ └── NZBGetService.ts │ ├── search │ │ └── AbstractSearchService.ts │ ├── redis │ │ ├── redisService.ts │ │ └── redisCacheService.ts │ ├── taskService.ts │ ├── socketService.ts │ ├── arr │ │ └── AbstractArrService.ts │ ├── synonymService.ts │ ├── skyhook │ │ └── SkyhookService.ts │ ├── stats │ │ └── StatisticsService.ts │ ├── historyService.ts │ └── loggingService.ts ├── utils │ └── formatters.ts ├── constants │ ├── ArrServiceDirectory.ts │ ├── iPlayarrConstants.ts │ └── EndpointDirectory.ts ├── validators │ ├── Validator.ts │ ├── OffScheduleFormValidator.ts │ └── AppFormValidator.ts ├── facade │ ├── scheduleFacade.ts │ ├── arrFacade.ts │ └── nzbFacade.ts ├── routes │ ├── json-api │ │ ├── StatisticsRoute.ts │ │ ├── QueueRoute.ts │ │ ├── SettingsRoute.ts │ │ ├── SynonymsRoute.ts │ │ └── OffScheduleRoute.ts │ └── ApiRoute.ts └── server.ts ├── .gitattributes ├── .prettierrc.json ├── readme-media ├── login.png ├── queue.png ├── search.png └── details.png ├── docker-compose.yml ├── tests ├── setup.ts ├── utils │ └── formatters.test.ts ├── endpoints │ ├── sabnzbd │ │ ├── VersionEndpoint.test.ts │ │ └── DownloadNZBEndpoint.test.ts │ └── newznab │ │ ├── CapsEndpoint.test.ts │ │ └── SearchEndpoint.test.ts ├── validators │ ├── OffScheduleFormValidator.test.ts │ └── Validator.test.ts ├── service │ ├── download │ │ ├── GetIPlayerDownloadService.test.ts │ │ └── YTDLPDownloadService.test.ts │ ├── schedule │ │ └── GetIplayerScheduleService.test.ts │ ├── socketService.test.ts │ ├── redisService.test.ts │ ├── taskService.test.ts │ └── loggingService.test.ts ├── types │ └── FixedFIFOQueue.test.ts └── routes │ ├── json-api │ └── QueueRoute.test.ts │ └── ApiRoute.test.ts ├── .dockerignore ├── .editorconfig ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── jest.config.js ├── eslint.config.mjs ├── docker_entry.sh ├── Dockerfile └── .gitignore /.node-version: -------------------------------------------------------------------------------- 1 | 23 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: nikorag 2 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/config/version.json: -------------------------------------------------------------------------------- 1 | { "version": "development" } 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /readme-media/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/readme-media/login.png -------------------------------------------------------------------------------- /readme-media/queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/readme-media/queue.png -------------------------------------------------------------------------------- /readme-media/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/readme-media/search.png -------------------------------------------------------------------------------- /readme-media/details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/readme-media/details.png -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/iplayarr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/iplayarr.png -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /src/types/User.ts: -------------------------------------------------------------------------------- 1 | class User { 2 | username: string = ''; 3 | password?: string; 4 | } 5 | 6 | export = User; 7 | -------------------------------------------------------------------------------- /src/types/responses/sabnzbd/TrueFalseResponse.ts: -------------------------------------------------------------------------------- 1 | export interface TrueFalseResponse { 2 | status: boolean; 3 | } 4 | -------------------------------------------------------------------------------- /src/types/data/AbstractHistoryEntry.ts: -------------------------------------------------------------------------------- 1 | export interface AbstractHistoryEntry { 2 | time: number, 3 | appId?: string 4 | } -------------------------------------------------------------------------------- /src/types/requests/arr/AbstractCreateIndexerRequest.ts: -------------------------------------------------------------------------------- 1 | export interface AbstractCreateIndexerRequest { 2 | id: number 3 | } -------------------------------------------------------------------------------- /src/types/GetIplayer/SpawnExecutable.ts: -------------------------------------------------------------------------------- 1 | export interface SpawnExecutable { 2 | exec: string; 3 | args: string[]; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /src/types/enums/DownloadClient.ts: -------------------------------------------------------------------------------- 1 | export enum DownloadClient { 2 | GET_IPLAYER = 'GET_IPLAYER', 3 | YTDLP = 'YTDLP', 4 | } 5 | -------------------------------------------------------------------------------- /frontend/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /frontend/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # For running container dependencies locally 2 | services: 3 | redis: 4 | image: redis 5 | ports: 6 | - 6379:6379 7 | -------------------------------------------------------------------------------- /frontend/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /frontend/public/shortcuts/Download With iPlayarr.shortcut: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/shortcuts/Download With iPlayarr.shortcut -------------------------------------------------------------------------------- /src/types/requests/nzbget/NZBGetAppendRequest.ts: -------------------------------------------------------------------------------- 1 | export interface NZBGetAppendRequest { 2 | method: string; 3 | params: any[]; 4 | id: number; 5 | } 6 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | jest.mock('dotenv'); 2 | jest.mock('ioredis', () => jest.requireActual('ioredis-mock')); 3 | 4 | beforeEach(() => { 5 | jest.clearAllMocks(); 6 | }); 7 | -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/android-chrome-maskable-192x192.png -------------------------------------------------------------------------------- /frontend/public/img/icons/android-chrome-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikorag/iplayarr/HEAD/frontend/public/img/icons/android-chrome-maskable-512x512.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | frontend/node_modules 3 | .node-persist 4 | .env 5 | .git 6 | dist 7 | readme-media 8 | eslint.config.mjs 9 | jest.config.js 10 | .github 11 | -------------------------------------------------------------------------------- /src/types/responses/arr/IndexerResponse.ts: -------------------------------------------------------------------------------- 1 | export interface IndexerResponse { 2 | name?: string; 3 | id?: string; 4 | url?: string; 5 | api_key?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/Synonym.ts: -------------------------------------------------------------------------------- 1 | export interface Synonym { 2 | id: string; 3 | from: string; 4 | target: string; 5 | filenameOverride?: string; 6 | exemptions: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/responses/EpisodeCacheTypes.ts: -------------------------------------------------------------------------------- 1 | export interface EpisodeCacheDefinition { 2 | id: string; 3 | url: string; 4 | name: string; 5 | cacheRefreshed?: Date; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | insert_final_newline = true 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 4 9 | max_line_length = 120 10 | -------------------------------------------------------------------------------- /src/endpoints/sabnzbd/VersionEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | export default (_: Request, res: Response) => { 4 | res.json({ 5 | version: '1.0.0', 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/types/responses/arr/DownloadClientResponse.ts: -------------------------------------------------------------------------------- 1 | export interface DownloadClientResponse { 2 | name?: string; 3 | id?: string; 4 | host?: string; 5 | api_key?: string; 6 | port?: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/FilenameTemplateContext.ts: -------------------------------------------------------------------------------- 1 | export interface FilenameTemplateContext { 2 | title: string; 3 | episode?: string; 4 | episodeTitle?: string; 5 | season?: string; 6 | quality?: string; 7 | synonym?: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/DownloadDetails.ts: -------------------------------------------------------------------------------- 1 | export interface DownloadDetails { 2 | uuid?: string; 3 | progress?: number; 4 | size?: number; 5 | sizeLeft?: number; 6 | speed?: number; 7 | eta?: string; 8 | start?: Date; 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "ms-azuretools.vscode-docker", 6 | "orta.vscode-jest", 7 | "vue.volar" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/service/schedule/AbstractScheduleService.ts: -------------------------------------------------------------------------------- 1 | import { IPlayerSearchResult } from '../../types/IPlayerSearchResult'; 2 | 3 | export interface AbstractScheduleService { 4 | refreshCache () : Promise; 5 | getFeed () : Promise; 6 | } -------------------------------------------------------------------------------- /src/types/data/SearchHistoryEntry.ts: -------------------------------------------------------------------------------- 1 | import { AbstractHistoryEntry } from './AbstractHistoryEntry'; 2 | 3 | export interface SearchHistoryEntry extends AbstractHistoryEntry { 4 | term: string; 5 | results: number; 6 | series?: number; 7 | episode?: number; 8 | } 9 | -------------------------------------------------------------------------------- /src/types/requests/form/CreateDownloadClientForm.ts: -------------------------------------------------------------------------------- 1 | export interface CreateDownloadClientForm { 2 | name: string; 3 | host: string; 4 | port: number; 5 | useSSL: boolean; 6 | urlBase?: string; 7 | apiKey: string; 8 | tags: string[]; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/LogLine.ts: -------------------------------------------------------------------------------- 1 | export interface LogLine { 2 | id: string; 3 | message: string; 4 | timestamp: Date; 5 | level: LogLineLevel; 6 | } 7 | 8 | export enum LogLineLevel { 9 | INFO = 'INFO', 10 | ERROR = 'ERROR', 11 | DEBUG = 'DEBUG', 12 | } 13 | -------------------------------------------------------------------------------- /src/types/responses/iplayer/IPlayerNewSearchResponse.ts: -------------------------------------------------------------------------------- 1 | export interface IPlayerNewSearchResponse { 2 | new_search: { 3 | results: IPlayerNewSearchResult[]; 4 | }; 5 | } 6 | 7 | export interface IPlayerNewSearchResult { 8 | id: string, 9 | title: string 10 | } -------------------------------------------------------------------------------- /src/types/responses/arr/ArrLookupResponse.ts: -------------------------------------------------------------------------------- 1 | export interface ArrLookupResponse { 2 | id: number; 3 | title: string; 4 | sortTitle: string; 5 | path?: string; 6 | alternateTitles?: { 7 | title: string; 8 | seasonNumber: number; 9 | }[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/data/GrabHistoryEntry.ts: -------------------------------------------------------------------------------- 1 | import { VideoType } from '../IPlayerSearchResult'; 2 | import { AbstractHistoryEntry } from './AbstractHistoryEntry'; 3 | 4 | export interface GrabHistoryEntry extends AbstractHistoryEntry { 5 | pid: string 6 | nzbName: string 7 | type: VideoType 8 | } -------------------------------------------------------------------------------- /src/service/download/AbstractDownloadService.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from 'child_process'; 2 | 3 | export default interface AbstractDownloadService { 4 | download(pid: string, directory: string): Promise; 5 | postProcess(pid: string, directory: string, code : any): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/requests/form/CreateIndexerForm.ts: -------------------------------------------------------------------------------- 1 | export interface CreateIndexerForm { 2 | appId: string; 3 | name: string; 4 | downloadClientId: number; 5 | url: string; 6 | urlBase?: string; 7 | apiKey: string; 8 | categories: number[]; 9 | priority?: number; 10 | tags: string[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/types/utils/AbstractFIFOQueue.ts: -------------------------------------------------------------------------------- 1 | export interface AbstractFIFOQueue { 2 | enqueue(item: T): Promise; 3 | dequeue(): Promise; 4 | peek(): Promise; 5 | size(): Promise; 6 | isEmpty(): Promise; 7 | getItems(): Promise; 8 | clear(): Promise; 9 | } -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "baseUrl": "./", 6 | "moduleResolution": "node", 7 | "paths": { 8 | "@/*": ["src/*"] 9 | }, 10 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /src/service/nzb/AbstractNZBService.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios' 2 | 3 | import { App } from '../../types/App'; 4 | 5 | export default interface AbstractNZBService { 6 | testConnection(inputUrl: string, credentials: { username?: string, password?: string, apikey?: string }): Promise; 7 | addFile(app: App, files: Express.Multer.File[]): Promise 8 | } -------------------------------------------------------------------------------- /src/utils/formatters.ts: -------------------------------------------------------------------------------- 1 | export function formatBytes(bytes: number, unit: boolean = true, decimals: number = 2): string { 2 | if (bytes === 0) return '0 Bytes'; 3 | 4 | const k: number = 1024; 5 | const sizes: string[] = ['Bytes', 'KB', 'MB', 'G', 'TB', 'PB']; 6 | const i: number = Math.floor(Math.log(bytes) / Math.log(k)); 7 | 8 | return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + (unit ? ' ' + sizes[i] : ''); 9 | } -------------------------------------------------------------------------------- /src/service/search/AbstractSearchService.ts: -------------------------------------------------------------------------------- 1 | import { IPlayerSearchResult } from '../../types/IPlayerSearchResult'; 2 | import { Synonym } from '../../types/Synonym'; 3 | 4 | export default interface AbstractSearchService { 5 | search(term : string, synonym?: Synonym): Promise; 6 | processCompletedSearch(results: IPlayerSearchResult[], inputTerm: string, synonym?: Synonym, season?: number, episode?: number): Promise; 7 | } -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | 5 | ``` 6 | npm install 7 | ``` 8 | 9 | ### Compiles and hot-reloads for development 10 | 11 | ``` 12 | npm run serve 13 | ``` 14 | 15 | ### Compiles and minifies for production 16 | 17 | ``` 18 | npm run build 19 | ``` 20 | 21 | ### Lints and fixes files 22 | 23 | ``` 24 | npm run lint 25 | ``` 26 | 27 | ### Customize configuration 28 | 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /src/types/IPlayerDetails.ts: -------------------------------------------------------------------------------- 1 | import { VideoType } from './IPlayerSearchResult'; 2 | 3 | export interface IPlayerDetails { 4 | pid: string; 5 | title: string; 6 | episode?: number; 7 | episodeTitle?: string; 8 | series?: number; 9 | channel?: string; 10 | category?: string; 11 | description?: string; 12 | runtime?: number; 13 | firstBroadcast?: string; 14 | link?: string; 15 | thumbnail?: string; 16 | type: VideoType; 17 | } 18 | -------------------------------------------------------------------------------- /src/constants/ArrServiceDirectory.ts: -------------------------------------------------------------------------------- 1 | import AbstractArrService from '../service/arr/AbstractArrService'; 2 | import V1ArrService from '../service/arr/V1ArrService'; 3 | import V3ArrService from '../service/arr/V3ArrService'; 4 | import { AppType } from '../types/AppType'; 5 | 6 | export const ArrServiceDirectory: Partial> = { 7 | [AppType.SONARR]: V3ArrService, 8 | [AppType.RADARR]: V3ArrService, 9 | [AppType.PROWLARR]: V1ArrService, 10 | } -------------------------------------------------------------------------------- /tests/utils/formatters.test.ts: -------------------------------------------------------------------------------- 1 | import { formatBytes } from '../../src/utils/formatters'; 2 | 3 | describe('formatBytes', () => { 4 | it('formats bytes correctly', () => { 5 | expect(formatBytes(0)).toBe('0 Bytes'); 6 | expect(formatBytes(1024)).toBe('1 KB'); 7 | expect(formatBytes(1048576)).toBe('1 MB'); 8 | }); 9 | 10 | it('returns without unit when unit = false', () => { 11 | expect(formatBytes(1024, false)).toBe('1'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/service/redis/redisService.ts: -------------------------------------------------------------------------------- 1 | import Redis, { RedisOptions } from 'ioredis'; 2 | 3 | const redisOptions: RedisOptions = { 4 | host: process.env.REDIS_HOST ?? '127.0.0.1', 5 | port: process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : 6379, 6 | tls: process.env.REDIS_SSL === 'true' ? {} : undefined, 7 | }; 8 | 9 | if (process.env.REDIS_PASSWORD) { 10 | redisOptions.password = process.env.REDIS_PASSWORD; 11 | } 12 | 13 | export const redis = new Redis(redisOptions); 14 | -------------------------------------------------------------------------------- /src/validators/Validator.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | export abstract class Validator { 4 | abstract validate(input: any): Promise<{ [key: string]: string }>; 5 | 6 | directoryExists(val: string) { 7 | return fs.existsSync(val); 8 | } 9 | 10 | isNumber(val: string | number): boolean { 11 | return val != '' && !isNaN(Number(val)); 12 | } 13 | 14 | matchesRegex(val: string, regexp: RegExp): boolean { 15 | return regexp.test(val); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/types/QueueEntry.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from 'child_process'; 2 | 3 | import { DownloadDetails } from './DownloadDetails'; 4 | import { VideoType } from './IPlayerSearchResult'; 5 | import { QueueEntryStatus } from './responses/sabnzbd/QueueResponse'; 6 | 7 | export interface QueueEntry { 8 | pid: string; 9 | status: QueueEntryStatus; 10 | process?: ChildProcess; 11 | details?: DownloadDetails; 12 | nzbName: string; 13 | type: VideoType; 14 | appId?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/types/responses/ApiResponse.ts: -------------------------------------------------------------------------------- 1 | export enum ApiError { 2 | API_NOT_FOUND = 'API Not Found', 3 | NOT_AUTHORISED = 'Not Authorised', 4 | INVALID_INPUT = 'Invalid Input', 5 | INTERNAL_ERROR = 'Internal Error', 6 | INVALID_CREDENTIALS = 'Invalid Credentials', 7 | OIDC_NOT_ENABLED = 'OIDC Not Enabled', 8 | } 9 | 10 | export interface ApiResponse { 11 | error?: ApiError; 12 | invalid_fields?: { 13 | [key: string]: string; 14 | }; 15 | message?: string; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/lib/ipFetch.js: -------------------------------------------------------------------------------- 1 | import { getHost } from './utils.js'; 2 | 3 | export const ipFetch = async (endpoint, method = 'GET', body) => { 4 | const options = { 5 | method, 6 | credentials: 'include', 7 | }; 8 | if (body) { 9 | options.headers = { 'Content-Type': 'application/json' }; 10 | options.body = JSON.stringify(body); 11 | } 12 | const response = await fetch(`${getHost()}/${endpoint}`, options); 13 | return { 14 | data: await response.json(), 15 | ok: response.ok, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/types/QualityProfiles.ts: -------------------------------------------------------------------------------- 1 | export interface QualityProfile { 2 | id: string; 3 | name: string; 4 | quality: string; 5 | sizeFactor: number; 6 | } 7 | 8 | export const qualityProfiles: QualityProfile[] = [ 9 | { id: 'mobile', name: 'Mobile', quality: '288p', sizeFactor: 0.012 }, 10 | { id: 'web', name: 'Web', quality: '396p', sizeFactor: 0.22 }, 11 | { id: 'sd', name: 'SD', quality: '540p', sizeFactor: 0.33 }, 12 | { id: 'hd', name: 'HD', quality: '720p', sizeFactor: 0.61 }, 13 | { id: 'fhd', name: 'Full-HD', quality: '1080p', sizeFactor: 1 }, 14 | ]; 15 | -------------------------------------------------------------------------------- /src/validators/OffScheduleFormValidator.ts: -------------------------------------------------------------------------------- 1 | import iplayerDetailsService from '../service/iplayerDetailsService'; 2 | import { Validator } from './Validator'; 3 | 4 | export class OffScheduleFormValidator extends Validator { 5 | async validate({ url }: any): Promise<{ [key: string]: string }> { 6 | const validatorError: { [key: string]: string } = {}; 7 | const brandPid: string | undefined = await iplayerDetailsService.findBrandForUrl(url); 8 | if (!brandPid) { 9 | validatorError.url = 'Invalid URL'; 10 | } 11 | return validatorError; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/endpoints/sabnzbd/VersionEndpoint.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import VersionEndpoint from '../../../src/endpoints/sabnzbd/VersionEndpoint'; 4 | 5 | describe('VersionEndpoint', () => { 6 | it('responds with the correct version JSON', () => { 7 | const jsonMock = jest.fn(); 8 | const res = { 9 | json: jsonMock, 10 | } as unknown as Response; 11 | 12 | VersionEndpoint({} as Request, res); 13 | 14 | expect(jsonMock).toHaveBeenCalledWith({ 15 | version: '1.0.0', 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/types/IPlayerSearchResult.ts: -------------------------------------------------------------------------------- 1 | export enum VideoType { 2 | TV = 'TV', 3 | MOVIE = 'MOVIE', 4 | UNKNOWN = 'UNKNOWN', 5 | } 6 | 7 | export interface IPlayerSearchResult { 8 | number: number; 9 | title: string; 10 | channel: string; 11 | pid: string; 12 | request: IplayerSearchResultRequest; 13 | nzbName?: string; 14 | type: VideoType; 15 | series?: number; 16 | episode?: number; 17 | episodeTitle?: string; 18 | size?: number; 19 | pubDate?: Date; 20 | } 21 | 22 | export interface IplayerSearchResultRequest { 23 | term: string; 24 | line: string; 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: PR Build Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - '**' 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 23 18 | cache: 'npm' 19 | 20 | - name: Install Dependencies 21 | run: npm run install:both 22 | 23 | - name: Run build 24 | run: npm run build:both 25 | -------------------------------------------------------------------------------- /frontend/vue.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@vue/cli-service'; 2 | 3 | export default defineConfig({ 4 | transpileDependencies: true, 5 | css: { 6 | loaderOptions: { 7 | less: { 8 | additionalData: ` 9 | @import "~@/assets/styles/variables.less"; 10 | @import "~@/assets/styles/global.less"; 11 | `, 12 | }, 13 | }, 14 | }, 15 | pwa: { 16 | name: 'iPlayarr', 17 | themeColor: '#202020', 18 | msTileColor: '#000000', 19 | appleMobileWebAppCapable: 'yes', 20 | appleMobileWebAppStatusBarStyle: 'black-translucent', 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE]' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /src/constants/iPlayarrConstants.ts: -------------------------------------------------------------------------------- 1 | export const progressRegex: RegExp = 2 | /([\d.]+)% of ~\s*(?:([\d.]+\s?[A-Za-z]+)|N\/A) @\s*([\d.]+\s?[A-Za-z]+\/s) ETA: (?:([\d:]+)|NA).*video\]$/; 3 | export const getIplayerSeriesRegex: RegExp = /: (?:Series|Season) (\d+)/; 4 | export const nativeSeriesRegex: RegExp = /^(?:(?:Series|Season) )?(\d+|[MDCLXVI]+)$/; 5 | export const episodeRegex: RegExp = /^Episode (\d+)$/; 6 | export const listFormat: string = 7 | 'RESULT|:||:||:||:||:||:||:||:||:||:|'; 8 | export const timestampFile: string = 'iplayarr_timestamp'; 9 | 10 | export const searchResultLimit: number = 150; 11 | export const pidRegex = /\/([a-z0-9]{8})(?:\/|$)/; 12 | -------------------------------------------------------------------------------- /src/endpoints/sabnzbd/AbstractSabNZBDActionEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | import { EndpointDirectory } from '../../constants/EndpointDirectory'; 4 | 5 | export interface ActionQueryString { 6 | name?: string 7 | value?: string 8 | } 9 | 10 | export class AbstractSabNZBDActionEndpoint { 11 | actionDirectory: EndpointDirectory; 12 | 13 | constructor(actionDirectory: EndpointDirectory) { 14 | this.actionDirectory = actionDirectory; 15 | } 16 | 17 | handler = async (req: Request, res: Response, next: NextFunction) => { 18 | const name = (req.query as ActionQueryString).name ?? '_default'; 19 | await this.actionDirectory[name](req, res, next); 20 | } 21 | } -------------------------------------------------------------------------------- /src/types/responses/newznab/NZBFileResponse.ts: -------------------------------------------------------------------------------- 1 | export interface NZBFileResponse { 2 | $: { xmlns: string }; 3 | head: { 4 | title: string; 5 | meta: NZBMetaEntry[]; 6 | }; 7 | file: { 8 | $: { 9 | poster: string; 10 | date: number; 11 | subject: string; 12 | }; 13 | groups: { 14 | group: string[]; 15 | }; 16 | segments: { 17 | segment: NZBSegmentEntry[]; 18 | }; 19 | }; 20 | } 21 | 22 | export interface NZBMetaEntry { 23 | _?: string; 24 | $: { type: string; _: string }; 25 | } 26 | 27 | interface NZBSegmentEntry { 28 | _: string; 29 | $: { 30 | bytes: number; 31 | number: number; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG]' 5 | labels: bug 6 | assignees: Nikorag 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Version** 27 | Please add the version, can be found on the About page. 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | transform: { 6 | '^.+\\.tsx?$': ['ts-jest', {}], 7 | }, 8 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 9 | reporters: [ 10 | 'default', 11 | [ 12 | 'jest-junit', 13 | { 14 | outputDirectory: './test-results', 15 | outputName: 'junit.xml', 16 | }, 17 | ], 18 | ], 19 | roots: [''], 20 | modulePaths: [''], 21 | setupFilesAfterEnv: ['/tests/setup.ts'], 22 | collectCoverage: true, 23 | coverageReporters: ['lcov', 'text'], 24 | testTimeout: 30000, 25 | }; 26 | -------------------------------------------------------------------------------- /src/types/App.ts: -------------------------------------------------------------------------------- 1 | import { AppType } from './AppType'; 2 | 3 | export interface App { 4 | id: string; 5 | type: AppType; 6 | name: string; 7 | url: string; 8 | link?: string; 9 | api_key?: string; 10 | username?: string; 11 | password?: string; 12 | priority?: number; 13 | tags?: string[]; 14 | iplayarr: { 15 | host: string; 16 | port: number; 17 | useSSL: boolean; 18 | }; 19 | download_client?: { 20 | id: number; 21 | name?: string; 22 | host?: string; 23 | api_key?: string; 24 | port?: number; 25 | }; 26 | indexer?: { 27 | id: number; 28 | name?: string; 29 | url?: string; 30 | api_key?: string; 31 | priority: number; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/types/responses/newznab/NewzNabSearchResponse.ts: -------------------------------------------------------------------------------- 1 | export interface NewzNabSearchResponse { 2 | $: { 3 | version: string; 4 | 'xmlns:atom': string; 5 | 'xmlns:newznab': string; 6 | }; 7 | channel: { 8 | 'atom:link': { $: { rel: string; type: string } }; 9 | title: string; 10 | item: NewzNabTVFeedItem[]; 11 | }; 12 | } 13 | 14 | interface NewzNabTVFeedItem { 15 | title: string; 16 | description: string; 17 | guid: string; 18 | comments: string; 19 | size: string; 20 | category: string[]; 21 | pubDate: string; 22 | 'newznab:attr': NewzNabAttr[]; 23 | link: string; 24 | enclosure: { $: { url: string; length: string; type: string } }; 25 | } 26 | 27 | export interface NewzNabAttr { 28 | $: { name: string; value: string }; 29 | } 30 | -------------------------------------------------------------------------------- /src/endpoints/sabnzbd/ConfigEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import configService from '../../service/configService'; 4 | import { IplayarrParameter } from '../../types/IplayarrParameters'; 5 | import { configSkeleton, SabNZBDConfigResponse } from '../../types/responses/sabnzbd/ConfigResponse'; 6 | 7 | export default async (req: Request, res: Response) => { 8 | const download_dir = (await configService.getParameter(IplayarrParameter.DOWNLOAD_DIR)) as string; 9 | const complete_dir = (await configService.getParameter(IplayarrParameter.COMPLETE_DIR)) as string; 10 | 11 | const configObject: SabNZBDConfigResponse = { 12 | ...configSkeleton, 13 | misc: { 14 | download_dir, 15 | complete_dir, 16 | }, 17 | } as SabNZBDConfigResponse; 18 | res.json({ config: configObject }); 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | iPlayarr 10 | 11 | 12 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/types/responses/newznab/CapsResponse.ts: -------------------------------------------------------------------------------- 1 | export interface NewzNabServerConfig { 2 | server: { $: { title: string } }; 3 | limits: { $: { default: string; max: string } }; 4 | searching: { 5 | search: { $: { available: string; supportedParams: string } }; 6 | 'tv-search': { $: { available: string; supportedParams: string } }; 7 | 'movie-search': { $: { available: string; supportedParams: string } }; 8 | 'music-search': { $: { available: string; supportedParams: string } }; 9 | 'audio-search': { $: { available: string; supportedParams: string } }; 10 | 'book-search': { $: { available: string; supportedParams: string } }; 11 | }; 12 | categories: { 13 | category: NewzNabCategory[]; 14 | }; 15 | tags: string; 16 | } 17 | 18 | export interface NewzNabCategory { 19 | $: { id: string; name: string }; 20 | subcat?: NewzNabCategory[]; 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "editor.formatOnSaveMode": "modificationsIfAvailable", 8 | "editor.guides.indentation": true, 9 | "editor.rulers": [120], 10 | "eslint.validate": ["javascript", "javascriptvue", "typescript", "typescriptvue"], 11 | "eslint.workingDirectories": [ 12 | { 13 | "mode": "auto" 14 | } 15 | ], 16 | "files.eol": "\n", 17 | "html.format.wrapLineLength": 120, 18 | "jest.runMode": { 19 | "coverage": true, 20 | "type": "on-save" 21 | }, 22 | "[typescript]": { 23 | "editor.defaultFormatter": "vscode.typescript-language-features" 24 | }, 25 | "[json]": { 26 | "editor.defaultFormatter": "vscode.json-language-features" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/public/img/sabnzbd.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import './registerServiceWorker'; 2 | import 'vue-final-modal/style.css'; 3 | 4 | import { library } from '@fortawesome/fontawesome-svg-core'; 5 | import { fab } from '@fortawesome/free-brands-svg-icons'; 6 | import { fas } from '@fortawesome/free-solid-svg-icons'; 7 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; 8 | import { createApp, reactive } from 'vue'; 9 | import { createVfm } from 'vue-final-modal'; 10 | import VueApexCharts from 'vue3-apexcharts'; 11 | 12 | import App from './App.vue'; 13 | import router from './router/router.js'; 14 | 15 | library.add(fas); 16 | library.add(fab); 17 | 18 | const app = createApp(App); 19 | 20 | const authState = reactive({ user: null }); 21 | app.provide('authState', authState); 22 | 23 | const vfm = createVfm(); 24 | app.use(vfm); 25 | 26 | app.use(VueApexCharts); 27 | 28 | app.component('FontAwesomeIcon', FontAwesomeIcon); 29 | app.use(router); 30 | app.mount('#app'); 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Backend", 11 | "runtimeExecutable": "npm", 12 | "runtimeArgs": ["run", "serve:backend"], 13 | "console": "integratedTerminal" 14 | }, 15 | { 16 | "type": "node", 17 | "request": "launch", 18 | "name": "Frontend", 19 | "runtimeExecutable": "npm", 20 | "runtimeArgs": ["run", "serve:frontend"], 21 | "console": "integratedTerminal" 22 | } 23 | ], 24 | "compounds": [ 25 | { 26 | "name": "Dev", 27 | "configurations": ["Backend", "Frontend"] 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/service/taskService.ts: -------------------------------------------------------------------------------- 1 | import cron from 'node-cron'; 2 | 3 | import downloadFacade from '../facade/downloadFacade'; 4 | import scheduleFacade from '../facade/scheduleFacade'; 5 | import { IplayarrParameter } from '../types/IplayarrParameters'; 6 | import configService from './configService'; 7 | import episodeCacheService from './episodeCacheService'; 8 | 9 | 10 | class TaskService { 11 | init(){ 12 | configService.getParameter(IplayarrParameter.REFRESH_SCHEDULE).then((cronSchedule) => { 13 | cron.schedule(cronSchedule as string, async () => { 14 | const nativeSearchEnabled = await configService.getParameter(IplayarrParameter.NATIVE_SEARCH); 15 | scheduleFacade.refreshCache().then(() => { 16 | if (nativeSearchEnabled == 'false') { 17 | episodeCacheService.recacheAllSeries(); 18 | } 19 | }); 20 | downloadFacade.cleanupFailedDownloads(); 21 | }); 22 | }); 23 | } 24 | } 25 | 26 | export default new TaskService(); -------------------------------------------------------------------------------- /src/service/socketService.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from 'socket.io'; 2 | 3 | import historyService from './historyService'; 4 | import queueService from './queueService'; 5 | 6 | const sockets: { 7 | [key: string]: Socket; 8 | } = {}; 9 | let io: Server | undefined = undefined; 10 | 11 | const socketService = { 12 | registerIo: (server: Server) => { 13 | io = server; 14 | io.on('connection', (socket) => { 15 | socketService.registerSocket(socket); 16 | }); 17 | }, 18 | 19 | registerSocket: async (socket: Socket) => { 20 | sockets[socket.id] = socket; 21 | 22 | const queue = queueService.getQueue(); 23 | const history = await historyService.getHistory(); 24 | 25 | socket.emit('queue', queue); 26 | socket.emit('history', history); 27 | 28 | socket.on('disconnect', () => { 29 | delete sockets[socket.id]; 30 | }); 31 | }, 32 | 33 | emit: (subject: string, message: any) => { 34 | (io as Server).emit(subject, message); 35 | }, 36 | }; 37 | 38 | export default socketService; 39 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | checks: write 13 | contents: write 14 | 15 | jobs: 16 | run-linters: 17 | name: Run linters 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Check out Git repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Node.js 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: 23 28 | 29 | - name: Install Node.js dependencies 30 | run: npm run install:both 31 | 32 | - name: Reset any package-lock changes backend 33 | run: git checkout -- package-lock.json 34 | 35 | - name: Reset any package-lock changes frontend 36 | run: git checkout -- frontend/package-lock.json 37 | 38 | - name: Run linters 39 | uses: wearerequired/lint-action@v2 40 | with: 41 | eslint: true 42 | -------------------------------------------------------------------------------- /frontend/src/components/common/NavLink.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 43 | -------------------------------------------------------------------------------- /frontend/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | import { register } from 'register-service-worker'; 2 | 3 | if (process.env.NODE_ENV === 'production') { 4 | register(`${process.env.BASE_URL}service-worker.js`, { 5 | ready() { 6 | console.log( 7 | 'App is being served from cache by a service worker.\n' + 8 | 'For more details, visit https://goo.gl/AFskqB' 9 | ); 10 | }, 11 | registered() { 12 | console.log('Service worker has been registered.'); 13 | }, 14 | cached() { 15 | console.log('Content has been cached for offline use.'); 16 | }, 17 | updatefound() { 18 | console.log('New content is downloading.'); 19 | }, 20 | updated() { 21 | console.log('New content is available; please refresh.'); 22 | }, 23 | offline() { 24 | console.log('No internet connection found. App is running in offline mode.'); 25 | }, 26 | error(error) { 27 | console.error('Error during service worker registration:', error); 28 | }, 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/types/responses/sabnzbd/ConfigResponse.ts: -------------------------------------------------------------------------------- 1 | export interface SabNZBDConfigResponse { 2 | misc: SabNZBDConfigMiscResponse; 3 | categories: SabNZBDConfigCategoryResponse[]; 4 | servers: string[]; 5 | } 6 | 7 | export interface SabNZBDConfigCategoryResponse { 8 | name: string; 9 | order: number; 10 | pp: string; 11 | script: string; 12 | dir: string; 13 | newzbin: string; 14 | priority: number; 15 | } 16 | 17 | export interface SabNZBDConfigMiscResponse { 18 | download_dir: string; 19 | complete_dir: string; 20 | } 21 | 22 | export const configSkeleton: Partial = { 23 | categories: [ 24 | { 25 | name: '*', 26 | order: 0, 27 | pp: '3', 28 | script: 'None', 29 | dir: '', 30 | newzbin: '', 31 | priority: 0, 32 | }, 33 | { 34 | name: 'iplayer', 35 | order: 1, 36 | pp: '', 37 | script: 'Default', 38 | dir: '', 39 | newzbin: '', 40 | priority: -100, 41 | }, 42 | ], 43 | servers: [], 44 | }; 45 | -------------------------------------------------------------------------------- /src/facade/scheduleFacade.ts: -------------------------------------------------------------------------------- 1 | import configService from '../service/configService'; 2 | import { AbstractScheduleService } from '../service/schedule/AbstractScheduleService'; 3 | import GetIplayerShceduleService from '../service/schedule/GetIplayerShceduleService'; 4 | import NativeScheduleService from '../service/schedule/NativeScheduleService'; 5 | import { IplayarrParameter } from '../types/IplayarrParameters'; 6 | import { IPlayerSearchResult } from '../types/IPlayerSearchResult'; 7 | 8 | class ScheduleFacade { 9 | async refreshCache () : Promise { 10 | const service = await this.#getService(); 11 | await service.refreshCache(); 12 | } 13 | 14 | async getFeed () : Promise { 15 | const service = await this.#getService(); 16 | return await service.getFeed(); 17 | } 18 | 19 | async #getService(): Promise { 20 | const nativeSearchEnabled = await configService.getParameter(IplayarrParameter.NATIVE_SEARCH); 21 | return nativeSearchEnabled == 'true' ? NativeScheduleService : GetIplayerShceduleService; 22 | } 23 | } 24 | 25 | export default new ScheduleFacade(); -------------------------------------------------------------------------------- /frontend/public/img/radarr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 13 | 15 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/types/utils/FixedFIFOQueue.ts: -------------------------------------------------------------------------------- 1 | import { AbstractFIFOQueue } from './AbstractFIFOQueue'; 2 | 3 | export class FixedFIFOQueue implements AbstractFIFOQueue { 4 | private queue: T[] = []; 5 | private maxSize: number; 6 | 7 | constructor(maxSize: number) { 8 | this.maxSize = maxSize; 9 | } 10 | 11 | async clear(): Promise { 12 | this.queue = []; 13 | } 14 | 15 | async enqueue(item: T): Promise { 16 | if (this.queue.length >= this.maxSize) { 17 | this.queue.shift(); // Remove the oldest item 18 | } 19 | this.queue.push(item); 20 | } 21 | 22 | async dequeue(): Promise { 23 | return this.queue.shift(); // Remove and return the oldest item 24 | } 25 | 26 | async peek(): Promise { 27 | return this.queue[0]; // Check the oldest item without removing it 28 | } 29 | 30 | async size(): Promise { 31 | return this.queue.length; 32 | } 33 | 34 | async isEmpty(): Promise { 35 | return this.queue.length === 0; 36 | } 37 | 38 | async getItems(): Promise { 39 | return [...this.queue]; // Return a copy of the queue 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Jest Tests 2 | 3 | permissions: 4 | checks: write 5 | pull-requests: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | branches: 13 | - main 14 | jobs: 15 | test: 16 | name: Run Tests and publish Reports 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 23 25 | cache: 'npm' 26 | 27 | - name: Install Dependencies 28 | run: npm install 29 | 30 | - name: Run Jest Tests 31 | run: | 32 | npm test 33 | 34 | - name: Publish Test Report 35 | uses: mikepenz/action-junit-report@v5 36 | if: success() || failure() 37 | with: 38 | report_paths: ./test-results/junit.xml 39 | token: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - uses: hrishikesh-kadam/setup-lcov@v1 42 | 43 | - name: Coveralls 44 | uses: coverallsapp/github-action@v2 45 | -------------------------------------------------------------------------------- /src/service/arr/AbstractArrService.ts: -------------------------------------------------------------------------------- 1 | import { App } from '../../types/App'; 2 | import { CreateDownloadClientForm } from '../../types/requests/form/CreateDownloadClientForm'; 3 | import { CreateIndexerForm } from '../../types/requests/form/CreateIndexerForm'; 4 | import { ArrLookupResponse } from '../../types/responses/arr/ArrLookupResponse'; 5 | import { DownloadClientResponse } from '../../types/responses/arr/DownloadClientResponse'; 6 | import { IndexerResponse } from '../../types/responses/arr/IndexerResponse'; 7 | 8 | export interface ArrTag { 9 | id: number; 10 | label: string; 11 | } 12 | 13 | export default interface AbstractArrService { 14 | upsertDownloadClient(form: CreateDownloadClientForm, app: App, allowCreate: boolean): Promise; 15 | getDownloadClient(app: App): Promise; 16 | 17 | upsertIndexer(form: CreateIndexerForm, app: App, allowCreate: boolean): Promise; 18 | getIndexer(app: App): Promise; 19 | 20 | testConnection(app: App): Promise; 21 | 22 | getTags(app: App): Promise; 23 | createTag(app: App, label: string): Promise; 24 | search(app: App, term?: string): Promise; 25 | } -------------------------------------------------------------------------------- /src/routes/json-api/StatisticsRoute.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | 3 | import statisticsService from '../../service/stats/StatisticsService'; 4 | 5 | const router = Router(); 6 | 7 | router.get('/searchHistory', async (req: Request, res: Response) => { 8 | const { limit, filterRss } = req.query as any as { limit?: number, filterRss?: boolean }; 9 | let searchHistory = await statisticsService.getSearchHistory(); 10 | searchHistory = filterRss ? searchHistory.filter(({ term }) => term != '*') : searchHistory; 11 | res.json(limit ? searchHistory.slice(limit * -1) : searchHistory); 12 | }); 13 | 14 | router.get('/grabHistory', async (req: Request, res: Response) => { 15 | const { limit } = req.query as any as { limit?: number }; 16 | const grabHistory = await statisticsService.getGrabHistory(); 17 | res.json(limit ? grabHistory.slice(limit * -1) : grabHistory); 18 | }); 19 | 20 | router.get('/uptime', async (_, res: Response) => { 21 | const uptime = await statisticsService.getUptime(); 22 | res.json({ uptime }); 23 | }); 24 | 25 | router.get('/cacheSizes', async (_, res: Response) => { 26 | const cacheSizes = await statisticsService.getCacheSizes(); 27 | res.json(cacheSizes); 28 | }); 29 | 30 | export default router; -------------------------------------------------------------------------------- /frontend/src/components/common/InfoBar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 51 | -------------------------------------------------------------------------------- /src/types/IplayarrParameters.ts: -------------------------------------------------------------------------------- 1 | export enum IplayarrParameter { 2 | DEBUG = 'DEBUG', 3 | PORT = 'PORT', 4 | API_KEY = 'API_KEY', 5 | ACTIVE_LIMIT = 'ACTIVE_LIMIT', 6 | DOWNLOAD_DIR = 'DOWNLOAD_DIR', 7 | COMPLETE_DIR = 'COMPLETE_DIR', 8 | DOWNLOAD_CLIENT = 'DOWNLOAD_CLIENT', 9 | GET_IPLAYER_EXEC = 'GET_IPLAYER_EXEC', 10 | YTDLP_EXEC = 'YTDLP_EXEC', 11 | ADDITIONAL_IPLAYER_DOWNLOAD_PARAMS = 'ADDITIONAL_IPLAYER_DOWNLOAD_PARAMS', 12 | SUB_DIR = 'SUB_DIR', 13 | VIDEO_QUALITY = 'VIDEO_QUALITY', 14 | 15 | REFRESH_SCHEDULE = 'REFRESH_SCHEDULE', 16 | 17 | 18 | AUTH_TYPE = 'AUTH_TYPE', 19 | AUTH_USERNAME = 'AUTH_USERNAME', 20 | AUTH_PASSWORD = 'AUTH_PASSWORD', 21 | 22 | OIDC_CONFIG_URL = 'OIDC_CONFIG_URL', 23 | OIDC_CLIENT_ID = 'OIDC_CLIENT_ID', 24 | OIDC_CLIENT_SECRET = 'OIDC_CLIENT_SECRET', 25 | OIDC_CALLBACK_HOST = 'OIDC_CALLBACK_HOST', 26 | OIDC_ALLOWED_EMAILS = 'OIDC_ALLOWED_EMAILS', 27 | 28 | TV_FILENAME_TEMPLATE = 'TV_FILENAME_TEMPLATE', 29 | MOVIE_FILENAME_TEMPLATE = 'MOVIE_FILENAME_TEMPLATE', 30 | FALLBACK_FILENAME_SUFFIX = 'FALLBACK_FILENAME_SUFFIX', 31 | 32 | RSS_FEED_HOURS = 'RSS_FEED_HOURS', 33 | NATIVE_SEARCH = 'NATIVE_SEARCH', 34 | ARCHIVE_ENABLED = 'ARCHIVE_ENABLED', 35 | OUTPUT_FORMAT = 'OUTPUT_FORMAT' 36 | } 37 | -------------------------------------------------------------------------------- /src/validators/AppFormValidator.ts: -------------------------------------------------------------------------------- 1 | import appService from '../service/appService'; 2 | import { Validator } from './Validator'; 3 | 4 | export class AppFormValidator extends Validator { 5 | async validate(input: any): Promise<{ [key: string]: string }> { 6 | const validatorError: { [key: string]: string } = {}; 7 | const testResult: string | boolean = await appService.testAppConnection(input); 8 | if (testResult != true) { 9 | validatorError['api_key'] = testResult as string; 10 | validatorError['url'] = testResult as string; 11 | } 12 | if ((input.indexer?.name || input.indexer?.priority) && !input.download_client?.name) { 13 | validatorError['indexer_name'] = 'Cannot create Indexer without Download Client' as string; 14 | validatorError['indexer_priority'] = 'Cannot create Indexer without Download Client' as string; 15 | } 16 | if (input.indexer?.priority && (input.indexer.priority < 0 || input.indexer.priority > 50)) { 17 | validatorError['indexer_priority'] = 'Priority must be between 0 and 50' as string; 18 | } 19 | if (input.priority && input.priority < 0) { 20 | validatorError['priority'] = 'Priority must be a positive number' as string; 21 | } 22 | return validatorError; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/common/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | 23 | 58 | -------------------------------------------------------------------------------- /tests/validators/OffScheduleFormValidator.test.ts: -------------------------------------------------------------------------------- 1 | import iplayerDetailsService from '../../src/service/iplayerDetailsService'; 2 | import { OffScheduleFormValidator } from '../../src/validators/OffScheduleFormValidator'; 3 | 4 | // Mock the iplayerDetailsService 5 | jest.mock('../../src/service/iplayerDetailsService', () => ({ 6 | findBrandForUrl: jest.fn(), 7 | })); 8 | 9 | describe('OffScheduleFormValidator', () => { 10 | let validator: OffScheduleFormValidator; 11 | 12 | beforeEach(() => { 13 | validator = new OffScheduleFormValidator(); 14 | }); 15 | 16 | it('should return no error for a valid URL with a brand PID', async () => { 17 | (iplayerDetailsService.findBrandForUrl as jest.Mock).mockResolvedValue('b006q2x0'); 18 | 19 | const input = { url: 'https://www.bbc.co.uk/programmes/b006q2x0' }; 20 | const result = await validator.validate(input); 21 | expect(result).toEqual({}); 22 | }); 23 | 24 | it('should return an error for an invalid URL with no brand PID', async () => { 25 | (iplayerDetailsService.findBrandForUrl as jest.Mock).mockResolvedValue(undefined); 26 | 27 | const input = { url: 'https://www.bbc.co.uk/invalid-programme' }; 28 | const result = await validator.validate(input); 29 | expect(result).toHaveProperty('url', 'Invalid URL'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/endpoints/newznab/CapsEndpoint.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { parseStringPromise } from 'xml2js'; 3 | 4 | import CapsEndpoint from '../../../src/endpoints/newznab/CapsEndpoint'; 5 | 6 | describe('CapsEndpoint', () => { 7 | let req: Partial; 8 | let res: Partial; 9 | let sendMock: jest.Mock; 10 | let setMock: jest.Mock; 11 | 12 | beforeEach(() => { 13 | sendMock = jest.fn(); 14 | setMock = jest.fn(); 15 | req = {}; 16 | res = { 17 | set: setMock, 18 | send: sendMock, 19 | }; 20 | }); 21 | 22 | it('responds with valid XML and correct headers', async () => { 23 | await CapsEndpoint(req as Request, res as Response); 24 | 25 | expect(setMock).toHaveBeenCalledWith('Content-Type', 'application/xml'); 26 | expect(sendMock).toHaveBeenCalled(); 27 | 28 | const xml = sendMock.mock.calls[0][0]; 29 | expect(typeof xml).toBe('string'); 30 | 31 | const parsed = await parseStringPromise(xml); 32 | 33 | expect(parsed).toHaveProperty('caps'); 34 | expect(parsed.caps).toHaveProperty('server'); 35 | expect(parsed.caps).toHaveProperty('limits'); 36 | expect(parsed.caps).toHaveProperty('searching'); 37 | expect(parsed.caps).toHaveProperty('categories'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/routes/json-api/QueueRoute.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | 3 | import historyService from '../../service/historyService'; 4 | import queueService from '../../service/queueService'; 5 | import socketService from '../../service/socketService'; 6 | import { QueueEntry } from '../../types/QueueEntry'; 7 | 8 | const router = Router(); 9 | 10 | interface DeleteRequest { 11 | pid: string; 12 | } 13 | 14 | router.get('/queue', (_: Request, res: Response) => { 15 | const queue: QueueEntry[] = queueService.getQueue() || []; 16 | res.json(queue); 17 | }); 18 | 19 | router.get('/history', async (_: Request, res: Response) => { 20 | const history: QueueEntry[] = (await historyService.getHistory()) || []; 21 | res.json(history); 22 | }); 23 | 24 | router.delete('/history', async (req: Request, res: Response) => { 25 | const { pid } = req.query as any as DeleteRequest; 26 | await historyService.removeHistory(pid); 27 | const history = (await historyService.getHistory()) || []; 28 | socketService.emit('history', history); 29 | res.json(history); 30 | }); 31 | 32 | router.delete('/queue', async (req: Request, res: Response) => { 33 | const { pid } = req.query as any as DeleteRequest; 34 | queueService.cancelItem(pid); 35 | const queue: QueueEntry[] = queueService.getQueue() || []; 36 | socketService.emit('queue', queue); 37 | res.json(queue); 38 | }); 39 | 40 | export default router; 41 | -------------------------------------------------------------------------------- /src/constants/EndpointDirectory.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | 3 | import DownloadEndpoint from '../endpoints/generic/DownloadEndpoint'; 4 | import CapsEndpoint from '../endpoints/newznab/CapsEndpoint'; 5 | import SearchEndpoint from '../endpoints/newznab/SearchEndpoint'; 6 | import AddFileEndpoint from '../endpoints/sabnzbd/AddFileEndpoint'; 7 | import ConfigEndpoint from '../endpoints/sabnzbd/ConfigEndpoint'; 8 | import DownloadNZBEndpoint from '../endpoints/sabnzbd/DownloadNZBEndpoint'; 9 | import HistoryEndpoint from '../endpoints/sabnzbd/HistoryEndpoint'; 10 | import QueueEndpoint from '../endpoints/sabnzbd/QueueEndpoint'; 11 | import VersionEndpoint from '../endpoints/sabnzbd/VersionEndpoint'; 12 | 13 | export interface EndpointDirectory { 14 | [key: string]: RequestHandler; 15 | } 16 | 17 | export const GenericEndpointDirectory: EndpointDirectory = { 18 | download: DownloadEndpoint, 19 | }; 20 | 21 | export const SabNZBDEndpointDirectory: EndpointDirectory = { 22 | ...GenericEndpointDirectory, 23 | queue: QueueEndpoint, 24 | get_config: ConfigEndpoint, 25 | history: HistoryEndpoint, 26 | version: VersionEndpoint, 27 | 'nzb-download': DownloadNZBEndpoint, 28 | addfile: AddFileEndpoint, 29 | }; 30 | 31 | export const NewzNabEndpointDirectory: EndpointDirectory = { 32 | ...GenericEndpointDirectory, 33 | caps: CapsEndpoint, 34 | tvsearch: SearchEndpoint, 35 | movie: SearchEndpoint, 36 | search: SearchEndpoint, 37 | }; 38 | -------------------------------------------------------------------------------- /src/types/utils/RedisFIFOQueue.ts: -------------------------------------------------------------------------------- 1 | import { redis } from '../../service/redis/redisService' 2 | import { AbstractFIFOQueue } from './AbstractFIFOQueue'; 3 | 4 | export class RedisFIFOQueue implements AbstractFIFOQueue { 5 | private key: string; 6 | private maxSize: number; 7 | 8 | constructor(key: string, maxSize: number) { 9 | this.key = key; 10 | this.maxSize = maxSize; 11 | } 12 | 13 | async enqueue(item: T): Promise { 14 | const serialized = JSON.stringify(item); 15 | await redis.lpush(this.key, serialized); 16 | await redis.ltrim(this.key, 0, this.maxSize - 1); 17 | } 18 | 19 | async dequeue(): Promise { 20 | const item = await redis.rpop(this.key); 21 | return item ? (JSON.parse(item) as T) : undefined; 22 | } 23 | 24 | async peek(): Promise { 25 | const item = await redis.lindex(this.key, -1); 26 | return item ? (JSON.parse(item) as T) : undefined; 27 | } 28 | 29 | async size(): Promise { 30 | return redis.llen(this.key); 31 | } 32 | 33 | async isEmpty(): Promise { 34 | return (await this.size()) === 0; 35 | } 36 | 37 | async getItems(): Promise { 38 | const items = await redis.lrange(this.key, 0, -1); 39 | return items.map((item) => JSON.parse(item) as T); 40 | } 41 | 42 | async clear(): Promise { 43 | await redis.del(this.key); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/endpoints/generic/DownloadEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import iplayerDetailsService from '../../service/iplayerDetailsService'; 4 | import queueService from '../../service/queueService'; 5 | import { VideoType } from '../../types/IPlayerSearchResult'; 6 | import { IPlayerMetadataResponse } from '../../types/responses/IPlayerMetadataResponse'; 7 | 8 | export default async (req: Request, res: Response) => { 9 | const { pid } = req.query as any; 10 | 11 | const metadata: IPlayerMetadataResponse | undefined = await iplayerDetailsService.getMetadata(pid); 12 | let name: string = ''; 13 | let type: VideoType = VideoType.TV; 14 | if (metadata?.programme.display_title) { 15 | type = getType(metadata); 16 | const { title, subtitle } = metadata.programme.display_title; 17 | name = `${title}${type == VideoType.TV && subtitle ? `.${subtitle}` : ''}`; 18 | name = name.replaceAll('.', '_') 19 | name = name.replaceAll(' ', '.'); 20 | } 21 | 22 | queueService.addToQueue(pid, name, type); 23 | 24 | res.json({ status: true }); 25 | }; 26 | 27 | function getType(metadata: IPlayerMetadataResponse): VideoType { 28 | if (metadata.programme.categories) { 29 | const formatCategory = metadata.programme.categories.find(({ type }) => type == 'format'); 30 | if (formatCategory && formatCategory.key == 'films') { 31 | return VideoType.MOVIE; 32 | } 33 | } 34 | return VideoType.TV; 35 | } 36 | -------------------------------------------------------------------------------- /src/routes/ApiRoute.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, Router } from 'express'; 2 | import multer, { Multer } from 'multer'; 3 | 4 | import { EndpointDirectory, NewzNabEndpointDirectory, SabNZBDEndpointDirectory } from '../constants/EndpointDirectory'; 5 | import configService from '../service/configService'; 6 | import { IplayarrParameter } from '../types/IplayarrParameters'; 7 | import { ApiError, ApiResponse } from '../types/responses/ApiResponse'; 8 | 9 | const router: Router = Router(); 10 | const upload: Multer = multer(); 11 | 12 | interface ApiRequest { 13 | apikey: string; 14 | mode?: string; 15 | t?: string; 16 | } 17 | 18 | router.all('/', upload.any(), async (req: Request, res: Response, next: NextFunction) => { 19 | const { apikey: queryKey, mode, t } = req.query as any as ApiRequest; 20 | const envKey: string | undefined = await configService.getParameter(IplayarrParameter.API_KEY); 21 | if (envKey && envKey == queryKey) { 22 | const endpoint: string | undefined = mode || t; 23 | const directory: EndpointDirectory = mode ? SabNZBDEndpointDirectory : NewzNabEndpointDirectory; 24 | if (endpoint && directory[endpoint]) { 25 | directory[endpoint](req, res, next); 26 | } else { 27 | res.status(404).json({ error: ApiError.API_NOT_FOUND } as ApiResponse); 28 | } 29 | } else { 30 | res.status(401).json({ error: ApiError.NOT_AUTHORISED } as ApiResponse); 31 | } 32 | }); 33 | 34 | export default router; 35 | -------------------------------------------------------------------------------- /src/service/schedule/GetIplayerShceduleService.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | 3 | import downloadFacade from '../../facade/downloadFacade'; 4 | import { IPlayerSearchResult } from '../../types/IPlayerSearchResult'; 5 | import getIplayerExecutableService from '../getIplayerExecutableService'; 6 | import loggingService from '../loggingService'; 7 | import GetIplayerSearchService from '../search/GetIplayerSearchService'; 8 | import { AbstractScheduleService } from './AbstractScheduleService'; 9 | 10 | class GetIplayerScheduleService implements AbstractScheduleService { 11 | async refreshCache(): Promise { 12 | const { exec, args } = await getIplayerExecutableService.getIPlayerExec(); 13 | 14 | //Refresh the cache 15 | loggingService.debug(`Executing get_iplayer with args: ${[...args].join(' ')} --cache-rebuild`); 16 | const refreshService = spawn(exec as string, [...args, '--cache-rebuild'], { shell: true }); 17 | 18 | refreshService.stdout.on('data', (data) => { 19 | loggingService.debug(data.toString()); 20 | }); 21 | 22 | refreshService.stderr.on('data', (data) => { 23 | loggingService.error(data.toString()); 24 | }); 25 | 26 | //Delete failed jobs 27 | downloadFacade.cleanupFailedDownloads(); 28 | } 29 | 30 | async getFeed(): Promise { 31 | return await GetIplayerSearchService.search('*'); 32 | } 33 | } 34 | 35 | export default new GetIplayerScheduleService(); -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import pluginJs from '@eslint/js'; 2 | import eslintConfigPrettier from 'eslint-config-prettier/flat'; 3 | import importPlugin from 'eslint-plugin-import'; 4 | import simpleImportSort from 'eslint-plugin-simple-import-sort'; 5 | import pluginVue from 'eslint-plugin-vue'; 6 | import globals from 'globals'; 7 | import tseslint from 'typescript-eslint'; 8 | 9 | /** @type {import('eslint').Linter.Config[]} */ 10 | export default [ 11 | { files: ['**/*.{ts,vue}', 'frontend/src/**/*.{js}'] }, 12 | { languageOptions: { globals: { ...globals.node, ...globals.browser } } }, 13 | pluginJs.configs.recommended, 14 | ...tseslint.configs.recommended, 15 | ...pluginVue.configs['flat/recommended'], 16 | { ignores: ['**/dist/**', '**/node_modules/**'] }, 17 | { 18 | plugins: { 19 | import: importPlugin, 20 | 'simple-import-sort': simpleImportSort, 21 | }, 22 | rules: { 23 | indent: ['error', 4], 24 | '@typescript-eslint/no-explicit-any': 'off', 25 | 'no-useless-catch': 'off', 26 | 'no-async-promise-executor': 'off', 27 | 'simple-import-sort/imports': 'error', 28 | 'simple-import-sort/exports': 'error', 29 | 'vue/max-attributes-per-line': 'off', 30 | 'vue/require-default-prop': 'off', 31 | }, 32 | }, 33 | eslintConfigPrettier, 34 | { 35 | // Override Prettier disabling specific rules 36 | rules: { 37 | quotes: ['error', 'single'], 38 | }, 39 | }, 40 | ]; 41 | -------------------------------------------------------------------------------- /tests/endpoints/sabnzbd/DownloadNZBEndpoint.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import DownloadNZBEndpoint from '../../../src/endpoints/sabnzbd/DownloadNZBEndpoint'; 4 | import { VideoType } from '../../../src/types/IPlayerSearchResult'; // Adjust the path as needed 5 | 6 | jest.mock('xml2js', () => { 7 | return { 8 | Builder: jest.fn().mockImplementation(() => { 9 | return { 10 | buildObject: jest.fn().mockReturnValue('mocked xml content'), 11 | }; 12 | }), 13 | }; 14 | }); 15 | 16 | describe('DownloadNZBEndpoint', () => { 17 | it('responds with the correct NZB file', async () => { 18 | const sendMock = jest.fn(); 19 | const setMock = jest.fn(); 20 | const res = { 21 | set: setMock, 22 | send: sendMock, 23 | } as unknown as Response; 24 | 25 | const req = { 26 | query: { 27 | pid: '12345', 28 | nzbName: 'sample-nzb', 29 | type: VideoType.TV, 30 | app: 'testApp', 31 | }, 32 | } as unknown as Request; 33 | 34 | await DownloadNZBEndpoint(req, res); 35 | 36 | expect(setMock).toHaveBeenCalledWith('Content-Type', 'application/x-nzb'); 37 | expect(sendMock).toHaveBeenCalledWith( 38 | expect.stringContaining('mocked xml content') 42 | ); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/service/download/GetIPlayerDownloadService.test.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | 3 | import getIplayerDownloadService from '../../../src/service/download/GetIplayerDownloadService'; 4 | import getIplayerExecutableService from '../../../src/service/getIplayerExecutableService'; 5 | 6 | // Mock the external dependencies 7 | jest.mock('child_process', () => ({ 8 | spawn: jest.fn(), 9 | })); 10 | 11 | jest.mock('../../../src/service/getIplayerExecutableService', () => ({ 12 | __esModule: true, 13 | default: { 14 | getAllDownloadParameters: jest.fn(), 15 | }, 16 | })); 17 | 18 | describe('GetIplayerDownloadService', () => { 19 | afterEach(() => { 20 | jest.clearAllMocks(); 21 | }); 22 | 23 | it('should call spawn with correct parameters when download is called', async () => { 24 | const pid = 'test-pid'; 25 | const directory = 'test-directory'; 26 | const exec = 'some-executable'; 27 | const args = ['some-arg1', 'some-arg2']; 28 | 29 | (getIplayerExecutableService.getAllDownloadParameters as jest.Mock).mockResolvedValue({ exec, args }); 30 | const mockChildProcess = { stdout: { on: jest.fn() }, stderr: { on: jest.fn() } }; 31 | (spawn as jest.Mock).mockReturnValue(mockChildProcess); 32 | 33 | const result = await getIplayerDownloadService.download(pid, directory); 34 | 35 | expect(getIplayerExecutableService.getAllDownloadParameters).toHaveBeenCalledWith(pid, directory); 36 | expect(spawn).toHaveBeenCalledWith(exec, args); 37 | expect(result).toBe(mockChildProcess); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /frontend/src/views/QueueInfoPage.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 50 | -------------------------------------------------------------------------------- /frontend/src/components/modals/AlertDialog.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 48 | 49 | 61 | -------------------------------------------------------------------------------- /src/types/AppType.ts: -------------------------------------------------------------------------------- 1 | export enum AppType { 2 | SONARR = 'SONARR', 3 | RADARR = 'RADARR', 4 | PROWLARR = 'PROWLARR', 5 | SABNZBD = 'SABNZBD', 6 | NZBGET = 'NZBGET', 7 | // LIDARR = 'LIDARR' 8 | } 9 | 10 | export enum AppFeature { 11 | API_KEY = 'api_key', 12 | CALLBACK = 'callback', 13 | DOWNLOAD_CLIENT = 'download_client', 14 | INDEXER = 'indexer', 15 | USERNAME_PASSWORD = 'username_password', 16 | PRIORITY = 'priority', 17 | LINK = 'link', 18 | TAGS = 'tags', 19 | } 20 | 21 | export const appCategories: Record = { 22 | [AppType.SONARR]: [5030, 5040], 23 | [AppType.RADARR]: [2010, 2020, 2030, 2040, 2045, 2050, 2060], 24 | [AppType.PROWLARR]: [5030, 5040, 2010, 2020, 2030, 2040, 2045, 2050, 2060], 25 | [AppType.SABNZBD]: [], 26 | [AppType.NZBGET]: [], 27 | }; 28 | 29 | export const appFeatures: Record = { 30 | [AppType.SONARR]: [ 31 | AppFeature.API_KEY, 32 | AppFeature.CALLBACK, 33 | AppFeature.DOWNLOAD_CLIENT, 34 | AppFeature.INDEXER, 35 | AppFeature.TAGS, 36 | ], 37 | [AppType.RADARR]: [ 38 | AppFeature.API_KEY, 39 | AppFeature.CALLBACK, 40 | AppFeature.DOWNLOAD_CLIENT, 41 | AppFeature.INDEXER, 42 | AppFeature.TAGS, 43 | ], 44 | [AppType.PROWLARR]: [ 45 | AppFeature.API_KEY, 46 | AppFeature.CALLBACK, 47 | AppFeature.DOWNLOAD_CLIENT, 48 | AppFeature.INDEXER, 49 | ], 50 | [AppType.SABNZBD]: [AppFeature.API_KEY, AppFeature.PRIORITY, AppFeature.LINK], 51 | [AppType.NZBGET]: [AppFeature.USERNAME_PASSWORD, AppFeature.PRIORITY, AppFeature.LINK], 52 | }; 53 | -------------------------------------------------------------------------------- /frontend/src/views/DownloadPage.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 49 | -------------------------------------------------------------------------------- /frontend/src/views/LogsPage.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 57 | -------------------------------------------------------------------------------- /src/service/nzb/SabNZBDService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | import FormData from 'form-data'; 3 | 4 | import { App } from '../../types/App'; 5 | import AbstractNZBService from './AbstractNZBService'; 6 | 7 | class SabNZBDService implements AbstractNZBService { 8 | getAddFileUrl({ url, api_key }: App): string { 9 | return `${url}/api?mode=addfile&cat=iplayer&priority=-100&apikey=${api_key}`; 10 | } 11 | 12 | async testConnection(sabnzbdUrl: string, { apiKey }: any): Promise { 13 | const url: string = `${sabnzbdUrl}/api?mode=queue&apikey=${apiKey}`; 14 | 15 | try { 16 | const response = await axios.get(url); 17 | if (response.status == 200) return true; 18 | return false; 19 | } catch (error) { 20 | if (axios.isAxiosError(error)) { 21 | return error.message; 22 | } 23 | return false; 24 | } 25 | } 26 | 27 | async addFile(app: App, files: Express.Multer.File[]): Promise { 28 | const url = this.getAddFileUrl(app); 29 | 30 | const formData = new FormData(); 31 | if (files) { 32 | files.forEach((file) => { 33 | formData.append('nzbfile', file.buffer, { 34 | filename: file.originalname, 35 | contentType: file.mimetype, 36 | }); 37 | }); 38 | } 39 | 40 | const response = await axios.post(url, formData, { 41 | headers: { 42 | 'Content-Type': 'multipart/form-data', 43 | }, 44 | }); 45 | 46 | return response; 47 | } 48 | 49 | } 50 | 51 | export default new SabNZBDService(); 52 | -------------------------------------------------------------------------------- /frontend/src/components/modals/IPlayarrModal.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 46 | 47 | 54 | -------------------------------------------------------------------------------- /frontend/src/components/charts/PieChart.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/components/common/LoadingIndicator.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 34 | 35 | 70 | -------------------------------------------------------------------------------- /frontend/src/components/modals/AppSelectDialog.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 43 | 44 | 63 | -------------------------------------------------------------------------------- /docker_entry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [[ -z "$REDIS_HOST" ]]; then 5 | echo "Starting redis" 6 | /redis/redis-server --dir /config & 7 | fi 8 | 9 | PUID=${PUID:-1000} 10 | PGID=${PGID:-1000} 11 | USERNAME="iplayarr" 12 | 13 | echo "Starting container with UID:$PUID and GID:$PGID" 14 | 15 | EXISTING_GROUP=$(getent group "$PGID" | cut -d: -f1) 16 | if [ -z "$EXISTING_GROUP" ]; then 17 | GROUPNAME="$USERNAME" 18 | addgroup -g "$PGID" "$GROUPNAME" 19 | else 20 | GROUPNAME="$EXISTING_GROUP" 21 | fi 22 | 23 | EXISTING_USER=$(getent passwd "$PUID" | cut -d: -f1) 24 | if [ -z "$EXISTING_USER" ]; then 25 | adduser -D -u "$PUID" -G "$GROUPNAME" "$USERNAME" 26 | EXISTING_USER="$USERNAME" 27 | fi 28 | 29 | if [ -n "$STORAGE_LOCATION" ] && [ -d "$STORAGE_LOCATION" ]; then 30 | chown -R "${EXISTING_USER}":"${GROUPNAME}" "${STORAGE_LOCATION}" || { echo "Failed to change ownership of ${STORAGE_LOCATION} to ${PUID}:${PGID}"; exit 1; } 31 | else 32 | echo "STORAGE_LOCATION is not set or does not exist" 33 | exit 1 34 | fi 35 | 36 | if [ -n "$CACHE_LOCATION" ] && [ -d "$CACHE_LOCATION" ]; then 37 | chown -R "${EXISTING_USER}":"${GROUPNAME}" "${CACHE_LOCATION}" || { echo "Failed to change ownership of ${CACHE_LOCATION} to ${PUID}:${PGID}"; exit 1; } 38 | else 39 | echo "CACHE_LOCATION is not set or does not exist" 40 | exit 1 41 | fi 42 | 43 | if [ -n "$LOG_DIR" ] && [ -d "$LOG_DIR" ]; then 44 | chown -R "${EXISTING_USER}":"${GROUPNAME}" "${LOG_DIR}" || { echo "Failed to change ownership of ${LOG_DIR} to ${PUID}:${PGID}"; exit 1; } 45 | else 46 | echo "LOG_DIR is not set or does not exist" 47 | exit 1 48 | fi 49 | 50 | find /app -name "node_modules" -prune -o \! -user "$PUID" \! -group "$PGID" -exec chown "${EXISTING_USER}":"${GROUPNAME}" {} + 51 | exec su-exec "$EXISTING_USER" "$@" 52 | -------------------------------------------------------------------------------- /tests/validators/Validator.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { Validator } from '../../src/validators/Validator'; 4 | 5 | jest.mock('fs'); 6 | 7 | class TestValidator extends Validator { 8 | async validate() { 9 | return {}; 10 | } 11 | } 12 | 13 | describe('Validator', () => { 14 | let validator: Validator; 15 | 16 | beforeEach(() => { 17 | validator = new TestValidator(); 18 | }); 19 | 20 | describe('directoryExists', () => { 21 | it('should return true if directory exists', () => { 22 | (fs.existsSync as jest.Mock).mockReturnValue(true); 23 | expect(validator.directoryExists('/path/to/dir')).toBe(true); 24 | }); 25 | 26 | it('should return false if directory does not exist', () => { 27 | (fs.existsSync as jest.Mock).mockReturnValue(false); 28 | expect(validator.directoryExists('/path/to/nowhere')).toBe(false); 29 | }); 30 | }); 31 | 32 | describe('isNumber', () => { 33 | it('should return true for valid numbers', () => { 34 | expect(validator.isNumber(123)).toBe(true); 35 | expect(validator.isNumber('456')).toBe(true); 36 | }); 37 | 38 | it('should return false for invalid numbers', () => { 39 | expect(validator.isNumber('')).toBe(false); 40 | expect(validator.isNumber('abc')).toBe(false); 41 | }); 42 | }); 43 | 44 | describe('matchesRegex', () => { 45 | it('should return true if string matches regex', () => { 46 | expect(validator.matchesRegex('hello123', /^[a-z]+\d+$/)).toBe(true); 47 | }); 48 | 49 | it('should return false if string does not match regex', () => { 50 | expect(validator.matchesRegex('hello', /^\d+$/)).toBe(false); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /frontend/src/components/charts/PolarArea.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 67 | -------------------------------------------------------------------------------- /frontend/src/views/QueuePage.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 55 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Redis Alpine as a source for the binary 2 | FROM redis:alpine AS redis 3 | 4 | # Main build stage 5 | FROM node:current-alpine3.20 6 | 7 | RUN apk --update add \ 8 | ffmpeg \ 9 | openssl \ 10 | perl-mojolicious \ 11 | perl-lwp-protocol-https \ 12 | perl-xml-simple \ 13 | perl-xml-libxml \ 14 | su-exec \ 15 | make \ 16 | build-base \ 17 | atomicparsley --repository http://dl-3.alpinelinux.org/alpine/edge/testing/ --allow-untrusted 18 | 19 | # Symlink AtomicParsley 20 | RUN ln -s `which atomicparsley` /usr/local/bin/AtomicParsley 21 | 22 | RUN mkdir -p /data/output /data/config /config /data /node-persist /app/frontend /logs 23 | 24 | WORKDIR /iplayer 25 | 26 | ENV GET_IPLAYER_VERSION=3.35 27 | 28 | RUN wget -qO- https://github.com/get-iplayer/get_iplayer/archive/v${GET_IPLAYER_VERSION}.tar.gz | tar -xvz -C /tmp && \ 29 | mv /tmp/get_iplayer-${GET_IPLAYER_VERSION}/get_iplayer . && \ 30 | rm -rf /tmp/* && \ 31 | chmod +x ./get_iplayer 32 | 33 | ENV GET_IPLAYER_EXEC=/iplayer/get_iplayer 34 | ENV STORAGE_LOCATION=/node-persist 35 | ENV CACHE_LOCATION=/data 36 | 37 | WORKDIR /ytdlp 38 | 39 | RUN apk add --no-cache python3 py3-pip 40 | RUN wget -q https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp 41 | RUN chmod +x ./yt-dlp 42 | 43 | ENV YTDLP_EXEC=/ytdlp/yt-dlp 44 | 45 | # Copy Redis binary from the Redis Alpine image 46 | WORKDIR /redis 47 | COPY --from=redis /usr/local/bin/redis-server /redis/redis-server 48 | RUN chmod +x /redis/redis-server 49 | 50 | # Install iplayarr 51 | WORKDIR /app 52 | 53 | COPY package*.json ./ 54 | COPY frontend/package*.json ./frontend/ 55 | 56 | RUN npm run install:both 57 | COPY . . 58 | RUN npm run build:both 59 | RUN rm -rf /app/src /app/frontend/src 60 | 61 | ENV LOG_DIR=/logs 62 | 63 | ENTRYPOINT [ "./docker_entry.sh" ] 64 | CMD ["npm", "run", "start"] 65 | -------------------------------------------------------------------------------- /frontend/src/components/charts/LineChart.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 80 | -------------------------------------------------------------------------------- /src/types/responses/IPlayerMetadataResponse.ts: -------------------------------------------------------------------------------- 1 | export interface IPlayerMetadataResponse { 2 | programme: IPlayerProgramMetadata; 3 | } 4 | 5 | export interface IPlayerProgramMetadata { 6 | type: 'series' | 'episode' | 'brand'; 7 | pid: string; 8 | parent?: IPlayerMetadataResponse; 9 | categories?: IPlayerCategoryResponse[]; 10 | display_title?: { 11 | title: string; 12 | subtitle?: string; 13 | }; 14 | position?: number | null; 15 | title: string; 16 | ownership?: { 17 | service?: { 18 | title?: string; 19 | }; 20 | }; 21 | medium_synopsis?: string; 22 | versions?: { 23 | canonical: number; 24 | pid: string; 25 | duration: number; 26 | types: string[]; 27 | }[]; 28 | first_broadcast_date?: string | null; 29 | image?: { 30 | pid: string; 31 | }; 32 | expected_child_count?: number | null; 33 | aggregated_episode_count?: number | null; 34 | } 35 | 36 | export interface IPlayerCategoryResponse { 37 | type: string; 38 | id: string; 39 | key: string; 40 | title: string; 41 | narrower?: IPlayerCategoryResponse[] | never[]; 42 | broader: { 43 | category?: IPlayerCategoryResponse; 44 | }; 45 | has_topic_page: boolean; 46 | sameAs?: IPlayerCategoryResponse | null; 47 | } 48 | 49 | export interface IPlayerChildrenResponse { 50 | children: { 51 | page: number; 52 | total: number; 53 | programmes: IPlayerProgramMetadata[]; 54 | }; 55 | } 56 | 57 | export interface IPlayerEpisodesResponse { 58 | programme_episodes: { 59 | elements: IPlayerEpisodeMetadata[]; 60 | } 61 | } 62 | 63 | export interface IPlayerEpisodeMetadata { 64 | id: string; 65 | type: 'episode' | 'brand' | 'series'; 66 | release_date_time?: string 67 | title: string 68 | } -------------------------------------------------------------------------------- /src/endpoints/newznab/CapsEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { Builder } from 'xml2js'; 3 | 4 | import { NewzNabServerConfig } from '../../types/responses/newznab/CapsResponse'; 5 | 6 | export default async (_: Request, res: Response) => { 7 | const serverConfig: NewzNabServerConfig = { 8 | server: { $: { title: 'iPlayarr' } }, 9 | limits: { $: { default: '100', max: '100' } }, 10 | searching: { 11 | search: { $: { available: 'yes', supportedParams: 'q' } }, 12 | 'tv-search': { $: { available: 'yes', supportedParams: 'q,season,ep' } }, 13 | 'movie-search': { $: { available: 'yes', supportedParams: 'q' } }, 14 | 'music-search': { $: { available: 'no', supportedParams: 'q' } }, 15 | 'audio-search': { $: { available: 'no', supportedParams: 'q' } }, 16 | 'book-search': { $: { available: 'no', supportedParams: 'q' } }, 17 | }, 18 | categories: { 19 | category: [ 20 | { $: { id: '0', name: 'Other' } }, 21 | { 22 | $: { id: '2000', name: 'Movies' }, 23 | subcat: [ 24 | { $: { id: '2010', name: 'Movies/Foreign' } }, 25 | { $: { id: '2020', name: 'Movies/Other' } }, 26 | { $: { id: '2030', name: 'Movies/SD' } }, 27 | { $: { id: '2040', name: 'Movies/HD' } }, 28 | { $: { id: '2050', name: 'Movies/BluRay' } }, 29 | { $: { id: '2060', name: 'Movies/3D' } }, 30 | ], 31 | }, 32 | { $: { id: '5000', name: 'TV' }, subcat: [{ $: { id: '5040', name: 'TV/HD' } }] }, 33 | ], 34 | }, 35 | tags: '', 36 | }; 37 | 38 | const builder = new Builder({ headless: true }); 39 | const xml = builder.buildObject({ caps: serverConfig }); 40 | res.set('Content-Type', 'application/xml'); 41 | res.send(xml); 42 | }; 43 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "@fortawesome/fontawesome-svg-core": "^6.7.2", 12 | "@fortawesome/free-brands-svg-icons": "^6.7.2", 13 | "@fortawesome/free-regular-svg-icons": "^6.7.2", 14 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 15 | "@fortawesome/vue-fontawesome": "^3.0.8", 16 | "apexcharts": "^4.7.0", 17 | "core-js": "^3.8.3", 18 | "register-service-worker": "^1.7.2", 19 | "socket.io": "^4.8.1", 20 | "socket.io-client": "^4.8.1", 21 | "uuid": "^11.1.0", 22 | "vue": "^3.2.13", 23 | "vue-final-modal": "^4.5.5", 24 | "vue-router": "^4.5.0", 25 | "vue3-apexcharts": "^1.8.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.12.16", 29 | "@babel/eslint-parser": "^7.12.16", 30 | "@vue/cli-plugin-babel": "~5.0.0", 31 | "@vue/cli-plugin-eslint": "~5.0.0", 32 | "@vue/cli-plugin-pwa": "~5.0.0", 33 | "@vue/cli-service": "~5.0.0", 34 | "eslint": "^7.32.0", 35 | "eslint-config-prettier": "^10.1.2", 36 | "eslint-plugin-vue": "^8.0.3", 37 | "less": "^4.2.2", 38 | "less-loader": "^12.2.0", 39 | "prettier": "^3.5.3" 40 | }, 41 | "eslintConfig": { 42 | "root": true, 43 | "env": { 44 | "node": true 45 | }, 46 | "extends": [ 47 | "plugin:vue/vue3-essential", 48 | "eslint:recommended", 49 | "prettier" 50 | ], 51 | "parserOptions": { 52 | "parser": "@babel/eslint-parser" 53 | }, 54 | "rules": {} 55 | }, 56 | "browserslist": [ 57 | "> 1%", 58 | "last 2 versions", 59 | "not dead", 60 | "not ie 11" 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/components/modals/OffScheduleForm.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 76 | -------------------------------------------------------------------------------- /src/facade/arrFacade.ts: -------------------------------------------------------------------------------- 1 | import { ArrServiceDirectory } from '../constants/ArrServiceDirectory'; 2 | import AbstractArrService, { ArrTag } from '../service/arr/AbstractArrService'; 3 | import { App } from '../types/App'; 4 | import { CreateDownloadClientForm } from '../types/requests/form/CreateDownloadClientForm'; 5 | import { CreateIndexerForm } from '../types/requests/form/CreateIndexerForm'; 6 | import { ArrLookupResponse } from '../types/responses/arr/ArrLookupResponse'; 7 | import { DownloadClientResponse } from '../types/responses/arr/DownloadClientResponse'; 8 | import { IndexerResponse } from '../types/responses/arr/IndexerResponse'; 9 | 10 | class ArrFacade { 11 | upsertDownloadClient(form: CreateDownloadClientForm, app: App, allowCreate: boolean): Promise { 12 | return this.getService(app).upsertDownloadClient(form, app, allowCreate); 13 | } 14 | getDownloadClient(app: App): Promise { 15 | return this.getService(app).getDownloadClient(app); 16 | } 17 | 18 | upsertIndexer(form: CreateIndexerForm, app: App, allowCreate: boolean): Promise { 19 | return this.getService(app).upsertIndexer(form, app, allowCreate); 20 | } 21 | getIndexer(app: App): Promise { 22 | return this.getService(app).getIndexer(app); 23 | } 24 | 25 | testConnection(app: App): Promise { 26 | return this.getService(app).testConnection(app); 27 | } 28 | 29 | getTags(app: App): Promise { 30 | return this.getService(app).getTags(app); 31 | } 32 | createTag(app: App, label: string): Promise { 33 | return this.getService(app).createTag(app, label); 34 | } 35 | search(app: App, term?: string): Promise { 36 | return this.getService(app).search(app, term); 37 | } 38 | 39 | getService(app: App): AbstractArrService { 40 | return ArrServiceDirectory[app.type] as AbstractArrService; 41 | } 42 | } 43 | 44 | export default new ArrFacade(); -------------------------------------------------------------------------------- /src/facade/nzbFacade.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | import { v4 } from 'uuid'; 3 | 4 | import historyService from '../service/historyService'; 5 | import loggingService from '../service/loggingService'; 6 | import NZBGetService from '../service/nzb/NZBGetService'; 7 | import SabNZBDService from '../service/nzb/SabNZBDService'; 8 | import { App } from '../types/App'; 9 | import { VideoType } from '../types/IPlayerSearchResult'; 10 | import { QueueEntry } from '../types/QueueEntry'; 11 | import { QueueEntryStatus } from '../types/responses/sabnzbd/QueueResponse'; 12 | 13 | class NZBFacade { 14 | async testConnection( 15 | type: string, 16 | url: string, 17 | apiKey?: string, 18 | username?: string, 19 | password?: string 20 | ): Promise { 21 | const service = this.#getService(type); 22 | return service.testConnection(url, { username, password, apiKey }); 23 | } 24 | 25 | #getService(type: string) { 26 | switch (type) { 27 | case 'sabnzbd': 28 | default: 29 | return SabNZBDService; 30 | case 'nzbget': 31 | return NZBGetService; 32 | } 33 | } 34 | 35 | async addFile(app: App, files: Express.Multer.File[], nzbName?: string): Promise { 36 | loggingService.log(`Received Real NZB, trying to add ${nzbName} to ${app.name}`); 37 | this.createRelayEntry(app, nzbName); 38 | const service = this.#getService(app.type.toString().toLowerCase()); 39 | return service.addFile(app, files); 40 | } 41 | 42 | createRelayEntry({ id: appId }: App, nzbName?: string): void { 43 | const relayEntry: QueueEntry = { 44 | pid: v4(), 45 | status: QueueEntryStatus.FORWARDED, 46 | nzbName: nzbName || 'Unknown', 47 | type: VideoType.UNKNOWN, 48 | appId, 49 | details: { 50 | start: new Date(), 51 | }, 52 | }; 53 | historyService.addRelay(relayEntry); 54 | } 55 | } 56 | 57 | export default new NZBFacade(); 58 | -------------------------------------------------------------------------------- /frontend/src/assets/styles/variables.less: -------------------------------------------------------------------------------- 1 | /* Breakpoints */ 2 | @mobile-breakpoint: 768px; 3 | 4 | /* Colours */ 5 | @brand-color: rgb(225, 31, 119); 6 | @error-color: #f05050; 7 | @warn-color: #ffa500; 8 | @success-color: #00853d; 9 | @primary-color: #5d9cec; 10 | @complete-color: rgb(122, 67, 182); 11 | 12 | @primary-text-color: rgb(229, 229, 229); 13 | @nav-text-color: rgb(225, 226, 227); 14 | @table-text-color: rgb(204, 204, 204); 15 | @subtle-text-color: #737d83; 16 | @toolbar-text-color: rgb(153, 153, 153); 17 | 18 | @warn-text-color: black; 19 | @error-text-color: white; 20 | @primary-text-color: white; 21 | 22 | @page-background-color: rgb(32, 32, 32); 23 | @nav-background-color: rgb(42, 42, 42); 24 | @nav-active-background-color: rgb(51, 51, 51); 25 | @toolbar-background-color: rgb(38, 38, 38); 26 | @grey-pill-background-color: rgb(89, 89, 89); 27 | 28 | @nav-border-color: @primary-text-color; 29 | @nav-link-color: rgb(225, 226, 227); 30 | 31 | @login-button-background-color: @primary-color; 32 | @login-button-border-color: #5899eb; 33 | @login-panel-header-color: #494949; 34 | @login-panel-body-color: #111; 35 | 36 | @logs-background-color: #000; 37 | 38 | @settings-button-background-color: #333; 39 | @settings-button-border-color: #393f45; 40 | @settings-button-hover-background-color: #444; 41 | @settings-button-hover-border-color: #5a6265; 42 | 43 | @primary-box-shadow: rgba(0, 0, 0, 0.075); 44 | 45 | @input-background-color: #333; 46 | @input-border-color: #dde6e9; 47 | @input-text-color: #ccc; 48 | 49 | @table-row-hover-color: rgba(255, 255, 255, 0.08); 50 | @table-border-color: #858585; 51 | 52 | // Alerts 53 | 54 | @alertDangerBorderColor: #a94442; 55 | @alertDangerBackgroundColor: rgba(255, 0, 0, 0.1); 56 | @alertDangerColor: #ccc; 57 | 58 | @alertInfoBorderColor: #31708f; 59 | @alertInfoBackgroundColor: rgba(0, 0, 255, 0.1); 60 | @alertInfoColor: #ccc; 61 | 62 | @alertSuccessBorderColor: #3c763d; 63 | @alertSuccessBackgroundColor: rgba(0, 255, 0, 0.1); 64 | @alertSuccessColor: #ccc; 65 | 66 | @alertWarningBorderColor: #8a6d3b; 67 | @alertWarningBackgroundColor: rgba(255, 255, 0, 0.1); 68 | @alertWarningColor: #ccc; 69 | -------------------------------------------------------------------------------- /frontend/src/components/log/LogPanel.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 49 | 50 | 84 | -------------------------------------------------------------------------------- /src/types/responses/sabnzbd/HistoryResponse.ts: -------------------------------------------------------------------------------- 1 | export enum HistoryStatus { 2 | COMPLETED = 'Completed', 3 | } 4 | 5 | export const historySkeleton: Partial = { 6 | noofslots: 220, 7 | ppslots: 1, 8 | day_size: '1.9 G', 9 | week_size: '30.4 G', 10 | month_size: '167.3 G', 11 | total_size: '678.1 G', 12 | last_history_update: 1469210913, 13 | }; 14 | 15 | export const historyEntrySkeleton: Partial = { 16 | action_line: '', 17 | meta: null, 18 | fail_message: '', 19 | loaded: false, 20 | category: 'iplayer', 21 | pp: 'D', 22 | retry: 0, 23 | script: 'None', 24 | download_time: 64, 25 | has_rating: false, 26 | status: HistoryStatus.COMPLETED, 27 | script_line: '', 28 | report: '', 29 | password: '', 30 | postproc_time: 40, 31 | md5sum: 'd2c16aeecbc1b1921d04422850e93013', 32 | archive: false, 33 | url_info: '', 34 | stage_log: [], 35 | }; 36 | 37 | export interface SabNZBDHistoryResponse { 38 | noofslots: number; 39 | ppslots: number; 40 | day_size: string; 41 | week_size: string; 42 | month_size: string; 43 | total_size: string; 44 | last_history_update: number; 45 | slots: SABNZBDHistoryEntryResponse[]; 46 | } 47 | 48 | export interface SABNZBDHistoryEntryResponse { 49 | action_line: string; 50 | duplicate_key: string; 51 | meta: null; 52 | fail_message: string; 53 | loaded: boolean; 54 | size: string; 55 | category: string; 56 | pp: string; 57 | retry: number; 58 | script: string; 59 | nzb_name: string; 60 | download_time: number; 61 | storage: string; 62 | has_rating: boolean; 63 | status: HistoryStatus; 64 | script_line: string; 65 | completed: number; 66 | nzo_id: string; 67 | downloaded: number; 68 | report: string; 69 | password: string; 70 | path: string; 71 | postproc_time: number; 72 | name: string; 73 | url: string; 74 | md5sum: string; 75 | archive: boolean; 76 | bytes: number; 77 | url_info: string; 78 | stage_log: string[]; 79 | } 80 | -------------------------------------------------------------------------------- /tests/types/FixedFIFOQueue.test.ts: -------------------------------------------------------------------------------- 1 | import { FixedFIFOQueue } from '../../src/types/utils/FixedFIFOQueue'; 2 | 3 | describe('FixedFIFOQueue', () => { 4 | it('should enqueue and dequeue in FIFO order', async () => { 5 | const queue = new FixedFIFOQueue(3); 6 | await queue.enqueue(1); 7 | await queue.enqueue(2); 8 | await queue.enqueue(3); 9 | 10 | expect(await queue.dequeue()).toBe(1); 11 | expect(await queue.dequeue()).toBe(2); 12 | expect(await queue.dequeue()).toBe(3); 13 | expect(await queue.dequeue()).toBeUndefined(); 14 | }); 15 | 16 | it('should not exceed max size', async () => { 17 | const queue = new FixedFIFOQueue(2); 18 | await queue.enqueue(1); 19 | await queue.enqueue(2); 20 | await queue.enqueue(3); // should remove 1 21 | 22 | const items = await queue.getItems(); 23 | expect(items).toEqual([2, 3]); 24 | expect(await queue.size()).toBe(2); 25 | }); 26 | 27 | it('peek should return the first item without removing it', async () => { 28 | const queue = new FixedFIFOQueue(2); 29 | await queue.enqueue('a'); 30 | expect(await queue.peek()).toBe('a'); 31 | expect(await queue.size()).toBe(1); 32 | }); 33 | 34 | it('isEmpty should return true when queue is empty', async () => { 35 | const queue = new FixedFIFOQueue(1); 36 | expect(await queue.isEmpty()).toBe(true); 37 | await queue.enqueue(true); 38 | expect(await queue.isEmpty()).toBe(false); 39 | await queue.dequeue(); 40 | expect(await queue.isEmpty()).toBe(true); 41 | }); 42 | 43 | it('getItems should return a copy of the queue', async () => { 44 | const queue = new FixedFIFOQueue(3); 45 | await queue.enqueue(1); 46 | await queue.enqueue(2); 47 | 48 | const items = await queue.getItems(); 49 | expect(items).toEqual([1, 2]); 50 | 51 | items.push(3); 52 | expect(await queue.getItems()).toEqual([1, 2]); // internal state shouldn't be affected 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/service/schedule/GetIplayerScheduleService.test.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | 3 | import downloadFacade from '../../../src/facade/downloadFacade'; 4 | import getIplayerExecutableService from '../../../src/service/getIplayerExecutableService'; 5 | import GetIplayerShceduleService from '../../../src/service/schedule/GetIplayerShceduleService'; 6 | import GetIplayerSearchService from '../../../src/service/search/GetIplayerSearchService'; 7 | 8 | 9 | jest.mock('../../../src/service/getIplayerExecutableService'); 10 | jest.mock('../../../src/service/search/GetIplayerSearchService'); 11 | 12 | jest.mock('child_process', () => ({ 13 | spawn: jest.fn(), 14 | })); 15 | 16 | describe('GetIplayerScheduleService', () => { 17 | describe('getFeed', () => { 18 | it('Should call GetIplayerSearchService.search and return results', async () => { 19 | const test_results = [{ 20 | pid: '12345', 21 | title: 'Test Title' 22 | }]; 23 | (GetIplayerSearchService.search as jest.Mock).mockResolvedValue(test_results); 24 | 25 | const results = await GetIplayerShceduleService.getFeed(); 26 | 27 | expect(GetIplayerSearchService.search).toHaveBeenCalledWith('*'); 28 | expect(results).toEqual(test_results); 29 | }); 30 | }); 31 | 32 | describe('refreshCache', () => { 33 | it('spawns a cache refresh process and logs output', async () => { 34 | const on = jest.fn(); 35 | const child = { stdout: { on }, stderr: { on } }; 36 | (getIplayerExecutableService.getIPlayerExec as jest.Mock).mockResolvedValue({ 37 | exec: 'get_iplayer', 38 | args: ['--type=tv'], 39 | }); 40 | (spawn as jest.Mock).mockReturnValue(child as any); 41 | 42 | const cleanupSpy = jest.spyOn(downloadFacade, 'cleanupFailedDownloads').mockResolvedValue(); 43 | 44 | await GetIplayerShceduleService.refreshCache(); 45 | 46 | expect(spawn).toHaveBeenCalledWith('get_iplayer', ['--type=tv', '--cache-rebuild'], { shell: true }); 47 | expect(cleanupSpy).toHaveBeenCalled(); 48 | }); 49 | }); 50 | }); -------------------------------------------------------------------------------- /frontend/src/lib/dialogService.js: -------------------------------------------------------------------------------- 1 | import { useModal } from 'vue-final-modal'; 2 | 3 | import AlertDialog from '@/components/modals/AlertDialog.vue'; 4 | 5 | const dialogService = { 6 | alert: (title, text, subtext) => { 7 | const formModal = useModal({ 8 | component: AlertDialog, 9 | attrs: { 10 | title, 11 | text, 12 | subtext, 13 | onConfirm: () => { 14 | formModal.close(); 15 | }, 16 | }, 17 | }); 18 | formModal.open(); 19 | }, 20 | 21 | confirm: async (title, text, subtext) => { 22 | return new Promise((resolve) => { 23 | const formModal = useModal({ 24 | component: AlertDialog, 25 | attrs: { 26 | title, 27 | text, 28 | subtext, 29 | showCancel: true, 30 | onConfirm: () => { 31 | resolve(true); 32 | formModal.close(); 33 | }, 34 | onCancel: () => { 35 | resolve(false); 36 | formModal.close(); 37 | }, 38 | }, 39 | }); 40 | formModal.open(); 41 | }); 42 | }, 43 | 44 | select: async (title, text, subtext, options) => { 45 | return new Promise((resolve) => { 46 | const formModal = useModal({ 47 | component: AlertDialog, 48 | attrs: { 49 | title, 50 | text, 51 | subtext, 52 | showCancel: true, 53 | options, 54 | onConfirm: (option) => { 55 | resolve(option); 56 | formModal.close(); 57 | }, 58 | onCancel: () => { 59 | resolve(false); 60 | formModal.close(); 61 | }, 62 | }, 63 | }); 64 | formModal.open(); 65 | }); 66 | }, 67 | }; 68 | 69 | export default dialogService; 70 | -------------------------------------------------------------------------------- /src/service/synonymService.ts: -------------------------------------------------------------------------------- 1 | import { v4 } from 'uuid'; 2 | 3 | import searchFacade from '../facade/searchFacade'; 4 | import { QueuedStorage } from '../types/QueuedStorage'; 5 | import { Synonym } from '../types/Synonym'; 6 | 7 | const storage: QueuedStorage = new QueuedStorage(); 8 | 9 | const synonymService = { 10 | getSynonym: async (inputTerm: string): Promise => { 11 | const allSynonyms = await synonymService.getAllSynonyms(); 12 | return allSynonyms.find( 13 | ({ from: savedFrom, target: savedTarget }) => 14 | savedFrom.toLocaleLowerCase() == inputTerm.toLocaleLowerCase() || 15 | savedTarget.toLocaleLowerCase() == inputTerm.toLocaleLowerCase() 16 | ); 17 | }, 18 | 19 | getAllSynonyms: async (): Promise => { 20 | return (await storage.getItem('synonyms')) || []; 21 | }, 22 | 23 | addSynonym: async (synonym: Synonym): Promise => { 24 | if (!synonym.id) { 25 | const id = v4(); 26 | synonym.id = id; 27 | } 28 | const allSynonyms = await synonymService.getAllSynonyms(); 29 | allSynonyms.push(synonym); 30 | await storage.setItem('synonyms', allSynonyms); 31 | searchFacade.removeFromSearchCache(synonym.target); 32 | }, 33 | 34 | updateSynonym: async (synonym: Synonym): Promise => { 35 | await synonymService.removeSynonym(synonym.id); 36 | const allSynonyms = await synonymService.getAllSynonyms(); 37 | allSynonyms.push(synonym); 38 | await storage.setItem('synonyms', allSynonyms); 39 | searchFacade.removeFromSearchCache(synonym.target); 40 | }, 41 | 42 | removeSynonym: async (id: string): Promise => { 43 | let allSynonyms = await synonymService.getAllSynonyms(); 44 | const foundSynonym: Synonym | undefined = allSynonyms.find(({ id: savedId }) => savedId == id); 45 | if (foundSynonym) { 46 | allSynonyms = allSynonyms.filter(({ id: savedId }) => savedId != id); 47 | await storage.setItem('synonyms', allSynonyms); 48 | searchFacade.removeFromSearchCache(foundSynonym.target); 49 | } 50 | }, 51 | }; 52 | 53 | export default synonymService; 54 | -------------------------------------------------------------------------------- /src/service/skyhook/SkyhookService.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import RedisCacheService from '../redis/redisCacheService'; 4 | 5 | class SkyhookService { 6 | skyhookSeriesCache: RedisCacheService<{ tvdbId: string }[]> 7 | skyhookEpisodeCache: RedisCacheService<{ episodes: any[] } | undefined> 8 | 9 | constructor() { 10 | this.skyhookSeriesCache = new RedisCacheService('skyhook_series_cache', 2700); 11 | this.skyhookEpisodeCache = new RedisCacheService('skyhook_episode_cache', 86400); 12 | } 13 | 14 | async lookupSeriesDetails(seriesName: string, episodeTitle: string): Promise<{ series?: number, episode?: number } | undefined> { 15 | const searchResults = await this.searchSeries(seriesName); 16 | for (const result of searchResults) { 17 | const episode = await this.findEpisode(parseInt(result.tvdbId), episodeTitle); 18 | if (episode) { 19 | return { series: episode.seasonNumber, episode: episode.episodeNumber }; 20 | } 21 | } 22 | return; 23 | } 24 | 25 | async searchSeries(seriesName: string): Promise<{ tvdbId: string }[]> { 26 | const cached = await this.skyhookSeriesCache.get(seriesName); 27 | if (cached) { 28 | return cached; 29 | } 30 | const url = `https://skyhook.sonarr.tv/v1/tvdb/search/en?term=${seriesName}`; 31 | const { data } = await axios.get(url); 32 | if (data && Array.isArray(data)) { 33 | await this.skyhookSeriesCache.set(seriesName, data); 34 | } 35 | return data; 36 | } 37 | 38 | async findEpisode(tvdbId: number, episodeName: string): Promise<{ title: string, seasonNumber: number, episodeNumber: number } | undefined> { 39 | const url = `https://skyhook.sonarr.tv/v1/tvdb/shows/en/${tvdbId}`; 40 | let data = await this.skyhookEpisodeCache.get(String(tvdbId)); 41 | if (!data) { 42 | data = (await axios.get(url)).data; 43 | await this.skyhookEpisodeCache.set(String(tvdbId), data); 44 | } 45 | const episode = data?.episodes.find((ep: any) => ep.title?.toLowerCase() === episodeName?.toLowerCase()); 46 | return episode; 47 | } 48 | } 49 | 50 | export default new SkyhookService(); -------------------------------------------------------------------------------- /src/routes/json-api/SettingsRoute.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | 3 | import { version } from '../../config/version.json'; 4 | import configService, { ConfigMap } from '../../service/configService'; 5 | import { IplayarrParameter } from '../../types/IplayarrParameters'; 6 | import { qualityProfiles } from '../../types/QualityProfiles'; 7 | import { ApiError, ApiResponse } from '../../types/responses/ApiResponse'; 8 | import { md5 } from '../../utils/Utils'; 9 | import { ConfigFormValidator } from '../../validators/ConfigFormValidator'; 10 | import { Validator } from '../../validators/Validator'; 11 | 12 | const router = Router(); 13 | 14 | router.get('/hiddenSettings', (_, res: Response) => { 15 | res.json({ 16 | HIDE_DONATE: Boolean(process.env.HIDE_DONATE) || false, 17 | VERSION: version, 18 | }); 19 | }); 20 | 21 | router.get('/', async (_, res: Response) => { 22 | const configMap: ConfigMap = await configService.getAllConfig(); 23 | res.json(configMap); 24 | }); 25 | 26 | router.put('/', async (req: Request, res: Response) => { 27 | const validator: Validator = new ConfigFormValidator(); 28 | const validationResult: { [key: string]: string } = await validator.validate(req.body); 29 | if (Object.keys(validationResult).length > 0) { 30 | const apiResponse: ApiResponse = { 31 | error: ApiError.INVALID_INPUT, 32 | invalid_fields: validationResult, 33 | }; 34 | res.status(400).json(apiResponse); 35 | return; 36 | } 37 | for (const key of Object.keys(req.body)) { 38 | const val = req.body[key]; 39 | if (key == IplayarrParameter.AUTH_PASSWORD) { 40 | const existing = await configService.getParameter(IplayarrParameter.AUTH_PASSWORD); 41 | const hashed = md5(val); 42 | if (existing != hashed && existing != val) { 43 | await configService.setParameter(key as IplayarrParameter, hashed); 44 | } 45 | } else { 46 | await configService.setParameter(key as IplayarrParameter, val); 47 | } 48 | } 49 | res.json(req.body); 50 | }); 51 | 52 | router.get('/qualityProfiles', (_, res: Response) => { 53 | res.json(qualityProfiles); 54 | }); 55 | 56 | export default router; 57 | -------------------------------------------------------------------------------- /frontend/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export const getHost = () => { 2 | return process.env.NODE_ENV != 'production' ? `http://${window.location.hostname}:4404` : ''; 3 | }; 4 | 5 | export const getPidFromBBCUrl = (url) => { 6 | if (!url) { 7 | return undefined; 8 | } 9 | const match = url.match(/https:\/\/www\.bbc\.co\.uk\/iplayer\/[^?]+\/(m[0-9a-z]{7})\/[^?]+/); 10 | return match ? match[1] : undefined; 11 | } 12 | 13 | export const formatStorageSize = (mb) => { 14 | if (mb) { 15 | if (mb >= 1024) { 16 | return (mb / 1024).toFixed(2) + ' GB'; 17 | } 18 | return mb.toFixed(2) + ' MB'; 19 | } 20 | return; 21 | }; 22 | 23 | export const enforceMaxLength = (arr, maxLength) => { 24 | if (arr.length > maxLength) { 25 | arr.splice(0, arr.length - maxLength); 26 | } 27 | }; 28 | 29 | export function capitalize(word) { 30 | return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); 31 | } 32 | 33 | export function deepCopy(input) { 34 | return input ? JSON.parse(JSON.stringify(input)) : undefined; 35 | } 36 | 37 | export function getCleanSceneTitle(title) { 38 | if (!title || title.trim().length === 0) { 39 | return ''; 40 | } 41 | 42 | const beginningThe = /^The\s/i; 43 | const specialCharacter = /[`'.]/g; 44 | const nonWord = /\W/g; 45 | 46 | let cleanTitle = title.replace(beginningThe, ''); 47 | cleanTitle = cleanTitle.replaceAll('&', 'and'); 48 | cleanTitle = cleanTitle.replace(specialCharacter, ''); 49 | cleanTitle = cleanTitle.replace(nonWord, '+'); 50 | 51 | // Remove any repeating +s 52 | cleanTitle = cleanTitle.replace(/\+{2,}/g, '+'); 53 | 54 | cleanTitle = cleanTitle.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); 55 | cleanTitle = cleanTitle.replace(/^\++|\++$/, ''); 56 | return cleanTitle.trim().replaceAll('+', ' '); 57 | } 58 | 59 | export function formatDate(dateString, dateStyle, timeStyle) { 60 | const date = dateString != null ? new Date(dateString) : undefined; 61 | return isNaN(date?.getTime()) 62 | ? undefined 63 | : new Intl.DateTimeFormat('en-GB', { 64 | dateStyle: dateStyle ?? 'medium', 65 | timeStyle: timeStyle ?? 'short', 66 | hour12: true, 67 | }).format(date); 68 | } 69 | -------------------------------------------------------------------------------- /src/service/stats/StatisticsService.ts: -------------------------------------------------------------------------------- 1 | import { GrabHistoryEntry } from '../../types/data/GrabHistoryEntry'; 2 | import { SearchHistoryEntry } from '../../types/data/SearchHistoryEntry'; 3 | import { AbstractFIFOQueue } from '../../types/utils/AbstractFIFOQueue'; 4 | import { RedisFIFOQueue } from '../../types/utils/RedisFIFOQueue'; 5 | import RedisCacheService from '../redis/redisCacheService'; 6 | import { redis } from '../redis/redisService'; 7 | 8 | class StatisticsService { 9 | searchHistory: AbstractFIFOQueue; 10 | grabHistory: AbstractFIFOQueue; 11 | 12 | constructor() { 13 | this.searchHistory = new RedisFIFOQueue('search-history', 500); 14 | this.grabHistory = new RedisFIFOQueue('grab-history', 500); 15 | } 16 | 17 | addSearch(entry: SearchHistoryEntry): void { 18 | this.searchHistory.enqueue(entry); 19 | } 20 | 21 | async getSearchHistory(): Promise { 22 | return await this.searchHistory.getItems(); 23 | } 24 | 25 | async clearSearchHistory(): Promise { 26 | await this.searchHistory.clear(); 27 | } 28 | 29 | addGrab(entry: GrabHistoryEntry): void { 30 | this.grabHistory.enqueue(entry); 31 | } 32 | 33 | async getGrabHistory(): Promise { 34 | return await this.grabHistory.getItems(); 35 | } 36 | 37 | async clearGrabHistory(): Promise { 38 | await this.grabHistory.clear(); 39 | } 40 | 41 | async setUptime(): Promise { 42 | await redis.set('iplayarr_uptime', Date.now()); 43 | } 44 | 45 | async getUptime(): Promise { 46 | const uptime = await redis.get('iplayarr_uptime'); 47 | if (uptime) { 48 | return Date.now() - parseInt(uptime); 49 | } else { 50 | return 0; 51 | } 52 | } 53 | 54 | async getCacheSizes(): Promise<{ [key: string]: string }> { 55 | const [search_size, schedule_size] = await Promise.all([RedisCacheService.getCacheSizeInMB(['search_cache_*']), RedisCacheService.getCacheSizeInMB(['schedule_cache_*'])]); 56 | 57 | return { 58 | search: search_size, 59 | schedule: schedule_size 60 | } 61 | } 62 | } 63 | 64 | export default new StatisticsService(); -------------------------------------------------------------------------------- /frontend/src/components/apps/AppTestButton.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 43 | 44 | 81 | -------------------------------------------------------------------------------- /frontend/src/components/common/form/CheckInput.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 43 | 44 | 86 | -------------------------------------------------------------------------------- /frontend/public/img/sonarr.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 11 | 12 | 13 | 15 | 17 | 20 | 22 | 23 | 27 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /frontend/src/router/router.js: -------------------------------------------------------------------------------- 1 | import { inject } from 'vue'; 2 | import { createRouter, createWebHistory } from 'vue-router'; 3 | 4 | import { getHost } from '@/lib/utils'; 5 | import AboutPage from '@/views/AboutPage.vue'; 6 | import AppsPage from '@/views/AppsPage.vue'; 7 | import DownloadPage from '@/views/DownloadPage.vue'; 8 | import LoginPage from '@/views/LoginPage.vue'; 9 | import LogsPage from '@/views/LogsPage.vue'; 10 | import OffSchedulePage from '@/views/OffSchedulePage.vue'; 11 | import QueueInfoPage from '@/views/QueueInfoPage.vue'; 12 | import QueuePage from '@/views/QueuePage.vue'; 13 | import SearchPage from '@/views/SearchPage.vue'; 14 | import SettingsPage from '@/views/SettingsPage.vue'; 15 | import StatisticsPage from '@/views/StatisticsPage.vue'; 16 | import SynonymsPage from '@/views/SynonymsPage.vue'; 17 | 18 | const routes = [ 19 | { path: '/', redirect: '/queue' }, 20 | { path: '/queue', component: QueuePage }, 21 | { path: '/info', component: QueueInfoPage, name: 'queueInfo' }, 22 | { path: '/logs', component: LogsPage }, 23 | { path: '/about', component: AboutPage }, 24 | { path: '/settings', component: SettingsPage }, 25 | { path: '/synonyms', component: SynonymsPage }, 26 | { path: '/login', component: LoginPage }, 27 | { path: '/search', component: SearchPage, name: 'search' }, 28 | { path: '/download', component: DownloadPage, name: 'download' }, 29 | { path: '/offSchedule', component: OffSchedulePage }, 30 | { path: '/apps', component: AppsPage }, 31 | { path: '/stats', component: StatisticsPage }, 32 | ]; 33 | 34 | const router = createRouter({ 35 | history: createWebHistory(), 36 | routes, 37 | scrollBehavior() { 38 | return { top: 0, behaviour: 'smooth' }; 39 | }, 40 | }); 41 | 42 | router.beforeEach(async (to, _, next) => { 43 | if (to.path == '/login') { 44 | return next(); 45 | } 46 | const authState = inject('authState'); // Inject global state 47 | 48 | try { 49 | const res = await fetch(`${getHost()}/auth/me`, { credentials: 'include' }); 50 | if (res.ok) { 51 | authState.user = await res.json(); // Store user data globally 52 | next(); 53 | } else { 54 | authState.user = null; 55 | next('/login'); 56 | } 57 | } catch { 58 | authState.user = null; 59 | next('/login'); 60 | } 61 | }); 62 | 63 | export default router; 64 | -------------------------------------------------------------------------------- /src/routes/json-api/SynonymsRoute.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | 3 | import arrFacade from '../../facade/arrFacade'; 4 | import appService from '../../service/appService'; 5 | import synonymService from '../../service/synonymService'; 6 | import { App } from '../../types/App'; 7 | import { ApiError, ApiResponse } from '../../types/responses/ApiResponse'; 8 | import { ArrLookupResponse } from '../../types/responses/arr/ArrLookupResponse'; 9 | import { Synonym } from '../../types/Synonym'; 10 | 11 | const router = Router(); 12 | 13 | router.get('/', async (_, res: Response) => { 14 | const synonyms = await synonymService.getAllSynonyms(); 15 | res.json(synonyms); 16 | }); 17 | 18 | router.post('/', async (req: Request, res: Response) => { 19 | const synonym: Synonym = req.body as any as Synonym; 20 | await synonymService.addSynonym(synonym); 21 | const synonyms = await synonymService.getAllSynonyms(); 22 | res.json(synonyms); 23 | }); 24 | 25 | router.put('/', async (req: Request, res: Response) => { 26 | const synonym: Synonym = req.body as any as Synonym; 27 | await synonymService.updateSynonym(synonym); 28 | const synonyms = await synonymService.getAllSynonyms(); 29 | res.json(synonyms); 30 | }); 31 | 32 | router.delete('/', async (req: Request, res: Response) => { 33 | const { id } = req.body; 34 | await synonymService.removeSynonym(id); 35 | const synonyms = await synonymService.getAllSynonyms(); 36 | res.json(synonyms); 37 | }); 38 | 39 | router.get('/lookup/:appId', async (req: Request, res: Response) => { 40 | const { appId } = req.params as { appId: string }; 41 | const { term } = req.query as { term?: string }; 42 | 43 | const app: App | undefined = await appService.getApp(appId); 44 | if (app) { 45 | try { 46 | const results: ArrLookupResponse[] = await arrFacade.search(app, term); 47 | res.json(results); 48 | return; 49 | } catch (err: any) { 50 | const apiResponse: ApiResponse = { 51 | error: ApiError.INTERNAL_ERROR, 52 | message: err?.message, 53 | }; 54 | res.status(400).json(apiResponse); 55 | return; 56 | } 57 | } 58 | const apiResponse: ApiResponse = { 59 | error: ApiError.INTERNAL_ERROR, 60 | message: `App ${appId} not found`, 61 | }; 62 | res.status(400).json(apiResponse); 63 | return; 64 | }); 65 | 66 | export default router; 67 | -------------------------------------------------------------------------------- /src/types/responses/sabnzbd/QueueResponse.ts: -------------------------------------------------------------------------------- 1 | import { formatBytes } from '../../../utils/formatters'; 2 | 3 | export enum QueueStatus { 4 | DOWNLOADING = 'Downloading', 5 | IDLE = 'Idle', 6 | } 7 | 8 | export enum QueueEntryStatus { 9 | DOWNLOADING = 'Downloading', 10 | QUEUED = 'Queued', 11 | FORWARDED = 'Forwarded', 12 | COMPLETE = 'Complete', 13 | CANCELLED = 'Cancelled', 14 | REMOVED = 'Removed' 15 | } 16 | 17 | export interface SabNZBDQueueResponse { 18 | speedlimit: number; 19 | speedlimit_abs: number; 20 | paused: boolean; 21 | limit: number; 22 | start: number; 23 | have_warnings: number; 24 | pause_int: number; 25 | left_quota: number; 26 | version: string; 27 | cache_art: number; 28 | cache_size: string; 29 | finishaction: null; 30 | paused_all: boolean; 31 | quota: number; 32 | have_quota: boolean; 33 | diskspace1: string; 34 | diskspacetotal1: string; 35 | diskspace1_norm: string; 36 | status: QueueStatus; 37 | noofslots_total: number; 38 | noofslots: number; 39 | finish: number; 40 | slots: SabNZBQueueEntry[]; 41 | } 42 | 43 | export interface SabNZBQueueEntry { 44 | status: QueueEntryStatus; 45 | index: number; 46 | password: string; 47 | avg_age: string; 48 | script: string; 49 | direct_unpack: string; 50 | mb: number; 51 | mbleft: number; 52 | filename: string; 53 | labels: string[]; 54 | priority: string; 55 | cat: string; 56 | timeleft: string; 57 | percentage: number; 58 | nzo_id: string; 59 | unpackopts: number; 60 | } 61 | 62 | export const queueSkeleton: Partial = { 63 | speedlimit: 9, 64 | speedlimit_abs: 4718592.0, 65 | paused: false, 66 | limit: 10, 67 | start: 0, 68 | have_warnings: 0, 69 | pause_int: 0, 70 | left_quota: 0, 71 | version: '3.x.x', 72 | cache_art: 16, 73 | cache_size: '6 MB', 74 | finishaction: null, 75 | paused_all: false, 76 | quota: 0, 77 | have_quota: false, 78 | diskspace1: formatBytes(107374182400, false), 79 | diskspacetotal1: formatBytes(107374182400, false), 80 | diskspace1_norm: formatBytes(107374182400, true), 81 | }; 82 | 83 | export const queueEntrySkeleton: Partial = { 84 | password: '', 85 | avg_age: '0d', 86 | script: 'None', 87 | direct_unpack: '10/30', 88 | labels: [], 89 | priority: 'Normal', 90 | cat: 'iplayer', 91 | unpackopts: 3, 92 | }; 93 | -------------------------------------------------------------------------------- /src/types/QueuedStorage.ts: -------------------------------------------------------------------------------- 1 | import { redis } from '../service/redis/redisService'; 2 | 3 | export class QueuedStorage { 4 | private current: Promise; 5 | 6 | constructor() { 7 | this.current = Promise.resolve(); // Start with a resolved promise 8 | } 9 | 10 | async values(): Promise { 11 | return new Promise((resolve, reject) => { 12 | this.current = this.current.then(async () => { 13 | try { 14 | const keys = await redis.keys('*'); 15 | const values = await redis.mget(keys); 16 | resolve(values.map((value) => (value ? JSON.parse(value) : undefined))); 17 | } catch (error) { 18 | reject(error); 19 | } 20 | }); 21 | }); 22 | } 23 | 24 | async keys(): Promise { 25 | return new Promise((resolve, reject) => { 26 | this.current = this.current.then(() => redis.keys('*').then(resolve, reject)); 27 | }); 28 | } 29 | 30 | async getItem(key: string): Promise { 31 | return new Promise((resolve, reject) => { 32 | this.current = this.current.then(() => 33 | redis.get(key).then((str: string | null) => resolve(str ? JSON.parse(str) : undefined), reject) 34 | ); 35 | }); 36 | } 37 | 38 | async setItem(key: string, value: any): Promise { 39 | return new Promise((resolve, reject) => { 40 | this.current = this.current.then(() => 41 | redis 42 | .set(key, JSON.stringify(value)) 43 | .then(() => { 44 | redis 45 | .save() 46 | .then(() => resolve()) 47 | .catch(reject); 48 | }) 49 | .catch(reject) 50 | ); 51 | }); 52 | } 53 | 54 | async removeItem(key: string): Promise { 55 | return new Promise((resolve, reject) => { 56 | this.current = this.current.then(() => 57 | redis 58 | .del(key) 59 | .then(() => { 60 | redis 61 | .save() 62 | .then(() => resolve()) 63 | .catch(reject); 64 | }) 65 | .catch(reject) 66 | ); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/service/historyService.ts: -------------------------------------------------------------------------------- 1 | import { QueuedStorage } from '../types/QueuedStorage'; 2 | import { QueueEntry } from '../types/QueueEntry'; 3 | import { QueueEntryStatus } from '../types/responses/sabnzbd/QueueResponse'; 4 | import socketService from './socketService'; 5 | const storage: QueuedStorage = new QueuedStorage(); 6 | 7 | const historyService = { 8 | getHistory: async (): Promise => { 9 | return (await storage.getItem('history')) ?? []; 10 | }, 11 | 12 | addHistory: async (item: QueueEntry): Promise => { 13 | const historyItem: QueueEntry = { 14 | ...item, 15 | status: QueueEntryStatus.COMPLETE, 16 | details: { ...item.details, eta: '', speed: 0, progress: 100 }, 17 | process: undefined, 18 | }; 19 | const history: QueueEntry[] = await historyService.getHistory(); 20 | history.push(historyItem); 21 | await storage.setItem('history', history); 22 | socketService.emit('history', history); 23 | }, 24 | 25 | addRelay: async (item: QueueEntry): Promise => { 26 | const history: QueueEntry[] = await historyService.getHistory(); 27 | history.push(item); 28 | await storage.setItem('history', history); 29 | socketService.emit('history', history); 30 | }, 31 | 32 | addArchive: async (item: QueueEntry, status: QueueEntryStatus = QueueEntryStatus.CANCELLED): Promise => { 33 | const historyItem: QueueEntry = { 34 | ...item, 35 | status, 36 | details: { ...item.details, eta: '', speed: 0, progress: 100 }, 37 | process: undefined, 38 | }; 39 | const history: QueueEntry[] = await historyService.getHistory(); 40 | history.push(historyItem); 41 | await storage.setItem('history', history); 42 | socketService.emit('history', history); 43 | }, 44 | 45 | removeHistory: async (pid: string, archive: boolean = false): Promise => { 46 | let history: QueueEntry[] = await historyService.getHistory(); 47 | const historyItem = history.find(({ pid: historyPid }) => historyPid === pid); 48 | history = history.filter(({ pid: historyPid }) => historyPid !== pid); 49 | await storage.setItem('history', history); 50 | socketService.emit('history', history); 51 | if (historyItem && archive) { 52 | historyService.addArchive(historyItem as QueueEntry, QueueEntryStatus.REMOVED); 53 | } 54 | }, 55 | }; 56 | 57 | export default historyService; 58 | -------------------------------------------------------------------------------- /src/service/redis/redisCacheService.ts: -------------------------------------------------------------------------------- 1 | import { redis } from './redisService'; 2 | 3 | export default class RedisCacheService { 4 | prefix: string; 5 | ttl: number; 6 | 7 | constructor(prefix: string, ttl: number) { 8 | this.prefix = prefix; 9 | this.ttl = ttl; 10 | } 11 | 12 | async getOr(key: string, fetchFunction: (key: string) => Promise): Promise { 13 | const cached = await this.get(key); 14 | if (cached) { 15 | return cached; 16 | } else { 17 | const value = await fetchFunction(key); 18 | await this.set(key, value); 19 | return value; 20 | } 21 | } 22 | 23 | get(key: string): Promise { 24 | return new Promise((resolve) => { 25 | redis 26 | .get(`${this.prefix}_${key}`) 27 | .then((data) => { 28 | if (data != null) { 29 | resolve(JSON.parse(data) as T); 30 | } else { 31 | resolve(undefined); 32 | } 33 | }) 34 | .catch(() => resolve(undefined)); 35 | }); 36 | } 37 | 38 | async set(key: string, value: T): Promise { 39 | await redis.set(`${this.prefix}_${key}`, JSON.stringify(value), 'EX', this.ttl); 40 | } 41 | 42 | async del(key: string): Promise { 43 | await redis.del(`${this.prefix}_${key}`); 44 | } 45 | 46 | async clear(): Promise { 47 | const keys = await redis.keys(`${this.prefix}_*`); 48 | if (keys.length) { 49 | await redis.del(keys); 50 | } 51 | } 52 | 53 | static async getCacheSizeInMB(patterns: string[]): Promise { 54 | let totalBytes = 0; 55 | 56 | for (const pattern of patterns) { 57 | let cursor = '0'; 58 | 59 | do { 60 | const [newCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); 61 | cursor = newCursor; 62 | 63 | if (keys.length > 0) { 64 | const sizes = await Promise.all( 65 | keys.map(key => redis.memory('USAGE', key).catch(() => 0)) 66 | ); 67 | totalBytes += sizes.reduce((sum: any, size) => sum + (size || 0), 0); 68 | } 69 | } while (cursor !== '0'); 70 | } 71 | 72 | const totalMB = totalBytes / (1024 * 1024); 73 | return totalMB.toFixed(2); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/endpoints/sabnzbd/DownloadNZBEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { Builder } from 'xml2js'; 3 | 4 | import configService from '../../service/configService'; 5 | import { IplayarrParameter } from '../../types/IplayarrParameters'; 6 | import { VideoType } from '../../types/IPlayerSearchResult'; 7 | import { NZBFileResponse, NZBMetaEntry } from '../../types/responses/newznab/NZBFileResponse'; 8 | 9 | interface DownloadNZBRequest { 10 | pid: string; 11 | nzbName: string; 12 | type: VideoType; 13 | app?: string; 14 | } 15 | 16 | export default async (req: Request, res: Response) => { 17 | const { pid, nzbName, type, app } = req.query as any as DownloadNZBRequest; 18 | 19 | const date: Date = new Date(); 20 | date.setMinutes(date.getMinutes() - 720); 21 | 22 | const outputFormat = await configService.getParameter(IplayarrParameter.OUTPUT_FORMAT); 23 | 24 | const builder: Builder = new Builder({ 25 | headless: true, 26 | renderOpts: { pretty: true }, 27 | }); 28 | 29 | const meta: NZBMetaEntry[] = [ 30 | { 31 | $: { 32 | type: 'nzbName', 33 | _: nzbName, 34 | }, 35 | }, 36 | { 37 | $: { 38 | type: 'type', 39 | _: type, 40 | }, 41 | }, 42 | ]; 43 | 44 | if (app) { 45 | meta.push({ 46 | $: { 47 | type: 'app', 48 | _: app, 49 | }, 50 | }); 51 | } 52 | 53 | const nzbFile: NZBFileResponse = { 54 | $: { 55 | xmlns: 'http://www.newzbin.com/DTD/2003/nzb', 56 | }, 57 | head: { 58 | title: pid, 59 | meta, 60 | }, 61 | file: { 62 | $: { 63 | poster: 'iplayer@bbc.com', 64 | date: date.getTime(), 65 | subject: `${nzbName}.${outputFormat}`, 66 | }, 67 | groups: { 68 | group: ['alt.binaries.example'], 69 | }, 70 | segments: { 71 | segment: [{ _: `${pid}@news.example.com`, $: { bytes: 2147483648, number: 1 } }], 72 | }, 73 | }, 74 | }; 75 | 76 | const xml: string = builder.buildObject({ nzb: nzbFile }); 77 | const finalXml: string = 78 | '\n' + 79 | '\n' + 80 | xml; 81 | 82 | res.set('Content-Type', 'application/x-nzb'); 83 | res.send(finalXml); 84 | }; 85 | -------------------------------------------------------------------------------- /tests/service/socketService.test.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from 'socket.io'; 2 | 3 | import historyService from '../../src/service/historyService'; 4 | import queueService from '../../src/service/queueService'; 5 | import socketService from '../../src/service/socketService'; 6 | 7 | // Mock dependencies 8 | jest.mock('../../src/service/historyService'); 9 | jest.mock('../../src/service/queueService'); 10 | 11 | describe('socketService', () => { 12 | let mockIo: jest.Mocked; 13 | let mockSocket: jest.Mocked; 14 | 15 | beforeEach(() => { 16 | mockIo = { 17 | on: jest.fn(), 18 | emit: jest.fn(), 19 | } as any; 20 | 21 | mockSocket = { 22 | id: 'socket123', 23 | emit: jest.fn(), 24 | on: jest.fn().mockReturnThis(), // Ensure it returns the Socket object 25 | } as any; 26 | 27 | (historyService.getHistory as jest.Mock).mockResolvedValue(['mockedHistory']); 28 | (queueService.getQueue as jest.Mock).mockReturnValue(['mockedQueue']); 29 | }); 30 | 31 | afterEach(() => { 32 | jest.clearAllMocks(); 33 | }); 34 | 35 | describe('registerIo', () => { 36 | it('should register the connection listener', () => { 37 | socketService.registerIo(mockIo); 38 | expect(mockIo.on).toHaveBeenCalledWith('connection', expect.any(Function)); 39 | }); 40 | }); 41 | 42 | describe('registerSocket', () => { 43 | it('should register socket, emit queue/history, and handle disconnect', async () => { 44 | const disconnectCallback = jest.fn(); 45 | mockSocket.on.mockImplementation((event, cb) => { 46 | if (event === 'disconnect') { 47 | disconnectCallback.mockImplementation(cb); 48 | } 49 | return mockSocket; // Return mockSocket to satisfy the method chaining 50 | }); 51 | 52 | await socketService.registerSocket(mockSocket); 53 | 54 | expect(mockSocket.emit).toHaveBeenCalledWith('queue', ['mockedQueue']); 55 | expect(mockSocket.emit).toHaveBeenCalledWith('history', ['mockedHistory']); 56 | expect(mockSocket.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); 57 | 58 | // Simulate disconnect 59 | disconnectCallback(); 60 | }); 61 | }); 62 | 63 | describe('emit', () => { 64 | it('should emit message using io server', () => { 65 | socketService.registerIo(mockIo); 66 | socketService.emit('testEvent', { msg: 'hello' }); 67 | 68 | expect(mockIo.emit).toHaveBeenCalledWith('testEvent', { msg: 'hello' }); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | .node-persist 133 | 134 | test-results 135 | .DS_Store -------------------------------------------------------------------------------- /tests/service/redisService.test.ts: -------------------------------------------------------------------------------- 1 | import type { RedisOptions } from 'ioredis'; 2 | 3 | jest.mock('ioredis', () => { 4 | return jest.fn().mockImplementation((options: RedisOptions) => { 5 | return { 6 | options, 7 | connect: jest.fn(), 8 | }; 9 | }); 10 | }); 11 | 12 | describe('Redis Client Initialization', () => { 13 | beforeEach(() => { 14 | jest.resetModules(); // Clear import cache 15 | 16 | // Reset env variables 17 | delete process.env.REDIS_HOST; 18 | delete process.env.REDIS_PORT; 19 | delete process.env.REDIS_SSL; 20 | delete process.env.REDIS_PASSWORD; 21 | }); 22 | 23 | it('should create Redis client with default options', async () => { 24 | process.env.REDIS_SSL = 'false'; 25 | 26 | const { redis } = await import('../../src/service/redis/redisService'); 27 | const Redis = (await import('ioredis')).default; 28 | 29 | expect(Redis).toHaveBeenCalledWith({ 30 | host: '127.0.0.1', 31 | port: 6379, 32 | tls: undefined, 33 | }); 34 | expect(redis.options).toEqual({ 35 | host: '127.0.0.1', 36 | port: 6379, 37 | tls: undefined, 38 | }); 39 | }); 40 | 41 | it('should use custom host and port from env', async () => { 42 | process.env.REDIS_HOST = 'myhost'; 43 | process.env.REDIS_PORT = '6380'; 44 | process.env.REDIS_SSL = 'false'; 45 | 46 | const { redis } = await import('../../src/service/redis/redisService'); 47 | const Redis = (await import('ioredis')).default; 48 | 49 | expect(Redis).toHaveBeenCalledWith({ 50 | host: 'myhost', 51 | port: 6380, 52 | tls: undefined, 53 | }); 54 | expect(redis.options).toEqual({ 55 | host: 'myhost', 56 | port: 6380, 57 | tls: undefined, 58 | }); 59 | }); 60 | 61 | it('should enable TLS if REDIS_SSL is true', async () => { 62 | process.env.REDIS_SSL = 'true'; 63 | 64 | await import('../../src/service/redis/redisService'); 65 | const Redis = (await import('ioredis')).default; 66 | 67 | expect(Redis).toHaveBeenCalledWith(expect.objectContaining({ tls: {} })); 68 | }); 69 | 70 | it('should set password if REDIS_PASSWORD is provided', async () => { 71 | process.env.REDIS_PASSWORD = 'secret'; 72 | process.env.REDIS_SSL = 'false'; 73 | 74 | await import('../../src/service/redis/redisService'); 75 | const Redis = (await import('ioredis')).default; 76 | 77 | expect(Redis).toHaveBeenCalledWith( 78 | expect.objectContaining({ 79 | password: 'secret', 80 | }) 81 | ); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/routes/json-api/OffScheduleRoute.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, Router } from 'express'; 2 | 3 | import episodeCacheService from '../../service/episodeCacheService'; 4 | import { ApiError, ApiResponse } from '../../types/responses/ApiResponse'; 5 | import { EpisodeCacheDefinition } from '../../types/responses/EpisodeCacheTypes'; 6 | import { OffScheduleFormValidator } from '../../validators/OffScheduleFormValidator'; 7 | import { Validator } from '../../validators/Validator'; 8 | 9 | const router = Router(); 10 | 11 | router.get('/', async (_, res: Response) => { 12 | const cachedSeries: EpisodeCacheDefinition[] = await episodeCacheService.getCachedSeries(); 13 | res.json(cachedSeries); 14 | }); 15 | 16 | router.post('/', async (req: Request, res: Response) => { 17 | const validator: Validator = new OffScheduleFormValidator(); 18 | const validationResult: { [key: string]: string } = await validator.validate(req.body); 19 | if (Object.keys(validationResult).length > 0) { 20 | const apiResponse: ApiResponse = { 21 | error: ApiError.INVALID_INPUT, 22 | invalid_fields: validationResult, 23 | }; 24 | res.status(400).json(apiResponse); 25 | return; 26 | } 27 | 28 | const { name, url } = req.body; 29 | await episodeCacheService.addCachedSeries(url, name); 30 | const cachedSeries: EpisodeCacheDefinition[] = await episodeCacheService.getCachedSeries(); 31 | res.json(cachedSeries); 32 | }); 33 | 34 | router.put('/', async (req: Request, res: Response) => { 35 | const validator: Validator = new OffScheduleFormValidator(); 36 | const validationResult: { [key: string]: string } = await validator.validate(req.body); 37 | if (Object.keys(validationResult).length > 0) { 38 | const apiResponse: ApiResponse = { 39 | error: ApiError.INVALID_INPUT, 40 | invalid_fields: validationResult, 41 | }; 42 | res.status(400).json(apiResponse); 43 | return; 44 | } 45 | 46 | const { name, url, id } = req.body; 47 | await episodeCacheService.updateCachedSeries({ id, url, name, cacheRefreshed: undefined }); 48 | const cachedSeries: EpisodeCacheDefinition[] = await episodeCacheService.getCachedSeries(); 49 | res.json(cachedSeries); 50 | }); 51 | 52 | router.delete('/', async (req: Request, res: Response) => { 53 | const { id } = req.body; 54 | await episodeCacheService.removeCachedSeries(id); 55 | const cachedSeries: EpisodeCacheDefinition[] = await episodeCacheService.getCachedSeries(); 56 | res.json(cachedSeries); 57 | }); 58 | 59 | router.post('/refresh', async (req: Request, res: Response) => { 60 | const def: EpisodeCacheDefinition = req.body; 61 | episodeCacheService.recacheSeries(def); 62 | res.json({ status: true }); 63 | }); 64 | 65 | export default router; 66 | -------------------------------------------------------------------------------- /src/endpoints/sabnzbd/QueueEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { EndpointDirectory } from '../../constants/EndpointDirectory'; 4 | import configService from '../../service/configService'; 5 | import historyService from '../../service/historyService'; 6 | import queueService from '../../service/queueService'; 7 | import { IplayarrParameter } from '../../types/IplayarrParameters'; 8 | import { QueueEntry } from '../../types/QueueEntry'; 9 | import { 10 | queueEntrySkeleton, 11 | QueueEntryStatus, 12 | queueSkeleton, 13 | QueueStatus, 14 | SabNZBDQueueResponse, 15 | SabNZBQueueEntry, 16 | } from '../../types/responses/sabnzbd/QueueResponse'; 17 | import { TrueFalseResponse } from '../../types/responses/sabnzbd/TrueFalseResponse'; 18 | import { AbstractSabNZBDActionEndpoint, ActionQueryString } from './AbstractSabNZBDActionEndpoint'; 19 | 20 | 21 | const actionDirectory: EndpointDirectory = { 22 | delete: async (req: Request, res: Response) => { 23 | const archive = (await configService.getParameter(IplayarrParameter.ARCHIVE_ENABLED)) == 'true'; 24 | const { value } = req.query as ActionQueryString; 25 | if (value) { 26 | queueService.cancelItem(value, archive); 27 | res.json({ status: true } as TrueFalseResponse); 28 | } else { 29 | res.json({ status: false } as TrueFalseResponse); 30 | } 31 | return; 32 | }, 33 | 34 | _default: async (req: Request, res: Response) => { 35 | const queue: QueueEntry[] = queueService.getQueue(); 36 | const downloadQueue: QueueEntry[] = queue.filter(({ status }) => status == QueueEntryStatus.DOWNLOADING); 37 | const iplayerComplete = await historyService.getHistory(); 38 | const queueResponse: SabNZBDQueueResponse = { 39 | ...queueSkeleton, 40 | status: downloadQueue.length > 0 ? QueueStatus.DOWNLOADING : QueueStatus.IDLE, 41 | noofslots_total: queue.length, 42 | noofslots: queue.length, 43 | finish: iplayerComplete.length, 44 | slots: queue.map(convertEntries), 45 | } as SabNZBDQueueResponse; 46 | res.json({ queue: queueResponse }); 47 | }, 48 | }; 49 | 50 | function convertEntries(slot: QueueEntry, index: number): SabNZBQueueEntry { 51 | return { 52 | ...queueEntrySkeleton, 53 | status: slot.status, 54 | index, 55 | mb: slot.details?.size || 0, 56 | mbleft: slot.details?.sizeLeft || 100, 57 | filename: slot.nzbName, 58 | timeleft: slot.details?.eta || '00:00:00', 59 | percentage: slot.details?.progress ? Math.trunc(slot.details.progress) : 0, 60 | nzo_id: slot.pid, 61 | } as SabNZBQueueEntry; 62 | } 63 | 64 | export default new AbstractSabNZBDActionEndpoint(actionDirectory).handler; -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import 'module-alias/register'; 2 | 3 | import cors from 'cors'; 4 | import express, { Express, NextFunction, Request, Response } from 'express'; 5 | import http, { Server } from 'http'; 6 | import path from 'path'; 7 | import { Server as SocketIOServer } from 'socket.io'; 8 | 9 | import ApiRoute from './routes/ApiRoute'; 10 | import AuthRoute, { addAuthMiddleware } from './routes/AuthRoute'; 11 | import JsonApiRoute from './routes/JsonApiRoute'; 12 | import loggingService from './service/loggingService'; 13 | import { redis } from './service/redis/redisService'; 14 | import socketService from './service/socketService'; 15 | import StatisticsService from './service/stats/StatisticsService'; 16 | import taskService from './service/taskService'; 17 | import { IplayarrParameter } from './types/IplayarrParameters'; 18 | 19 | const isDebug = process.env.DEBUG == 'true'; 20 | 21 | const app: Express = express(); 22 | const port: number = parseInt(process.env[IplayarrParameter.PORT.toString()] || '4404'); 23 | 24 | if (isDebug) { 25 | app.use( 26 | cors({ 27 | origin: (origin, callback) => { 28 | callback(null, origin); 29 | }, 30 | credentials: true, 31 | }) 32 | ); 33 | } 34 | 35 | // Session and Auth 36 | app.use(express.urlencoded({ extended: true })); 37 | app.use(express.json()); 38 | addAuthMiddleware(app); 39 | app.use('/auth', AuthRoute); 40 | 41 | // Healthcheck endpoint (unauthenticated) 42 | app.get('/ping', async (_req: Request, res: Response) => { 43 | try { 44 | await redis.ping(); 45 | res.json({ status: 'OK' }); 46 | } catch (error : any) { 47 | res.status(503).json({ status: 'ERROR', message: `Redis error - ${error?.message}`}); 48 | } 49 | }); 50 | 51 | app.use(express.static(path.join(process.cwd(), 'frontend', 'dist'))); 52 | 53 | // Middleware 54 | app.use((req: Request, _: Response, next: NextFunction) => { 55 | loggingService.debug('Request received:'); 56 | loggingService.debug('Method:', req.method); 57 | loggingService.debug('URL:', req.url); 58 | loggingService.debug('Headers:', req.headers); 59 | loggingService.debug('Body:', req.body); 60 | next(); 61 | }); 62 | 63 | // Routes 64 | app.use('/api', ApiRoute); 65 | app.use('/json-api', JsonApiRoute); 66 | app.get('*', (req, res) => { 67 | res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html')); 68 | }); 69 | 70 | // Start the server 71 | const server: Server = http.createServer(app); 72 | 73 | const io = isDebug ? new SocketIOServer(server, { cors: {} }) : new SocketIOServer(server); 74 | socketService.registerIo(io); 75 | 76 | server.listen(port, () => { 77 | loggingService.log(`Server running at http://localhost:${port}`); 78 | StatisticsService.setUptime(); 79 | }); 80 | 81 | //Cron 82 | taskService.init(); 83 | -------------------------------------------------------------------------------- /src/service/download/YTDLPDownloadService.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, spawn } from 'child_process'; 2 | import fs from 'fs'; 3 | 4 | import AbstractDownloadService from '../../service/download/AbstractDownloadService'; 5 | import { SpawnExecutable } from '../../types/GetIplayer/SpawnExecutable'; 6 | import { IplayarrParameter } from '../../types/IplayarrParameters'; 7 | import { qualityProfiles } from '../../types/QualityProfiles'; 8 | import configService from '../configService'; 9 | import loggingService from '../loggingService'; 10 | 11 | class YTDLPDownloadService implements AbstractDownloadService { 12 | async postProcess(pid: string, directory: string, code : any): Promise { 13 | if (code != 0) { 14 | fs.rmSync(directory, { recursive: true, force: true }); 15 | } 16 | } 17 | 18 | async #getExecutable(): Promise { 19 | const ytdlpExecConf = (await configService.getParameter(IplayarrParameter.YTDLP_EXEC)) as string; 20 | const execArgs = ytdlpExecConf.split(' '); 21 | const ytdlpExec: string = execArgs.shift() as string; 22 | 23 | return { exec: ytdlpExec, args: execArgs }; 24 | } 25 | 26 | async download(pid: string, directory: string): Promise { 27 | const executable: SpawnExecutable = await this.#getExecutable(); 28 | const videoQuality = (await configService.getParameter(IplayarrParameter.VIDEO_QUALITY)) as string; 29 | const width_str = qualityProfiles.find(({ id }) => id == videoQuality)?.quality; 30 | 31 | const outputFormat = await configService.getParameter(IplayarrParameter.OUTPUT_FORMAT) as string; 32 | 33 | if (width_str) { 34 | const width = parseInt(width_str); 35 | if (!isNaN(width)) { 36 | executable.args.push('-f'); 37 | executable.args.push(`bestvideo[width<=${width}]+bestaudio`); 38 | executable.args.push('--merge-output-format'); 39 | executable.args.push(outputFormat); 40 | } 41 | } 42 | 43 | const iplayerURL: string = `https://www.bbc.co.uk/iplayer/episode/${pid}`; 44 | const outputTemplate = `${directory}/%(title)s.%(ext)s`; 45 | 46 | const args = [ 47 | ...executable.args, 48 | '--progress-template', 49 | '%(progress._percent_str)s of ~%(progress._total_bytes_estimate_str)s @ %(progress._speed_str)s ETA: %(progress.eta_str)s [audio+video]', 50 | '-o', 51 | outputTemplate, 52 | iplayerURL, 53 | ]; 54 | 55 | // Log the command being run 56 | const fullCommand = `${executable.exec} ${args.join(' ')}`; 57 | loggingService.debug(pid, `Running command: ${fullCommand}`); 58 | 59 | return spawn(executable.exec, args); 60 | } 61 | } 62 | 63 | export default new YTDLPDownloadService(); 64 | -------------------------------------------------------------------------------- /tests/service/download/YTDLPDownloadService.test.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import fs from 'fs'; 3 | 4 | import configService from '../../../src/service/configService'; 5 | import ytdlpDownloadService from '../../../src/service/download/YTDLPDownloadService'; 6 | import loggingService from '../../../src/service/loggingService'; 7 | 8 | jest.mock('child_process', () => ({ 9 | spawn: jest.fn(), 10 | })); 11 | jest.mock('fs', () => ({ 12 | rmSync: jest.fn(), 13 | })); 14 | jest.mock('../../../src/service/configService', () => ({ 15 | getParameter: jest.fn(), 16 | })); 17 | jest.mock('../../../src/service/loggingService', () => ({ 18 | debug: jest.fn(), 19 | })); 20 | 21 | describe('YTDLPDownloadService', () => { 22 | afterEach(() => { 23 | jest.clearAllMocks(); 24 | }); 25 | 26 | describe('download', () => { 27 | it('should call spawn with correct parameters', async () => { 28 | const pid = 'test-pid'; 29 | const directory = '/fake/dir'; 30 | const ytdlpExecConf = 'yt-dlp'; 31 | const videoQualityId = '720p'; 32 | 33 | (configService.getParameter as jest.Mock) 34 | .mockResolvedValueOnce(ytdlpExecConf) 35 | .mockResolvedValueOnce(videoQualityId); 36 | 37 | const mockChildProcess = { stdout: { on: jest.fn() }, stderr: { on: jest.fn() } }; 38 | (spawn as jest.Mock).mockReturnValue(mockChildProcess); 39 | 40 | const result = await ytdlpDownloadService.download(pid, directory); 41 | 42 | expect(configService.getParameter).toHaveBeenCalledWith('YTDLP_EXEC'); 43 | expect(configService.getParameter).toHaveBeenCalledWith('VIDEO_QUALITY'); 44 | expect(loggingService.debug).toHaveBeenCalledWith( 45 | pid, 46 | expect.stringContaining('Running command: yt-dlp') 47 | ); 48 | expect(spawn).toHaveBeenCalledWith( 49 | 'yt-dlp', 50 | expect.arrayContaining([ 51 | '--progress-template', 52 | expect.any(String), 53 | '-o', 54 | expect.stringContaining(directory), 55 | expect.stringContaining(pid) 56 | ]) 57 | ); 58 | expect(result).toBe(mockChildProcess); 59 | 60 | }); 61 | }); 62 | 63 | describe('postProcess', () => { 64 | it('should remove directory if code is not 0', async () => { 65 | const directory = '/fake/dir'; 66 | const code = 1; 67 | 68 | await ytdlpDownloadService.postProcess('some-pid', directory, code); 69 | 70 | expect(fs.rmSync).toHaveBeenCalledWith(directory, { recursive: true, force: true }); 71 | }); 72 | 73 | it('should not remove directory if code is 0', async () => { 74 | const directory = '/fake/dir'; 75 | const code = 0; 76 | 77 | await ytdlpDownloadService.postProcess('some-pid', directory, code); 78 | 79 | expect(fs.rmSync).not.toHaveBeenCalled(); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /frontend/src/components/common/ListEditor.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 32 | 33 | 106 | -------------------------------------------------------------------------------- /tests/endpoints/newznab/SearchEndpoint.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { parseStringPromise } from 'xml2js'; 3 | 4 | import SearchEndpoint from '../../../src/endpoints/newznab/SearchEndpoint'; 5 | import searchFacade from '../../../src/facade/searchFacade'; 6 | import statisticsService from '../../../src/service/stats/StatisticsService'; 7 | import { VideoType } from '../../../src/types/IPlayerSearchResult'; 8 | import * as Utils from '../../../src/utils/Utils'; 9 | 10 | jest.mock('../../../src/facade/searchFacade'); 11 | jest.mock('../../../src/service/stats/StatisticsService'); 12 | jest.spyOn(Utils, 'getBaseUrl').mockReturnValue('http://localhost:3000'); 13 | jest.spyOn(Utils, 'createNZBDownloadLink').mockImplementation(() => Promise.resolve('/nzb/link.nzb')); 14 | 15 | describe('SearchEndpoint', () => { 16 | let req: Partial; 17 | let res: Partial; 18 | let sendMock: jest.Mock; 19 | let setMock: jest.Mock; 20 | 21 | beforeEach(() => { 22 | sendMock = jest.fn(); 23 | setMock = jest.fn(); 24 | req = { 25 | query: { 26 | q: 'Test Show', 27 | season: '1', 28 | ep: '2', 29 | cat: '5000,5040', 30 | app: 'radarr', 31 | apikey: 'mockkey', 32 | }, 33 | }; 34 | res = { 35 | set: setMock, 36 | send: sendMock, 37 | }; 38 | }); 39 | 40 | it('responds with valid XML and logs search history', async () => { 41 | const fakeResults = [ 42 | { 43 | pid: 'xyz123', 44 | type: VideoType.TV, 45 | nzbName: 'Test.Show.S01E02.720p', 46 | size: 1500, 47 | pubDate: new Date(), 48 | }, 49 | ]; 50 | 51 | (searchFacade.search as jest.Mock).mockResolvedValue(fakeResults); 52 | (statisticsService.addSearch as jest.Mock).mockImplementation(() => { }); 53 | 54 | await SearchEndpoint(req as Request, res as Response); 55 | 56 | expect(setMock).toHaveBeenCalledWith('Content-Type', 'application/xml'); 57 | expect(sendMock).toHaveBeenCalled(); 58 | 59 | const xml = sendMock.mock.calls[0][0]; 60 | expect(typeof xml).toBe('string'); 61 | 62 | const parsed = await parseStringPromise(xml); 63 | expect(parsed).toHaveProperty('rss'); 64 | expect(parsed.rss).toHaveProperty('channel'); 65 | expect(parsed.rss.channel[0]).toHaveProperty('item'); 66 | 67 | expect(searchFacade.search).toHaveBeenCalledWith('Test Show', '1', '2'); 68 | expect(statisticsService.addSearch).toHaveBeenCalledWith({ 69 | term: 'Test Show', 70 | results: 1, 71 | appId: 'radarr', 72 | series: '1', 73 | episode: '2', 74 | time: expect.any(Number) 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/routes/json-api/QueueRoute.test.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import request from 'supertest'; 3 | 4 | import router from '../../../src/routes/json-api/QueueRoute'; 5 | import historyService from '../../../src/service/historyService'; 6 | import queueService from '../../../src/service/queueService'; 7 | import socketService from '../../../src/service/socketService'; 8 | 9 | jest.mock('../../../src/service/queueService'); 10 | jest.mock('../../../src/service/historyService'); 11 | jest.mock('../../../src/service/socketService'); 12 | 13 | const app = express(); 14 | app.use(express.json()); 15 | app.use('/', router); 16 | 17 | describe('Queue and History Routes', () => { 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | describe('GET /queue', () => { 23 | it('returns the current queue', async () => { 24 | const mockQueue = [{ pid: '1', name: 'Test Item' }]; 25 | (queueService.getQueue as jest.Mock).mockReturnValue(mockQueue); 26 | 27 | const res = await request(app).get('/queue'); 28 | expect(res.status).toBe(200); 29 | expect(res.body).toEqual(mockQueue); 30 | }); 31 | }); 32 | 33 | describe('GET /history', () => { 34 | it('returns the history', async () => { 35 | const mockHistory = [{ pid: '2', name: 'Old Item' }]; 36 | (historyService.getHistory as jest.Mock).mockResolvedValue(mockHistory); 37 | 38 | const res = await request(app).get('/history'); 39 | expect(res.status).toBe(200); 40 | expect(res.body).toEqual(mockHistory); 41 | }); 42 | }); 43 | 44 | describe('DELETE /queue', () => { 45 | it('removes an item from the queue and emits update', async () => { 46 | const pid = '123'; 47 | const updatedQueue = [{ pid: '456', name: 'Remaining Item' }]; 48 | 49 | (queueService.getQueue as jest.Mock).mockReturnValue(updatedQueue); 50 | 51 | const res = await request(app).delete(`/queue?pid=${pid}`); 52 | expect(queueService.cancelItem).toHaveBeenCalledWith(pid); 53 | expect(socketService.emit).toHaveBeenCalledWith('queue', updatedQueue); 54 | expect(res.status).toBe(200); 55 | expect(res.body).toEqual(updatedQueue); 56 | }); 57 | }); 58 | 59 | describe('DELETE /history', () => { 60 | it('removes an item from history and emits update', async () => { 61 | const pid = '789'; 62 | const updatedHistory = [{ pid: '321', name: 'Another' }]; 63 | 64 | (historyService.removeHistory as jest.Mock).mockResolvedValue(undefined); 65 | (historyService.getHistory as jest.Mock).mockResolvedValue(updatedHistory); 66 | 67 | const res = await request(app).delete(`/history?pid=${pid}`); 68 | expect(historyService.removeHistory).toHaveBeenCalledWith(pid); 69 | expect(socketService.emit).toHaveBeenCalledWith('history', updatedHistory); 70 | expect(res.status).toBe(200); 71 | expect(res.body).toEqual(updatedHistory); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /tests/routes/ApiRoute.test.ts: -------------------------------------------------------------------------------- 1 | import express, { Response } from 'express'; 2 | import request from 'supertest'; 3 | 4 | import { NewzNabEndpointDirectory } from '../../src/constants/EndpointDirectory'; 5 | import router from '../../src/routes/ApiRoute'; // Update with the correct path to your router 6 | import configService from '../../src/service/configService'; 7 | import { ApiError } from '../../src/types/responses/ApiResponse'; 8 | 9 | // Mocking dependencies 10 | jest.mock('../../src/service/configService'); 11 | jest.mock('../../src/constants/EndpointDirectory', () => ({ 12 | SabNZBDEndpointDirectory: { 13 | someEndpoint: jest.fn(async (_, res: Response) => { 14 | res.status(200).json({ success: true }); 15 | return true; 16 | }), 17 | }, 18 | NewzNabEndpointDirectory: { 19 | someEndpoint: jest.fn(async (_, res: Response) => { 20 | res.status(200).json({ success: true }); 21 | return true; 22 | }), 23 | }, 24 | })); 25 | 26 | describe('API Route Tests', () => { 27 | const app = express(); 28 | app.use('/api', router); 29 | 30 | it('should return 401 if API key is incorrect', async () => { 31 | // Mock the getParameter method to return a different API key than the one passed in the query 32 | (configService.getParameter as jest.Mock).mockResolvedValue('correct-api-key'); 33 | 34 | const response = await request(app) 35 | .post('/api') 36 | .query({ apikey: 'wrong-api-key' }) // Using incorrect API key 37 | .send(); 38 | 39 | expect(response.status).toBe(401); 40 | expect(response.body.error).toBe(ApiError.NOT_AUTHORISED); 41 | }); 42 | 43 | it('should return 404 if endpoint is not found', async () => { 44 | // Mock the getParameter method to return the correct API key 45 | (configService.getParameter as jest.Mock).mockResolvedValue('correct-api-key'); 46 | 47 | const response = await request(app) 48 | .post('/api') 49 | .query({ apikey: 'correct-api-key', mode: 'invalidMode' }) // Using invalid mode 50 | .send(); 51 | 52 | expect(response.status).toBe(404); 53 | expect(response.body.error).toBe(ApiError.API_NOT_FOUND); 54 | }); 55 | 56 | it('should call the correct endpoint if API key is correct and endpoint exists', async () => { 57 | // Mock the getParameter method to return the correct API key 58 | (configService.getParameter as jest.Mock).mockResolvedValue('correct-api-key'); 59 | 60 | // Send a request with a valid API key and a valid endpoint (NewzNab) 61 | const response = await request(app) 62 | .post('/api') 63 | .query({ apikey: 'correct-api-key', t: 'someEndpoint' }) 64 | .send(); 65 | 66 | expect(response.status).toBe(200); 67 | expect((NewzNabEndpointDirectory as any).someEndpoint).toHaveBeenCalled(); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/service/nzb/NZBGetService.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from 'axios'; 2 | import { v4 } from 'uuid'; 3 | 4 | import { App } from '../../types/App'; 5 | import { NZBGetAppendRequest } from '../../types/requests/nzbget/NZBGetAppendRequest'; 6 | import AbstractNZBService from './AbstractNZBService'; 7 | 8 | class NZBGetService implements AbstractNZBService { 9 | 10 | async testConnection(inputUrl: string, { username, password }: any): Promise { 11 | const url = new URL(`${inputUrl}/jsonrpc`); 12 | url.username = username; 13 | url.password = password; 14 | 15 | try { 16 | const response = await axios.get(`${url.toString()}/version`); 17 | if (response.status == 200) return true; 18 | return false; 19 | } catch (error) { 20 | if (axios.isAxiosError(error)) { 21 | return error.message; 22 | } 23 | return false; 24 | } 25 | } 26 | 27 | async addFile({ url: inputUrl, username, password }: App, files: Express.Multer.File[]): Promise { 28 | const url = `${inputUrl}/jsonrpc`; 29 | 30 | const file = files[0]; 31 | const nzo_id = v4(); 32 | const requestBody: NZBGetAppendRequest = { 33 | method: 'append', 34 | params: [ 35 | file.originalname, // NZB filename (empty for auto-detection) 36 | file.buffer.toString('base64'), // Base64-encoded NZB file 37 | 'iplayer', // Category 38 | 0, // Priority 39 | false, // Add to top 40 | false, // Add paused 41 | nzo_id, // Dupe key 42 | 1000, // Dupe score 43 | 'force', // Dupe mode 44 | [], // Post-processing parameters 45 | ], 46 | id: 1, 47 | }; 48 | 49 | try { 50 | const response = await axios.post(`${url}/append`, requestBody, { 51 | auth: { 52 | username: username as string, 53 | password: password as string, 54 | }, 55 | headers: { 56 | 'Content-Type': 'application/json', 57 | }, 58 | }); 59 | 60 | if (response.status == 200) { 61 | return { 62 | status: 200, 63 | data: { 64 | status: true, 65 | nzo_ids: [nzo_id], 66 | }, 67 | } as unknown as AxiosResponse; 68 | } else { 69 | return response; 70 | } 71 | } catch { 72 | return { 73 | status: 500, 74 | data: { 75 | status: false, 76 | nzo_ids: [], 77 | }, 78 | } as unknown as AxiosResponse; 79 | } 80 | } 81 | } 82 | 83 | export default new NZBGetService(); 84 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Publish Docker image 11 | 12 | on: 13 | release: 14 | types: [published] 15 | workflow_dispatch: 16 | 17 | jobs: 18 | push_to_registry: 19 | name: Push Docker image to Docker Hub 20 | runs-on: ubuntu-latest 21 | permissions: 22 | packages: write 23 | contents: read 24 | attestations: write 25 | id-token: write 26 | 27 | steps: 28 | - name: Check out the repo 29 | uses: actions/checkout@v4 30 | 31 | - name: Log in to Docker Hub 32 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 33 | with: 34 | username: ${{ secrets.DOCKER_USERNAME }} 35 | password: ${{ secrets.DOCKER_PASSWORD }} 36 | 37 | - name: Extract metadata (tags, labels) for Docker 38 | id: meta 39 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 40 | with: 41 | images: nikorag/iplayarr 42 | 43 | - name: Set up QEMU 44 | uses: docker/setup-qemu-action@v3 45 | 46 | - name: Set up Docker Buildx 47 | uses: docker/setup-buildx-action@v3 48 | 49 | - name: Extract Git tag 50 | id: extract_tag 51 | run: echo "git_tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 52 | 53 | - name: Write version to JSON 54 | run: | 55 | echo "{\"version\": \"${{ env.git_tag }}\"}" > src/config/version.json 56 | 57 | - name: Build and push Docker image 58 | id: push 59 | uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 60 | with: 61 | context: . 62 | file: ./Dockerfile 63 | push: true 64 | platforms: linux/amd64,linux/arm64,linux/arm/v7 65 | tags: | 66 | ${{ steps.meta.outputs.tags }} 67 | nikorag/iplayarr:latest 68 | labels: ${{ steps.meta.outputs.labels }} 69 | build-args: | 70 | iplayarr_version=${{ env.git_tag }} 71 | 72 | - name: Generate artifact attestation 73 | uses: actions/attest-build-provenance@v2 74 | with: 75 | subject-name: index.docker.io/nikorag/iplayarr 76 | subject-digest: ${{ steps.push.outputs.digest }} 77 | push-to-registry: true 78 | -------------------------------------------------------------------------------- /src/service/loggingService.ts: -------------------------------------------------------------------------------- 1 | import 'winston-daily-rotate-file'; 2 | 3 | import winston from 'winston'; 4 | import DailyRotateFile from 'winston-daily-rotate-file'; 5 | 6 | import { IplayarrParameter } from '../types/IplayarrParameters'; 7 | import { LogLine, LogLineLevel } from '../types/LogLine'; 8 | import configService from './configService'; 9 | import socketService from './socketService'; 10 | 11 | const isTest = process.env.NODE_ENV === 'test'; 12 | 13 | const transport = isTest ? {} : new winston.transports.DailyRotateFile({ 14 | dirname: process.env.LOG_DIR || './logs', 15 | filename: 'iplayarr-%DATE%.log', 16 | datePattern: 'YYYY-MM-DD', 17 | zippedArchive: true, 18 | maxSize: '20m', 19 | maxFiles: '14d', 20 | }); 21 | 22 | const fileLogger = isTest 23 | ? { info: () => { }, error: () => { }, debug: () => { } } : 24 | winston.createLogger({ 25 | level: 'info', 26 | format: winston.format.combine( 27 | winston.format.timestamp(), 28 | winston.format.printf(({ timestamp, level, message }) => { 29 | return `[${timestamp}] [${level.toUpperCase()}] ${message}`; 30 | }) 31 | ), 32 | transports: [transport as DailyRotateFile], 33 | }); 34 | 35 | const loggingService = { 36 | log: (...params: any[]) => { 37 | console.log(...params); 38 | const message = joinOrReturn(params); 39 | fileLogger.info(message); 40 | const logLine: LogLine = { level: LogLineLevel.INFO, id: 'INFO', message, timestamp: new Date() }; 41 | socketService.emit('log', logLine); 42 | }, 43 | 44 | error: (...params: any[]) => { 45 | console.error(params); 46 | const message = joinOrReturn(params); 47 | fileLogger.error(message); 48 | const logLine: LogLine = { level: LogLineLevel.ERROR, id: 'ERROR', message, timestamp: new Date() }; 49 | socketService.emit('log', logLine); 50 | }, 51 | 52 | debug: (...params: any[]) => { 53 | configService.getParameter(IplayarrParameter.DEBUG).then((debug) => { 54 | const message = joinOrReturn(params); 55 | if (debug && debug.toLowerCase() == 'true') { 56 | console.log(...params); 57 | fileLogger.debug(message); 58 | const logLine: LogLine = { level: LogLineLevel.DEBUG, id: 'DEBUG', message, timestamp: new Date() }; 59 | socketService.emit('log', logLine); 60 | } 61 | }); 62 | }, 63 | }; 64 | 65 | function joinOrReturn(input: string | any[]): string { 66 | if (Array.isArray(input)) { 67 | return input 68 | .map((item: any) => { 69 | if (typeof item === 'string') { 70 | return item; 71 | } else { 72 | return JSON.stringify(item, null, 2); 73 | } 74 | }) 75 | .join(' '); 76 | } else if (typeof input === 'string') { 77 | return input; 78 | } 79 | return ''; 80 | } 81 | 82 | export default loggingService; 83 | -------------------------------------------------------------------------------- /frontend/src/views/AboutPage.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 56 | 57 | 91 | -------------------------------------------------------------------------------- /tests/service/taskService.test.ts: -------------------------------------------------------------------------------- 1 | import cron from 'node-cron'; 2 | 3 | import downloadFacade from '../../src/facade/downloadFacade'; 4 | import scheduleFacade from '../../src/facade/scheduleFacade'; 5 | import configService from '../../src/service/configService'; 6 | import episodeCacheService from '../../src/service/episodeCacheService'; 7 | import TaskService from '../../src/service/taskService'; 8 | 9 | // Mock dependencies 10 | jest.mock('node-cron', () => ({ 11 | schedule: jest.fn(), 12 | })); 13 | 14 | jest.mock('../../src/service/configService', () => ({ 15 | getParameter: jest.fn(), 16 | })); 17 | 18 | jest.mock('../../src/facade/scheduleFacade', () => ({ 19 | refreshCache: jest.fn(() => Promise.resolve()), 20 | })); 21 | 22 | jest.mock('../../src/facade/downloadFacade', () => ({ 23 | cleanupFailedDownloads: jest.fn(), 24 | })); 25 | 26 | jest.mock('../../src/service/episodeCacheService', () => ({ 27 | recacheAllSeries: jest.fn(), 28 | })); 29 | 30 | describe('TaskService', () => { 31 | beforeEach(() => { 32 | jest.clearAllMocks(); 33 | }); 34 | 35 | it('should schedule a cron job with the configured schedule', async () => { 36 | // Mock config values 37 | (configService.getParameter as jest.Mock) 38 | .mockResolvedValueOnce('*/5 * * * *') // REFRESH_SCHEDULE 39 | .mockResolvedValueOnce('false'); // NATIVE_SEARCH 40 | 41 | const cronCallback = jest.fn(); 42 | (cron.schedule as jest.Mock).mockImplementation((expression, callback) => { 43 | cronCallback.mockImplementation(callback); // Save the callback for later execution 44 | return {}; 45 | }); 46 | 47 | // Run init 48 | await TaskService.init(); 49 | 50 | // Check cron.schedule was called with correct expression 51 | expect(cron.schedule).toHaveBeenCalledWith('*/5 * * * *', expect.any(Function)); 52 | 53 | // Execute scheduled job manually 54 | await cronCallback(); 55 | 56 | expect(scheduleFacade.refreshCache).toHaveBeenCalled(); 57 | expect(downloadFacade.cleanupFailedDownloads).toHaveBeenCalled(); 58 | expect(episodeCacheService.recacheAllSeries).toHaveBeenCalled(); 59 | }); 60 | 61 | it('should skip recaching if native search is enabled', async () => { 62 | (configService.getParameter as jest.Mock) 63 | .mockResolvedValueOnce('*/5 * * * *') // REFRESH_SCHEDULE 64 | .mockResolvedValueOnce('true'); // NATIVE_SEARCH 65 | 66 | const cronCallback = jest.fn(); 67 | (cron.schedule as jest.Mock).mockImplementation((expression, callback) => { 68 | cronCallback.mockImplementation(callback); 69 | return {}; 70 | }); 71 | 72 | await TaskService.init(); 73 | await cronCallback(); 74 | 75 | expect(scheduleFacade.refreshCache).toHaveBeenCalled(); 76 | expect(downloadFacade.cleanupFailedDownloads).toHaveBeenCalled(); 77 | expect(episodeCacheService.recacheAllSeries).not.toHaveBeenCalled(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /tests/service/loggingService.test.ts: -------------------------------------------------------------------------------- 1 | import configService from '../../src/service/configService'; 2 | import loggingService from '../../src/service/loggingService'; 3 | import socketService from '../../src/service/socketService'; 4 | import { LogLineLevel } from '../../src/types/LogLine'; 5 | 6 | jest.mock('../../src/service/socketService', () => ({ 7 | emit: jest.fn(), 8 | })); 9 | 10 | jest.mock('../../src/service/configService', () => ({ 11 | getParameter: jest.fn(), 12 | })); 13 | 14 | describe('loggingService', () => { 15 | const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); 16 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 17 | 18 | afterEach(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | it('should log info messages and emit socket log', () => { 23 | loggingService.log('test info', { value: 123 }); 24 | 25 | expect(logSpy).toHaveBeenCalledWith('test info', { value: 123 }); 26 | expect(socketService.emit).toHaveBeenCalledWith( 27 | 'log', 28 | expect.objectContaining({ 29 | level: LogLineLevel.INFO, 30 | id: 'INFO', 31 | message: expect.stringContaining('test info'), 32 | timestamp: expect.any(Date), 33 | }) 34 | ); 35 | }); 36 | 37 | it('should log error messages and emit socket log', () => { 38 | loggingService.error('something went wrong', { err: true }); 39 | 40 | expect(errorSpy).toHaveBeenCalledWith(['something went wrong', { err: true }]); 41 | expect(socketService.emit).toHaveBeenCalledWith( 42 | 'log', 43 | expect.objectContaining({ 44 | level: LogLineLevel.ERROR, 45 | id: 'ERROR', 46 | message: expect.stringContaining('something went wrong'), 47 | timestamp: expect.any(Date), 48 | }) 49 | ); 50 | }); 51 | 52 | it('should emit debug log only if DEBUG param is true', async () => { 53 | (configService.getParameter as jest.Mock).mockResolvedValue('true'); 54 | 55 | loggingService.debug('debugging info', { debug: true }); 56 | 57 | await new Promise(setImmediate); 58 | 59 | expect(logSpy).toHaveBeenCalledWith('debugging info', { debug: true }); 60 | expect(socketService.emit).toHaveBeenCalledWith( 61 | 'log', 62 | expect.objectContaining({ 63 | level: LogLineLevel.DEBUG, 64 | id: 'DEBUG', 65 | message: expect.stringContaining('debugging info'), 66 | timestamp: expect.any(Date), 67 | }) 68 | ); 69 | }); 70 | 71 | it('should not emit debug log if DEBUG param is not true', async () => { 72 | (configService.getParameter as jest.Mock).mockResolvedValue('false'); 73 | 74 | loggingService.debug('debugging info'); 75 | 76 | await new Promise(setImmediate); 77 | 78 | expect(logSpy).not.toHaveBeenCalled(); 79 | expect(socketService.emit).not.toHaveBeenCalled(); 80 | }); 81 | }); 82 | --------------------------------------------------------------------------------