├── config ├── .gitkeep ├── db │ └── .gitkeep └── logs │ └── .gitkeep ├── .github ├── FUNDING.yml ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── invalid_template.yml │ ├── deploy_docs.yml │ ├── support.yml │ └── preview.yml ├── stale.yml └── lock.yml ├── server ├── api │ └── themoviedb │ │ └── constants.ts ├── templates │ └── email │ │ ├── resetpassword │ │ └── subject.pug │ │ ├── test-email │ │ └── subject.pug │ │ ├── generatedpassword │ │ └── subject.pug │ │ └── media-request │ │ └── subject.pug ├── constants │ ├── user.ts │ ├── server.ts │ └── media.ts ├── interfaces │ └── api │ │ ├── discoverInterfaces.ts │ │ ├── common.ts │ │ ├── mediaInterfaces.ts │ │ ├── personInterfaces.ts │ │ ├── requestInterfaces.ts │ │ ├── userInterfaces.ts │ │ ├── serviceInterfaces.ts │ │ ├── userSettingsInterfaces.ts │ │ ├── plexInterfaces.ts │ │ └── settingsInterfaces.ts ├── tsconfig.json ├── entity │ ├── Session.ts │ ├── UserPushSubscription.ts │ ├── SeasonRequest.ts │ └── Season.ts ├── utils │ ├── appDataVolume.ts │ ├── typeHelpers.ts │ ├── appVersion.ts │ └── asyncLock.ts ├── migration │ ├── 1607928251245-DropImdbIdConstraint.ts │ └── 1605085519544-SeasonStatus.ts ├── types │ ├── express.d.ts │ └── plex-api.d.ts ├── routes │ ├── collection.ts │ └── search.ts ├── lib │ ├── notifications │ │ ├── agents │ │ │ └── agent.ts │ │ └── index.ts │ ├── email │ │ └── index.ts │ ├── cache.ts │ └── permissions.ts ├── models │ └── Collection.ts ├── middleware │ └── auth.ts └── logger.ts ├── public ├── favicon.ico ├── preview.jpg ├── logo_full.png ├── badge-128x128.png ├── favicon-16x16.png ├── favicon-32x32.png ├── images │ ├── rotate1.jpg │ ├── rotate2.jpg │ ├── rotate3.jpg │ ├── rotate4.jpg │ ├── rotate5.jpg │ ├── rotate6.jpg │ ├── overseerr_poster_not_found.png │ ├── overseerr_poster_not_found_logo_top.png │ └── overseerr_poster_not_found_logo_center.png ├── os_logo_filled.png ├── apple-touch-icon.png ├── clock-icon-192x192.png ├── cog-icon-192x192.png ├── user-icon-192x192.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-splash-1125-2436.jpg ├── apple-splash-1136-640.jpg ├── apple-splash-1170-2532.jpg ├── apple-splash-1242-2208.jpg ├── apple-splash-1242-2688.jpg ├── apple-splash-1284-2778.jpg ├── apple-splash-1334-750.jpg ├── apple-splash-1536-2048.jpg ├── apple-splash-1620-2160.jpg ├── apple-splash-1668-2224.jpg ├── apple-splash-1668-2388.jpg ├── apple-splash-1792-828.jpg ├── apple-splash-2048-1536.jpg ├── apple-splash-2048-2732.jpg ├── apple-splash-2160-1620.jpg ├── apple-splash-2208-1242.jpg ├── apple-splash-2224-1668.jpg ├── apple-splash-2388-1668.jpg ├── apple-splash-2436-1125.jpg ├── apple-splash-2532-1170.jpg ├── apple-splash-2688-1242.jpg ├── apple-splash-2732-2048.jpg ├── apple-splash-2778-1284.jpg ├── apple-splash-640-1136.jpg ├── apple-splash-750-1334.jpg ├── apple-splash-828-1792.jpg ├── sparkles-icon-192x192.png ├── android-chrome-192x192_maskable.png ├── android-chrome-512x512_maskable.png ├── os_icon.svg └── site.webmanifest ├── .gitbook.yaml ├── Dockerfile.local ├── postcss.config.js ├── .prettierignore ├── .stoplight.json ├── src ├── pages │ ├── search.tsx │ ├── setup.tsx │ ├── index.tsx │ ├── login │ │ ├── index.tsx │ │ └── plex │ │ │ └── loading.tsx │ ├── profile │ │ ├── index.tsx │ │ └── settings │ │ │ ├── index.tsx │ │ │ ├── password.tsx │ │ │ ├── permissions.tsx │ │ │ ├── main.tsx │ │ │ └── notifications │ │ │ ├── email.tsx │ │ │ ├── discord.tsx │ │ │ ├── telegram.tsx │ │ │ └── webpush.tsx │ ├── tv │ │ └── [tvId] │ │ │ ├── cast.tsx │ │ │ ├── crew.tsx │ │ │ ├── similar.tsx │ │ │ ├── recommendations.tsx │ │ │ └── index.tsx │ ├── users │ │ ├── [userId] │ │ │ ├── index.tsx │ │ │ ├── requests.tsx │ │ │ └── settings │ │ │ │ ├── index.tsx │ │ │ │ ├── permissions.tsx │ │ │ │ ├── password.tsx │ │ │ │ ├── main.tsx │ │ │ │ └── notifications │ │ │ │ ├── email.tsx │ │ │ │ ├── discord.tsx │ │ │ │ ├── telegram.tsx │ │ │ │ └── webpush.tsx │ │ └── index.tsx │ ├── discover │ │ ├── trending.tsx │ │ ├── tv │ │ │ ├── genres.tsx │ │ │ ├── index.tsx │ │ │ ├── upcoming.tsx │ │ │ ├── genre │ │ │ │ └── [genreId] │ │ │ │ │ └── index.tsx │ │ │ ├── network │ │ │ │ └── [networkId] │ │ │ │ │ └── index.tsx │ │ │ └── language │ │ │ │ └── [language] │ │ │ │ └── index.tsx │ │ └── movies │ │ │ ├── genres.tsx │ │ │ ├── upcoming.tsx │ │ │ ├── index.tsx │ │ │ ├── genre │ │ │ └── [genreId] │ │ │ │ └── index.tsx │ │ │ ├── studio │ │ │ └── [studioId] │ │ │ │ └── index.tsx │ │ │ └── language │ │ │ └── [language] │ │ │ └── index.tsx │ ├── requests │ │ └── index.tsx │ ├── person │ │ └── [personId] │ │ │ └── index.tsx │ ├── movie │ │ └── [movieId] │ │ │ ├── cast.tsx │ │ │ ├── crew.tsx │ │ │ ├── similar.tsx │ │ │ ├── recommendations.tsx │ │ │ └── index.tsx │ ├── resetpassword │ │ ├── [guid] │ │ │ └── index.tsx │ │ └── index.tsx │ ├── settings │ │ ├── index.tsx │ │ ├── logs.tsx │ │ ├── main.tsx │ │ ├── about.tsx │ │ ├── plex.tsx │ │ ├── users.tsx │ │ ├── jobs.tsx │ │ ├── jellyfin.tsx │ │ ├── services.tsx │ │ └── notifications │ │ │ ├── email.tsx │ │ │ ├── discord.tsx │ │ │ ├── lunasea.tsx │ │ │ ├── pushover.tsx │ │ │ ├── slack.tsx │ │ │ ├── telegram.tsx │ │ │ ├── webhook.tsx │ │ │ ├── pushbullet.tsx │ │ │ └── webpush.tsx │ ├── _document.tsx │ ├── collection │ │ └── [collectionId] │ │ │ └── index.tsx │ └── 404.tsx ├── hooks │ ├── useIsTouch.ts │ ├── useSettings.ts │ ├── useLocale.ts │ ├── useRouteGuard.ts │ ├── useLockBodyScroll.ts │ ├── useClickOutside.ts │ ├── useDebouncedState.ts │ └── useRequestOverride.ts ├── assets │ ├── ellipsis.svg │ ├── spinner.svg │ ├── extlogos │ │ ├── lunasea.svg │ │ ├── pushbullet.svg │ │ ├── telegram.svg │ │ ├── slack.svg │ │ ├── pushover.svg │ │ └── discord.svg │ ├── services │ │ ├── radarr.svg │ │ ├── plex.svg │ │ └── imdb.svg │ ├── rt_fresh.svg │ ├── rt_rotten.svg │ └── tmdb_logo.svg ├── types │ ├── react-intl-auto.d.ts │ └── custom.d.ts ├── utils │ ├── numberHelpers.ts │ ├── creditHelpers.ts │ ├── typeHelpers.ts │ └── jellyfin.ts ├── components │ ├── Layout │ │ └── Notifications │ │ │ └── index.tsx │ ├── TitleCard │ │ ├── Placeholder.tsx │ │ └── TmdbTitleCard.tsx │ ├── Common │ │ ├── PageTitle │ │ │ └── index.tsx │ │ ├── CachedImage │ │ │ └── index.tsx │ │ ├── Header │ │ │ └── index.tsx │ │ ├── Badge │ │ │ └── index.tsx │ │ ├── List │ │ │ └── index.tsx │ │ ├── PlayButton │ │ │ └── index.tsx │ │ ├── SensitiveInput │ │ │ └── index.tsx │ │ ├── ConfirmButton │ │ │ └── index.tsx │ │ └── Alert │ │ │ └── index.tsx │ ├── ToastContainer │ │ └── index.tsx │ ├── JSONEditor │ │ └── index.tsx │ ├── Settings │ │ └── CopyButton.tsx │ ├── AppDataWarning │ │ └── index.tsx │ ├── Discover │ │ ├── DiscoverTv.tsx │ │ ├── DiscoverMovies.tsx │ │ ├── DiscoverTvUpcoming.tsx │ │ ├── Upcoming.tsx │ │ ├── Trending.tsx │ │ ├── TvGenreList │ │ │ └── index.tsx │ │ ├── MovieGenreList │ │ │ └── index.tsx │ │ ├── TvGenreSlider │ │ │ └── index.tsx │ │ ├── DiscoverTvGenre │ │ │ └── index.tsx │ │ ├── MovieGenreSlider │ │ │ └── index.tsx │ │ └── DiscoverMovieGenre │ │ │ └── index.tsx │ ├── CompanyCard │ │ └── index.tsx │ ├── Search │ │ └── index.tsx │ ├── StatusChacker │ │ └── index.tsx │ ├── ServiceWorkerSetup │ │ └── index.tsx │ └── PlexLoginButton │ │ └── index.tsx ├── context │ ├── InteractionContext.tsx │ ├── UserContext.tsx │ └── SettingsContext.tsx └── i18n │ └── globalMessages.ts ├── docs ├── using-overseerr │ └── notifications │ │ ├── pushbullet.md │ │ ├── slack.md │ │ ├── lunasea.md │ │ ├── pushover.md │ │ ├── discord.md │ │ ├── README.md │ │ ├── webpush.md │ │ └── telegram.md ├── extending-overseerr │ ├── fail2ban.md │ └── third-party.md ├── SUMMARY.md └── README.md ├── next-env.d.ts ├── .editorconfig ├── docker-compose.yml ├── next.config.js ├── stylelint.config.js ├── .dockerignore ├── .gitattributes ├── babel.config.js ├── .vscode ├── settings.json └── extensions.json ├── tsconfig.json ├── .gitignore ├── Dockerfile ├── LICENSE ├── ormconfig.js ├── .eslintrc.js └── tailwind.config.js /config/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/db/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [sct] 2 | patreon: overseerr 3 | -------------------------------------------------------------------------------- /server/api/themoviedb/constants.ts: -------------------------------------------------------------------------------- 1 | export const ANIME_KEYWORD_ID = 210024; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/preview.jpg -------------------------------------------------------------------------------- /public/logo_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/logo_full.png -------------------------------------------------------------------------------- /server/templates/email/resetpassword/subject.pug: -------------------------------------------------------------------------------- 1 | != `Password Reset [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /server/templates/email/test-email/subject.pug: -------------------------------------------------------------------------------- 1 | != `Test Notification [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /.gitbook.yaml: -------------------------------------------------------------------------------- 1 | root: ./docs 2 | 3 | ​structure: 4 | readme: README.md 5 | summary: SUMMARY.md​ 6 | -------------------------------------------------------------------------------- /public/badge-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/badge-128x128.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /server/templates/email/generatedpassword/subject.pug: -------------------------------------------------------------------------------- 1 | != `Account Information [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /public/images/rotate1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/rotate1.jpg -------------------------------------------------------------------------------- /public/images/rotate2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/rotate2.jpg -------------------------------------------------------------------------------- /public/images/rotate3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/rotate3.jpg -------------------------------------------------------------------------------- /public/images/rotate4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/rotate4.jpg -------------------------------------------------------------------------------- /public/images/rotate5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/rotate5.jpg -------------------------------------------------------------------------------- /public/images/rotate6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/rotate6.jpg -------------------------------------------------------------------------------- /public/os_logo_filled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/os_logo_filled.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/clock-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/clock-icon-192x192.png -------------------------------------------------------------------------------- /public/cog-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/cog-icon-192x192.png -------------------------------------------------------------------------------- /public/user-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/user-icon-192x192.png -------------------------------------------------------------------------------- /server/constants/user.ts: -------------------------------------------------------------------------------- 1 | export enum UserType { 2 | PLEX = 1, 3 | LOCAL = 2, 4 | JELLYFIN = 3, 5 | } 6 | -------------------------------------------------------------------------------- /server/templates/email/media-request/subject.pug: -------------------------------------------------------------------------------- 1 | != `${requestType} - ${mediaName} [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /Dockerfile.local: -------------------------------------------------------------------------------- 1 | FROM node:14.17-alpine 2 | 3 | COPY . /app 4 | WORKDIR /app 5 | 6 | RUN yarn 7 | 8 | CMD yarn dev 9 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-splash-1125-2436.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1125-2436.jpg -------------------------------------------------------------------------------- /public/apple-splash-1136-640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1136-640.jpg -------------------------------------------------------------------------------- /public/apple-splash-1170-2532.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1170-2532.jpg -------------------------------------------------------------------------------- /public/apple-splash-1242-2208.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1242-2208.jpg -------------------------------------------------------------------------------- /public/apple-splash-1242-2688.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1242-2688.jpg -------------------------------------------------------------------------------- /public/apple-splash-1284-2778.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1284-2778.jpg -------------------------------------------------------------------------------- /public/apple-splash-1334-750.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1334-750.jpg -------------------------------------------------------------------------------- /public/apple-splash-1536-2048.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1536-2048.jpg -------------------------------------------------------------------------------- /public/apple-splash-1620-2160.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1620-2160.jpg -------------------------------------------------------------------------------- /public/apple-splash-1668-2224.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1668-2224.jpg -------------------------------------------------------------------------------- /public/apple-splash-1668-2388.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1668-2388.jpg -------------------------------------------------------------------------------- /public/apple-splash-1792-828.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-1792-828.jpg -------------------------------------------------------------------------------- /public/apple-splash-2048-1536.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2048-1536.jpg -------------------------------------------------------------------------------- /public/apple-splash-2048-2732.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2048-2732.jpg -------------------------------------------------------------------------------- /public/apple-splash-2160-1620.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2160-1620.jpg -------------------------------------------------------------------------------- /public/apple-splash-2208-1242.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2208-1242.jpg -------------------------------------------------------------------------------- /public/apple-splash-2224-1668.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2224-1668.jpg -------------------------------------------------------------------------------- /public/apple-splash-2388-1668.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2388-1668.jpg -------------------------------------------------------------------------------- /public/apple-splash-2436-1125.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2436-1125.jpg -------------------------------------------------------------------------------- /public/apple-splash-2532-1170.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2532-1170.jpg -------------------------------------------------------------------------------- /public/apple-splash-2688-1242.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2688-1242.jpg -------------------------------------------------------------------------------- /public/apple-splash-2732-2048.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2732-2048.jpg -------------------------------------------------------------------------------- /public/apple-splash-2778-1284.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-2778-1284.jpg -------------------------------------------------------------------------------- /public/apple-splash-640-1136.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-640-1136.jpg -------------------------------------------------------------------------------- /public/apple-splash-750-1334.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-750-1334.jpg -------------------------------------------------------------------------------- /public/apple-splash-828-1792.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/apple-splash-828-1792.jpg -------------------------------------------------------------------------------- /public/sparkles-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/sparkles-icon-192x192.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /server/constants/server.ts: -------------------------------------------------------------------------------- 1 | export enum MediaServerType { 2 | PLEX = 1, 3 | JELLYFIN, 4 | EMBY, 5 | NOT_CONFIGURED, 6 | } 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192_maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/android-chrome-192x192_maskable.png -------------------------------------------------------------------------------- /public/android-chrome-512x512_maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/android-chrome-512x512_maskable.png -------------------------------------------------------------------------------- /public/images/overseerr_poster_not_found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/overseerr_poster_not_found.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Generated files which we would not like to format 2 | .next/ 3 | dist/ 4 | config/ 5 | 6 | # assets 7 | src/assets/ 8 | public/ 9 | -------------------------------------------------------------------------------- /server/interfaces/api/discoverInterfaces.ts: -------------------------------------------------------------------------------- 1 | export interface GenreSliderItem { 2 | id: number; 3 | name: string; 4 | backdrops: string[]; 5 | } 6 | -------------------------------------------------------------------------------- /public/images/overseerr_poster_not_found_logo_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/overseerr_poster_not_found_logo_top.png -------------------------------------------------------------------------------- /public/images/overseerr_poster_not_found_logo_center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juandjara/jellyseer/HEAD/public/images/overseerr_poster_not_found_logo_center.png -------------------------------------------------------------------------------- /.stoplight.json: -------------------------------------------------------------------------------- 1 | { 2 | "formats": { 3 | "openapi": { 4 | "rootDir": ".", 5 | "include": ["**"] 6 | } 7 | }, 8 | "exclude": ["docs"] 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/search.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Search from '../components/Search'; 3 | 4 | const SearchPage: React.FC = () => { 5 | return ; 6 | }; 7 | 8 | export default SearchPage; 9 | -------------------------------------------------------------------------------- /server/interfaces/api/common.ts: -------------------------------------------------------------------------------- 1 | interface PageInfo { 2 | pages: number; 3 | page: number; 4 | results: number; 5 | pageSize: number; 6 | } 7 | 8 | export interface PaginatedResponse { 9 | pageInfo: PageInfo; 10 | } 11 | -------------------------------------------------------------------------------- /server/interfaces/api/mediaInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type Media from '../../entity/Media'; 2 | import { PaginatedResponse } from './common'; 3 | 4 | export interface MediaResultsResponse extends PaginatedResponse { 5 | results: Media[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/setup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import Setup from '../components/Setup'; 4 | 5 | const SetupPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default SetupPage; 10 | -------------------------------------------------------------------------------- /docs/using-overseerr/notifications/pushbullet.md: -------------------------------------------------------------------------------- 1 | # Pushbullet 2 | 3 | ## Configuration 4 | 5 | ### Access Token 6 | 7 | [Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Overseerr access to the Pushbullet API. 8 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import Discover from '../components/Discover'; 4 | 5 | const Index: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default Index; 10 | -------------------------------------------------------------------------------- /server/interfaces/api/personInterfaces.ts: -------------------------------------------------------------------------------- 1 | import { PersonCreditCast, PersonCreditCrew } from '../../models/Person'; 2 | 3 | export interface PersonCombinedCreditsResponse { 4 | id: number; 5 | cast: PersonCreditCast[]; 6 | crew: PersonCreditCrew[]; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import Login from '../../components/Login'; 4 | 5 | const LoginPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default LoginPage; 10 | -------------------------------------------------------------------------------- /server/interfaces/api/requestInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type { PaginatedResponse } from './common'; 2 | import type { MediaRequest } from '../../entity/MediaRequest'; 3 | 4 | export interface RequestResultsResponse extends PaginatedResponse { 5 | results: MediaRequest[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/hooks/useIsTouch.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { InteractionContext } from '../context/InteractionContext'; 3 | 4 | export const useIsTouch = (): boolean => { 5 | const { isTouch } = useContext(InteractionContext); 6 | return isTouch; 7 | }; 8 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /src/pages/profile/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserProfile from '../../components/UserProfile'; 4 | 5 | const UserPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default UserPage; 10 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/cast.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import TvCast from '../../../components/TvDetails/TvCast'; 4 | 5 | const TvCastPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default TvCastPage; 10 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/crew.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import TvCrew from '../../../components/TvDetails/TvCrew'; 4 | 5 | const TvCrewPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default TvCrewPage; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2019", 5 | "lib": ["ES2019"], 6 | "module": "commonjs", 7 | "outDir": "../dist", 8 | "noEmit": false 9 | }, 10 | "include": ["**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | overseerr: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile.local 7 | ports: 8 | - 5055:5055 9 | volumes: 10 | - .:/app:rw,cached 11 | - /app/node_modules 12 | - /app/.next 13 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserProfile from '../../../components/UserProfile'; 4 | 5 | const UserPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default UserPage; 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global code ownership 2 | * @sct 3 | 4 | # Documentation 5 | docs/ @TheCatLady @samwiseg0 6 | 7 | # Snap-related files 8 | .github/workflows/snap.yaml @samwiseg0 9 | snap/ @samwiseg0 10 | 11 | # i18n locale files 12 | src/i18n/locale/ @sct @TheCatLady 13 | -------------------------------------------------------------------------------- /src/pages/discover/trending.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import Trending from '../../components/Discover/Trending'; 4 | 5 | const TrendingPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default TrendingPage; 10 | -------------------------------------------------------------------------------- /src/pages/requests/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import RequestList from '../../components/RequestList'; 4 | 5 | const RequestsPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default RequestsPage; 10 | -------------------------------------------------------------------------------- /src/pages/person/[personId]/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import PersonDetails from '../../../components/PersonDetails'; 4 | 5 | const MoviePage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default MoviePage; 10 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/similar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import TvSimilar from '../../../components/TvDetails/TvSimilar'; 4 | 5 | const TvSimilarPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default TvSimilarPage; 10 | -------------------------------------------------------------------------------- /src/pages/discover/tv/genres.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import TvGenreList from '../../../components/Discover/TvGenreList'; 4 | 5 | const TvGenresPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default TvGenresPage; 10 | -------------------------------------------------------------------------------- /src/pages/discover/tv/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverTv from '../../../components/Discover/DiscoverTv'; 4 | 5 | const DiscoverTvPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverTvPage; 10 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/cast.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import MovieCast from '../../../components/MovieDetails/MovieCast'; 4 | 5 | const MovieCastPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default MovieCastPage; 10 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/crew.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import MovieCrew from '../../../components/MovieDetails/MovieCrew'; 4 | 5 | const MovieCrewPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default MovieCrewPage; 10 | -------------------------------------------------------------------------------- /src/assets/ellipsis.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Description 2 | 3 | #### Screenshot (if UI-related) 4 | 5 | #### To-Dos 6 | 7 | - [ ] Successful build `yarn build` 8 | - [ ] Translation keys `yarn i18n:extract` 9 | - [ ] Database migration (if required) 10 | 11 | #### Issues Fixed or Closed 12 | 13 | - Fixes #XXXX 14 | -------------------------------------------------------------------------------- /src/pages/discover/movies/genres.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import MovieGenreList from '../../../components/Discover/MovieGenreList'; 4 | 5 | const MovieGenresPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default MovieGenresPage; 10 | -------------------------------------------------------------------------------- /src/pages/discover/movies/upcoming.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import UpcomingMovies from '../../../components/Discover/Upcoming'; 4 | 5 | const UpcomingMoviesPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default UpcomingMoviesPage; 10 | -------------------------------------------------------------------------------- /src/pages/login/plex/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LoadingSpinner from '../../../components/Common/LoadingSpinner'; 3 | 4 | const PlexLoading: React.FC = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default PlexLoading; 13 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/similar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import MovieSimilar from '../../../components/MovieDetails/MovieSimilar'; 4 | 5 | const MovieSimilarPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default MovieSimilarPage; 10 | -------------------------------------------------------------------------------- /src/pages/resetpassword/[guid]/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import ResetPassword from '../../../components/ResetPassword'; 4 | 5 | const ResetPasswordPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default ResetPasswordPage; 10 | -------------------------------------------------------------------------------- /src/pages/discover/movies/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverMovies from '../../../components/Discover/DiscoverMovies'; 4 | 5 | const DiscoverMoviesPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverMoviesPage; 10 | -------------------------------------------------------------------------------- /src/pages/discover/tv/upcoming.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverTvUpcoming from '../../../components/Discover/DiscoverTvUpcoming'; 4 | 5 | const DiscoverTvPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverTvPage; 10 | -------------------------------------------------------------------------------- /src/pages/resetpassword/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import RequestResetLink from '../../components/ResetPassword/RequestResetLink'; 4 | 5 | const RequestResetLinkPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default RequestResetLinkPage; 10 | -------------------------------------------------------------------------------- /src/pages/discover/tv/genre/[genreId]/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverTvGenre from '../../../../../components/Discover/DiscoverTvGenre'; 4 | 5 | const DiscoverTvGenrePage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverTvGenrePage; 10 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/recommendations.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import TvRecommendations from '../../../components/TvDetails/TvRecommendations'; 4 | 5 | const TvRecommendationsPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default TvRecommendationsPage; 10 | -------------------------------------------------------------------------------- /server/constants/media.ts: -------------------------------------------------------------------------------- 1 | export enum MediaRequestStatus { 2 | PENDING = 1, 3 | APPROVED, 4 | DECLINED, 5 | } 6 | 7 | export enum MediaType { 8 | MOVIE = 'movie', 9 | TV = 'tv', 10 | } 11 | 12 | export enum MediaStatus { 13 | UNKNOWN = 1, 14 | PENDING, 15 | PROCESSING, 16 | PARTIALLY_AVAILABLE, 17 | AVAILABLE, 18 | } 19 | -------------------------------------------------------------------------------- /src/types/react-intl-auto.d.ts: -------------------------------------------------------------------------------- 1 | import { MessageDescriptor } from 'react-intl'; 2 | 3 | declare module 'react-intl' { 4 | interface ExtractableMessage { 5 | [key: string]: string; 6 | } 7 | 8 | export function defineMessages( 9 | messages: T 10 | ): { [K in keyof T]: MessageDescriptor }; 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/discover/tv/network/[networkId]/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverNetwork from '../../../../../components/Discover/DiscoverNetwork'; 4 | 5 | const DiscoverTvNetworkPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverTvNetworkPage; 10 | -------------------------------------------------------------------------------- /src/hooks/useSettings.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { 3 | SettingsContext, 4 | SettingsContextProps, 5 | } from '../context/SettingsContext'; 6 | 7 | const useSettings = (): SettingsContextProps => { 8 | const settings = useContext(SettingsContext); 9 | 10 | return settings; 11 | }; 12 | 13 | export default useSettings; 14 | -------------------------------------------------------------------------------- /src/pages/discover/movies/genre/[genreId]/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverMovieGenre from '../../../../../components/Discover/DiscoverMovieGenre'; 4 | 5 | const DiscoverMoviesGenrePage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverMoviesGenrePage; 10 | -------------------------------------------------------------------------------- /src/pages/discover/tv/language/[language]/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverTvLanguage from '../../../../../components/Discover/DiscoverTvLanguage'; 4 | 5 | const DiscoverTvLanguagePage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverTvLanguagePage; 10 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/recommendations.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import MovieRecommendations from '../../../components/MovieDetails/MovieRecommendations'; 4 | 5 | const MovieRecommendationsPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default MovieRecommendationsPage; 10 | -------------------------------------------------------------------------------- /src/pages/discover/movies/studio/[studioId]/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverMovieStudio from '../../../../../components/Discover/DiscoverStudio'; 4 | 5 | const DiscoverMoviesStudioPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverMoviesStudioPage; 10 | -------------------------------------------------------------------------------- /src/hooks/useLocale.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { 3 | LanguageContext, 4 | LanguageContextProps, 5 | } from '../context/LanguageContext'; 6 | 7 | const useLocale = (): Omit => { 8 | const languageContext = useContext(LanguageContext); 9 | 10 | return languageContext; 11 | }; 12 | 13 | export default useLocale; 14 | -------------------------------------------------------------------------------- /src/pages/discover/movies/language/[language]/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import DiscoverMovieLanguage from '../../../../../components/Discover/DiscoverMovieLanguage'; 4 | 5 | const DiscoverMovieLanguagePage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default DiscoverMovieLanguagePage; 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | time: '20:00' 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: github-actions 10 | directory: '/' 11 | schedule: 12 | interval: daily 13 | time: '20:00' 14 | open-pull-requests-limit: 10 15 | -------------------------------------------------------------------------------- /src/assets/spinner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | commitTag: process.env.COMMIT_TAG || 'local', 4 | }, 5 | images: { 6 | domains: ['image.tmdb.org'], 7 | }, 8 | webpack(config) { 9 | config.module.rules.push({ 10 | test: /\.svg$/, 11 | issuer: /\.(js|ts)x?$/, 12 | use: ['@svgr/webpack'], 13 | }); 14 | 15 | return config; 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Support via Discord 4 | url: https://discord.gg/overseerr 5 | about: Chat with users and devs on support and setup related topics. 6 | - name: Support via GitHub Discussions 7 | url: https://github.com/sct/overseerr/discussions 8 | about: Ask questions and discuss with other community members 9 | -------------------------------------------------------------------------------- /server/entity/Session.ts: -------------------------------------------------------------------------------- 1 | import { ISession } from 'connect-typeorm'; 2 | import { Index, Column, PrimaryColumn, Entity } from 'typeorm'; 3 | 4 | @Entity() 5 | export class Session implements ISession { 6 | @Index() 7 | @Column('bigint') 8 | public expiredAt = Date.now(); 9 | 10 | @PrimaryColumn('varchar', { length: 255 }) 11 | public id = ''; 12 | 13 | @Column('text') 14 | public json = ''; 15 | } 16 | -------------------------------------------------------------------------------- /docs/using-overseerr/notifications/slack.md: -------------------------------------------------------------------------------- 1 | # Slack 2 | 3 | ## Configuration 4 | 5 | ### Webhook URL 6 | 7 | Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field. 8 | 9 | {% hint style="info" %} 10 | Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications. 11 | {% endhint %} 12 | -------------------------------------------------------------------------------- /src/pages/users/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import UserList from '../../components/UserList'; 4 | import useRouteGuard from '../../hooks/useRouteGuard'; 5 | import { Permission } from '../../hooks/useUser'; 6 | 7 | const UsersPage: NextPage = () => { 8 | useRouteGuard(Permission.MANAGE_USERS); 9 | return ; 10 | }; 11 | 12 | export default UsersPage; 13 | -------------------------------------------------------------------------------- /src/utils/numberHelpers.ts: -------------------------------------------------------------------------------- 1 | export const formatBytes = (bytes: number, decimals = 2): string => { 2 | if (bytes === 0) return '0 Bytes'; 3 | 4 | const k = 1024; 5 | const dm = decimals < 0 ? 0 : decimals; 6 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 7 | 8 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 9 | 10 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 11 | }; 12 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignoreFiles: ['**/*.js'], 3 | rules: { 4 | 'at-rule-no-unknown': [ 5 | true, 6 | { 7 | ignoreAtRules: [ 8 | 'tailwind', 9 | 'apply', 10 | 'variants', 11 | 'responsive', 12 | 'screen', 13 | ], 14 | }, 15 | ], 16 | 'declaration-block-trailing-semicolon': null, 17 | 'no-descending-specificity': null, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/.gitkeep 3 | **/.vscode 4 | .all-contributorsrc 5 | .dockerignore 6 | .editorconfig 7 | .eslintrc.js 8 | .git 9 | .gitbook.yaml 10 | .gitconfig 11 | .github 12 | .gitignore 13 | .next 14 | .prettierignore 15 | config/db/* 16 | config/logs/* 17 | config/*.json 18 | dist 19 | Dockerfile* 20 | docker-compose.yml 21 | docs 22 | LICENSE 23 | node_modules 24 | public/os_logo_filled.png 25 | public/preview.jpg 26 | snap 27 | stylelint.config.js 28 | -------------------------------------------------------------------------------- /server/utils/appDataVolume.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import path from 'path'; 3 | 4 | const CONFIG_PATH = process.env.CONFIG_DIRECTORY 5 | ? process.env.CONFIG_DIRECTORY 6 | : path.join(__dirname, '../../config'); 7 | 8 | const DOCKER_PATH = `${CONFIG_PATH}/DOCKER`; 9 | 10 | export const appDataStatus = (): boolean => { 11 | return !existsSync(DOCKER_PATH); 12 | }; 13 | 14 | export const appDataPath = (): string => { 15 | return CONFIG_PATH; 16 | }; 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | 3 | # 4 | ## These files are binary and should be left untouched 5 | # 6 | 7 | # (binary is a macro for -text -diff) 8 | *.png binary 9 | *.jpg binary 10 | *.jpeg binary 11 | *.gif binary 12 | *.ico binary 13 | *.mov binary 14 | *.mp4 binary 15 | *.mp3 binary 16 | *.flv binary 17 | *.fla binary 18 | *.swf binary 19 | *.gz binary 20 | *.zip binary 21 | *.7z binary 22 | *.ttf binary 23 | *.eot binary 24 | *.woff binary 25 | *.pyc binary 26 | *.pdf binary 27 | -------------------------------------------------------------------------------- /src/pages/profile/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../components/UserProfile/UserSettings'; 4 | import UserGeneralSettings from '../../../components/UserProfile/UserSettings/UserGeneralSettings'; 5 | 6 | const UserSettingsPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default UserSettingsPage; 15 | -------------------------------------------------------------------------------- /src/pages/profile/settings/password.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../components/UserProfile/UserSettings'; 4 | import UserPasswordChange from '../../../components/UserProfile/UserSettings/UserPasswordChange'; 5 | 6 | const UserPassswordPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default UserPassswordPage; 15 | -------------------------------------------------------------------------------- /src/pages/profile/settings/permissions.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../components/UserProfile/UserSettings'; 4 | import UserPermissions from '../../../components/UserProfile/UserSettings/UserPermissions'; 5 | 6 | const UserPermissionsPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default UserPermissionsPage; 15 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | 4 | return { 5 | presets: [ 6 | [ 7 | 'next/babel', 8 | { 9 | 'preset-env': { 10 | useBuiltIns: 'entry', 11 | corejs: '3', 12 | }, 13 | }, 14 | ], 15 | ], 16 | plugins: [ 17 | [ 18 | 'react-intl-auto', 19 | { 20 | removePrefix: 'src/', 21 | }, 22 | ], 23 | ], 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/pages/profile/settings/main.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../components/UserProfile/UserSettings'; 4 | import UserGeneralSettings from '../../../components/UserProfile/UserSettings/UserGeneralSettings'; 5 | 6 | const UserSettingsMainPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default UserSettingsMainPage; 15 | -------------------------------------------------------------------------------- /src/components/Layout/Notifications/index.tsx: -------------------------------------------------------------------------------- 1 | import { BellIcon } from '@heroicons/react/outline'; 2 | import React from 'react'; 3 | 4 | const Notifications: React.FC = () => { 5 | return ( 6 | 12 | ); 13 | }; 14 | 15 | export default Notifications; 16 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/requests.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import RequestList from '../../../components/RequestList'; 4 | import useRouteGuard from '../../../hooks/useRouteGuard'; 5 | import { Permission } from '../../../hooks/useUser'; 6 | 7 | const UserRequestsPage: NextPage = () => { 8 | useRouteGuard([Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], { 9 | type: 'or', 10 | }); 11 | return ; 12 | }; 13 | 14 | export default UserRequestsPage; 15 | -------------------------------------------------------------------------------- /src/assets/extlogos/lunasea.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/TitleCard/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface PlaceholderProps { 4 | canExpand?: boolean; 5 | } 6 | 7 | const Placeholder: React.FC = ({ canExpand = false }) => { 8 | return ( 9 |
14 |
15 |
16 | ); 17 | }; 18 | 19 | export default Placeholder; 20 | -------------------------------------------------------------------------------- /server/utils/typeHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TmdbMovieResult, 3 | TmdbTvResult, 4 | TmdbPersonResult, 5 | } from '../api/themoviedb/interfaces'; 6 | 7 | export const isMovie = ( 8 | movie: TmdbMovieResult | TmdbTvResult | TmdbPersonResult 9 | ): movie is TmdbMovieResult => { 10 | return (movie as TmdbMovieResult).title !== undefined; 11 | }; 12 | 13 | export const isPerson = ( 14 | person: TmdbMovieResult | TmdbTvResult | TmdbPersonResult 15 | ): person is TmdbPersonResult => { 16 | return (person as TmdbPersonResult).known_for !== undefined; 17 | }; 18 | -------------------------------------------------------------------------------- /src/assets/extlogos/pushbullet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/context/InteractionContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useInteraction from '../hooks/useInteraction'; 3 | 4 | interface InteractionContextProps { 5 | isTouch: boolean; 6 | } 7 | 8 | export const InteractionContext = React.createContext({ 9 | isTouch: false, 10 | }); 11 | 12 | export const InteractionProvider: React.FC = ({ children }) => { 13 | const isTouch = useInteraction(); 14 | 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/pages/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 4 | import SettingsMain from '../../components/Settings/SettingsMain'; 5 | import useRouteGuard from '../../hooks/useRouteGuard'; 6 | import { Permission } from '../../hooks/useUser'; 7 | 8 | const SettingsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default SettingsPage; 18 | -------------------------------------------------------------------------------- /.github/workflows/invalid_template.yml: -------------------------------------------------------------------------------- 1 | name: 'Invalid Template' 2 | 3 | on: 4 | issues: 5 | types: [labeled, unlabeled, reopened] 6 | 7 | jobs: 8 | support: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: dessant/support-requests@v2.0.1 12 | with: 13 | github-token: ${{ github.token }} 14 | support-label: 'invalid:template-incomplete' 15 | issue-comment: > 16 | :wave: @{issue-author}, please follow the template provided. 17 | close-issue: true 18 | lock-issue: true 19 | issue-lock-reason: 'resolved' 20 | -------------------------------------------------------------------------------- /src/pages/settings/logs.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 4 | import SettingsLogs from '../../components/Settings/SettingsLogs'; 5 | import useRouteGuard from '../../hooks/useRouteGuard'; 6 | import { Permission } from '../../hooks/useUser'; 7 | 8 | const SettingsLogsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default SettingsLogsPage; 18 | -------------------------------------------------------------------------------- /src/pages/settings/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 4 | import SettingsMain from '../../components/Settings/SettingsMain'; 5 | import { Permission } from '../../hooks/useUser'; 6 | import useRouteGuard from '../../hooks/useRouteGuard'; 7 | 8 | const SettingsMainPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default SettingsMainPage; 18 | -------------------------------------------------------------------------------- /src/pages/settings/about.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import SettingsAbout from '../../components/Settings/SettingsAbout'; 4 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 5 | import useRouteGuard from '../../hooks/useRouteGuard'; 6 | import { Permission } from '../../hooks/useUser'; 7 | 8 | const SettingsAboutPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default SettingsAboutPage; 18 | -------------------------------------------------------------------------------- /src/pages/settings/plex.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 4 | import SettingsPlex from '../../components/Settings/SettingsPlex'; 5 | import { Permission } from '../../hooks/useUser'; 6 | import useRouteGuard from '../../hooks/useRouteGuard'; 7 | 8 | const PlexSettingsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default PlexSettingsPage; 18 | -------------------------------------------------------------------------------- /src/pages/settings/users.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { NextPage } from 'next'; 3 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 4 | import SettingsUsers from '../../components/Settings/SettingsUsers'; 5 | import { Permission } from '../../hooks/useUser'; 6 | import useRouteGuard from '../../hooks/useRouteGuard'; 7 | 8 | const SettingsUsersPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default SettingsUsersPage; 18 | -------------------------------------------------------------------------------- /src/assets/extlogos/telegram.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/settings/jobs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 4 | import SettingsJobs from '../../components/Settings/SettingsJobsCache'; 5 | import { Permission } from '../../hooks/useUser'; 6 | import useRouteGuard from '../../hooks/useRouteGuard'; 7 | 8 | const SettingsMainPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default SettingsMainPage; 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'awaiting-triage, type:enhancement' 6 | assignees: '' 7 | --- 8 | 9 | #### Description 10 | 11 | Is your feature request related to a problem? If so, please provide a clear and concise description of the problem. E.g., "I'm always frustrated when [...]." 12 | 13 | #### Desired Behavior 14 | 15 | Provide a clear and concise description of what you want to happen. 16 | 17 | #### Additional Context 18 | 19 | Provide any additional information or screenshots that may be relevant or helpful. 20 | -------------------------------------------------------------------------------- /src/pages/settings/jellyfin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 4 | import SettingsJellyfin from '../../components/Settings/SettingsJellyfin'; 5 | import { Permission } from '../../hooks/useUser'; 6 | import useRouteGuard from '../../hooks/useRouteGuard'; 7 | 8 | const JellyfinSettingsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default JellyfinSettingsPage; 18 | -------------------------------------------------------------------------------- /src/pages/settings/services.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { NextPage } from 'next'; 3 | import SettingsLayout from '../../components/Settings/SettingsLayout'; 4 | import SettingsServices from '../../components/Settings/SettingsServices'; 5 | import { Permission } from '../../hooks/useUser'; 6 | import useRouteGuard from '../../hooks/useRouteGuard'; 7 | 8 | const ServicesSettingsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_SETTINGS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default ServicesSettingsPage; 18 | -------------------------------------------------------------------------------- /src/hooks/useRouteGuard.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | import { Permission, PermissionCheckOptions, useUser } from './useUser'; 4 | 5 | const useRouteGuard = ( 6 | permission: Permission | Permission[], 7 | options?: PermissionCheckOptions 8 | ): void => { 9 | const router = useRouter(); 10 | const { user, hasPermission } = useUser(); 11 | 12 | useEffect(() => { 13 | if (user && !hasPermission(permission, options)) { 14 | router.push('/'); 15 | } 16 | }, [user, permission, router, hasPermission, options]); 17 | }; 18 | 19 | export default useRouteGuard; 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact" 8 | ], 9 | "typescript.tsdk": "node_modules/typescript/lib", 10 | "sqltools.connections": [ 11 | { 12 | "previewLimit": 50, 13 | "driver": "SQLite", 14 | "name": "Local SQLite", 15 | "database": "./config/db/db.sqlite3" 16 | } 17 | ], 18 | "i18n-ally.localesPaths": ["src/i18n", "src/i18n/locale"], 19 | "editor.codeActionsOnSave": { 20 | "source.organizeImports": true 21 | }, 22 | "editor.formatOnSave": true 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Common/PageTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useSettings from '../../../hooks/useSettings'; 3 | import Head from 'next/head'; 4 | 5 | interface PageTitleProps { 6 | title: string | (string | undefined)[]; 7 | } 8 | 9 | const PageTitle: React.FC = ({ title }) => { 10 | const settings = useSettings(); 11 | 12 | return ( 13 | 14 | 15 | {Array.isArray(title) ? title.filter(Boolean).join(' - ') : title} -{' '} 16 | {settings.currentSettings.applicationTitle} 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default PageTitle; 23 | -------------------------------------------------------------------------------- /src/components/ToastContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ToastContainerProps } from 'react-toast-notifications'; 3 | 4 | const ToastContainer: React.FC = ({ 5 | hasToasts, 6 | ...props 7 | }) => { 8 | return ( 9 |
19 | ); 20 | }; 21 | 22 | export default ToastContainer; 23 | -------------------------------------------------------------------------------- /.github/workflows/deploy_docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy API Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Generate Swagger UI 14 | uses: Legion2/swagger-ui-action@v1.1.2 15 | with: 16 | output: swagger-ui 17 | spec-file: overseerr-api.yml 18 | - name: Deploy to GitHub Pages 19 | uses: peaceiris/actions-gh-pages@v3.8.0 20 | with: 21 | github_token: ${{ secrets.GITHUB_TOKEN }} 22 | publish_dir: swagger-ui 23 | cname: api-docs.overseerr.dev 24 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../components/UserProfile/UserSettings'; 4 | import UserGeneralSettings from '../../../../components/UserProfile/UserSettings/UserGeneralSettings'; 5 | import useRouteGuard from '../../../../hooks/useRouteGuard'; 6 | import { Permission } from '../../../../hooks/useUser'; 7 | 8 | const UserSettingsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default UserSettingsPage; 18 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/permissions.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../components/UserProfile/UserSettings'; 4 | import UserPermissions from '../../../../components/UserProfile/UserSettings/UserPermissions'; 5 | import useRouteGuard from '../../../../hooks/useRouteGuard'; 6 | import { Permission } from '../../../../hooks/useUser'; 7 | 8 | const UserPermissionsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default UserPermissionsPage; 18 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/password.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../components/UserProfile/UserSettings'; 4 | import UserPasswordChange from '../../../../components/UserProfile/UserSettings/UserPasswordChange'; 5 | import useRouteGuard from '../../../../hooks/useRouteGuard'; 6 | import { Permission } from '../../../../hooks/useUser'; 7 | 8 | const UserPassswordPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default UserPassswordPage; 18 | -------------------------------------------------------------------------------- /server/interfaces/api/userInterfaces.ts: -------------------------------------------------------------------------------- 1 | import { MediaRequest } from '../../entity/MediaRequest'; 2 | import type { User } from '../../entity/User'; 3 | import { PaginatedResponse } from './common'; 4 | 5 | export interface UserResultsResponse extends PaginatedResponse { 6 | results: User[]; 7 | } 8 | 9 | export interface UserRequestsResponse extends PaginatedResponse { 10 | results: MediaRequest[]; 11 | } 12 | 13 | export interface QuotaStatus { 14 | days?: number; 15 | limit?: number; 16 | used: number; 17 | remaining?: number; 18 | restricted: boolean; 19 | } 20 | 21 | export interface QuotaResponse { 22 | movie: QuotaStatus; 23 | tv: QuotaStatus; 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/main.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../components/UserProfile/UserSettings'; 4 | import UserGeneralSettings from '../../../../components/UserProfile/UserSettings/UserGeneralSettings'; 5 | import useRouteGuard from '../../../../hooks/useRouteGuard'; 6 | import { Permission } from '../../../../hooks/useUser'; 7 | 8 | const UserSettingsMainPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default UserSettingsMainPage; 18 | -------------------------------------------------------------------------------- /src/utils/creditHelpers.ts: -------------------------------------------------------------------------------- 1 | import { Crew } from '../../server/models/common'; 2 | const priorityJobs = [ 3 | 'Director', 4 | 'Creator', 5 | 'Screenplay', 6 | 'Writer', 7 | 'Composer', 8 | 'Editor', 9 | 'Producer', 10 | 'Co-Producer', 11 | 'Executive Producer', 12 | 'Animation', 13 | ]; 14 | 15 | export const sortCrewPriority = (crew: Crew[]): Crew[] => { 16 | return crew 17 | .filter((person) => priorityJobs.includes(person.job)) 18 | .sort((a, b) => { 19 | const aScore = priorityJobs.findIndex((job) => job.includes(a.job)); 20 | const bScore = priorityJobs.findIndex((job) => job.includes(b.job)); 21 | 22 | return aScore - bScore; 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /docs/using-overseerr/notifications/lunasea.md: -------------------------------------------------------------------------------- 1 | # LunaSea 2 | 3 | ## Configuration 4 | 5 | ### Webhook URL 6 | 7 | Copy either a device- or user-based webhook URL from the LunaSea app into this field. 8 | 9 | ### Profile Name (optional) 10 | 11 | If not using the `default` profile in the LunaSea app, specify the name of the profile here. 12 | 13 | Note that the entered profile name **_must_** match the name in LunaSea exactly (including any capitalization, punctuation, and/or whitespace). 14 | 15 | {% hint style="info" %} 16 | Please refer to the [LunaSea documentation](https://docs.lunasea.app/lunasea/notifications/overseerr) for more details on configuring these notifications. 17 | {% endhint %} 18 | -------------------------------------------------------------------------------- /src/components/Common/CachedImage/index.tsx: -------------------------------------------------------------------------------- 1 | import Image, { ImageProps } from 'next/image'; 2 | import React from 'react'; 3 | import useSettings from '../../../hooks/useSettings'; 4 | 5 | /** 6 | * The CachedImage component should be used wherever 7 | * we want to offer the option to locally cache images. 8 | * 9 | * It uses the `next/image` Image component but overrides 10 | * the `unoptimized` prop based on the application setting `cacheImages`. 11 | **/ 12 | const CachedImage: React.FC = (props) => { 13 | const { currentSettings } = useSettings(); 14 | 15 | return ; 16 | }; 17 | 18 | export default CachedImage; 19 | -------------------------------------------------------------------------------- /docs/extending-overseerr/fail2ban.md: -------------------------------------------------------------------------------- 1 | # Fail2ban Filter 2 | 3 | {% hint style="warning" %} 4 | If you are running Overseerr behind a reverse proxy, make sure that the **Enable Proxy Support** setting is **enabled**. 5 | {% endhint %} 6 | 7 | To use Fail2ban with Overseerr, create a new file named `overseerr.local` in your Fail2ban `filter.d` directory with the following filter definition: 8 | 9 | ``` 10 | [Definition] 11 | failregex = .*\[info\]\[Auth\]\: Failed sign-in attempt.*"ip":"" 12 | ``` 13 | 14 | You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. 15 | -------------------------------------------------------------------------------- /server/entity/UserPushSubscription.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { User } from './User'; 3 | 4 | @Entity() 5 | export class UserPushSubscription { 6 | @PrimaryGeneratedColumn() 7 | public id: number; 8 | 9 | @ManyToOne(() => User, (user) => user.pushSubscriptions, { 10 | eager: true, 11 | onDelete: 'CASCADE', 12 | }) 13 | public user: User; 14 | 15 | @Column() 16 | public endpoint: string; 17 | 18 | @Column() 19 | public p256dh: string; 20 | 21 | @Column({ unique: true }) 22 | public auth: string; 23 | 24 | constructor(init?: Partial) { 25 | Object.assign(this, init); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/migration/1607928251245-DropImdbIdConstraint.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner, TableUnique } from 'typeorm'; 2 | 3 | export class DropImdbIdConstraint1607928251245 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.dropUniqueConstraint( 6 | 'media', 7 | 'UQ_7ff2d11f6a83cb52386eaebe74b' 8 | ); 9 | } 10 | 11 | public async down(queryRunner: QueryRunner): Promise { 12 | await queryRunner.createUniqueConstraint( 13 | 'media', 14 | new TableUnique({ 15 | name: 'UQ_7ff2d11f6a83cb52386eaebe74b', 16 | columnNames: ['imdbId'], 17 | }) 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.svg' { 3 | const content: any; 4 | export default content; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const content: any; 9 | export default content; 10 | } 11 | declare module '*.jpeg' { 12 | const content: any; 13 | export default content; 14 | } 15 | 16 | declare module '*.gif' { 17 | const content: any; 18 | export default content; 19 | } 20 | 21 | declare module '*.png' { 22 | const content: any; 23 | export default content; 24 | } 25 | 26 | declare module '*.css' { 27 | interface IClassNames { 28 | [className: string]: string; 29 | } 30 | const classNames: IClassNames; 31 | export = classNames; 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/typeHelpers.ts: -------------------------------------------------------------------------------- 1 | export type Undefinable = T | undefined; 2 | export type Nullable = T | null; 3 | export type Maybe = T | null | undefined; 4 | 5 | /** 6 | * Helps type objects with an arbitrary number of properties that are 7 | * usually being defined at export. 8 | * 9 | * @param component Main object you want to apply properties to 10 | * @param properties Object of properties you want to type on the main component 11 | */ 12 | export function withProperties(component: A, properties: B): A & B { 13 | (Object.keys(properties) as (keyof B)[]).forEach((key) => { 14 | Object.assign(component, { [key]: properties[key] }); 15 | }); 16 | return component as A & B; 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/email.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../components/UserProfile/UserSettings'; 4 | import UserNotificationSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings'; 5 | import UserNotificationsEmail from '../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail'; 6 | 7 | const NotificationsPage: NextPage = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default NotificationsPage; 18 | -------------------------------------------------------------------------------- /src/assets/services/radarr.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/discord.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../components/UserProfile/UserSettings'; 4 | import UserNotificationSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings'; 5 | import UserNotificationsDiscord from '../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord'; 6 | 7 | const NotificationsPage: NextPage = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default NotificationsPage; 18 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/telegram.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../components/UserProfile/UserSettings'; 4 | import UserNotificationSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings'; 5 | import UserNotificationsTelegram from '../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram'; 6 | 7 | const NotificationsPage: NextPage = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default NotificationsPage; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "strictPropertyInitialization": false, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "useUnknownInCatchVariables": false 20 | }, 21 | "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/webpush.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../components/UserProfile/UserSettings'; 4 | import UserNotificationSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings'; 5 | import UserWebPushSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush'; 6 | 7 | const WebPushProfileNotificationsPage: NextPage = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default WebPushProfileNotificationsPage; 18 | -------------------------------------------------------------------------------- /server/migration/1605085519544-SeasonStatus.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class SeasonStatus1605085519544 implements MigrationInterface { 4 | name = 'SeasonStatus1605085519544'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TABLE "season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer)` 9 | ); 10 | } 11 | 12 | public async down(queryRunner: QueryRunner): Promise { 13 | await queryRunner.query(`DROP TABLE "season"`); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | - dependencies 10 | # Label to use when marking an issue as stale 11 | staleLabel: stale 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: false 19 | -------------------------------------------------------------------------------- /docs/using-overseerr/notifications/pushover.md: -------------------------------------------------------------------------------- 1 | # Pushover 2 | 3 | ## Configuration 4 | 5 | ### Application/API Token 6 | 7 | [Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/sct/overseerr/tree/develop/public) when configuring the application.) 8 | 9 | For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration). 10 | 11 | ### User Key 12 | 13 | Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users. 14 | 15 | For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers). 16 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/email.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsEmail from '../../../components/Settings/Notifications/NotificationsEmail'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/discord.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsDiscord from '../../../components/Settings/Notifications/NotificationsDiscord'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/lunasea.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsLunaSea from '../../../components/Settings/Notifications/NotificationsLunaSea'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/pushover.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsPushover from '../../../components/Settings/Notifications/NotificationsPushover'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/slack.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsSlack from '../../../components/Settings/Notifications/NotificationsSlack'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsSlackPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsSlackPage; 21 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/telegram.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsTelegram from '../../../components/Settings/Notifications/NotificationsTelegram'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/webhook.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsWebhook from '../../../components/Settings/Notifications/NotificationsWebhook'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # next.js 10 | /.next/ 11 | /out/ 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env.local 27 | .env.development.local 28 | .env.test.local 29 | .env.production.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # database 35 | config/db/*.sqlite3* 36 | config/settings.json 37 | 38 | # logs 39 | config/logs/*.log* 40 | config/logs/*.json 41 | config/logs/*.log.gz 42 | config/logs/*-audit.json 43 | 44 | # anidb mapping file 45 | config/anime-list.xml 46 | 47 | # dist files 48 | dist 49 | 50 | # sqlite journal 51 | config/db/db.sqlite3-journal 52 | 53 | # VS Code 54 | .vscode/launch.json 55 | -------------------------------------------------------------------------------- /server/interfaces/api/serviceInterfaces.ts: -------------------------------------------------------------------------------- 1 | import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base'; 2 | import { LanguageProfile } from '../../api/servarr/sonarr'; 3 | 4 | export interface ServiceCommonServer { 5 | id: number; 6 | name: string; 7 | is4k: boolean; 8 | isDefault: boolean; 9 | activeProfileId: number; 10 | activeDirectory: string; 11 | activeLanguageProfileId?: number; 12 | activeAnimeProfileId?: number; 13 | activeAnimeDirectory?: string; 14 | activeAnimeLanguageProfileId?: number; 15 | activeTags: number[]; 16 | activeAnimeTags?: number[]; 17 | } 18 | 19 | export interface ServiceCommonServerWithDetails { 20 | server: ServiceCommonServer; 21 | profiles: QualityProfile[]; 22 | rootFolders: Partial[]; 23 | languageProfiles?: LanguageProfile[]; 24 | tags: Tag[]; 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/pushbullet.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsPushbullet from '../../../components/Settings/Notifications/NotificationsPushbullet'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/webpush.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import NotificationsWebPush from '../../../components/Settings/Notifications/NotificationsWebPush'; 4 | import SettingsLayout from '../../../components/Settings/SettingsLayout'; 5 | import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; 6 | import useRouteGuard from '../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../hooks/useUser'; 8 | 9 | const NotificationsWebPushPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_SETTINGS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsWebPushPage; 21 | -------------------------------------------------------------------------------- /server/types/express.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import type { NextFunction, Request, Response } from 'express'; 3 | import type { User } from '../entity/User'; 4 | 5 | declare global { 6 | namespace Express { 7 | export interface Request { 8 | user?: User; 9 | locale?: string; 10 | } 11 | } 12 | 13 | export type Middleware = ( 14 | req: Request, 15 | res: Response, 16 | next: NextFunction 17 | ) => Promise | void | NextFunction; 18 | } 19 | 20 | // Declaration merging to apply our own types to SessionData 21 | // See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23) 22 | declare module 'express-session' { 23 | export interface SessionData { 24 | userId: number; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/utils/appVersion.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import path from 'path'; 3 | import logger from '../logger'; 4 | 5 | const COMMIT_TAG_PATH = path.join(__dirname, '../../committag.json'); 6 | let commitTag = 'local'; 7 | 8 | if (existsSync(COMMIT_TAG_PATH)) { 9 | // eslint-disable-next-line @typescript-eslint/no-var-requires 10 | commitTag = require(COMMIT_TAG_PATH).commitTag; 11 | logger.info(`Commit Tag: ${commitTag}`); 12 | } 13 | 14 | export const getCommitTag = (): string => { 15 | return commitTag; 16 | }; 17 | 18 | export const getAppVersion = (): string => { 19 | // eslint-disable-next-line @typescript-eslint/no-var-requires 20 | const { version } = require('../../package.json'); 21 | 22 | let finalVersion = version; 23 | 24 | if (version === '0.1.0') { 25 | finalVersion = `develop-${getCommitTag()}`; 26 | } 27 | 28 | return finalVersion; 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/Common/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface HeaderProps { 4 | extraMargin?: number; 5 | subtext?: React.ReactNode; 6 | } 7 | 8 | const Header: React.FC = ({ 9 | children, 10 | extraMargin = 0, 11 | subtext, 12 | }) => { 13 | return ( 14 |
15 |
16 |

17 | 18 | {children} 19 | 20 |

21 | {subtext &&
{subtext}
} 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default Header; 28 | -------------------------------------------------------------------------------- /server/routes/collection.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import TheMovieDb from '../api/themoviedb'; 3 | import Media from '../entity/Media'; 4 | import { mapCollection } from '../models/Collection'; 5 | 6 | const collectionRoutes = Router(); 7 | 8 | collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { 9 | const tmdb = new TheMovieDb(); 10 | 11 | try { 12 | const collection = await tmdb.getCollection({ 13 | collectionId: Number(req.params.id), 14 | language: req.locale ?? (req.query.language as string), 15 | }); 16 | 17 | const media = await Media.getRelatedMedia( 18 | collection.parts.map((part) => part.id) 19 | ); 20 | 21 | return res.status(200).json(mapCollection(collection, media)); 22 | } catch (e) { 23 | return next({ status: 404, message: 'Collection does not exist' }); 24 | } 25 | }); 26 | 27 | export default collectionRoutes; 28 | -------------------------------------------------------------------------------- /docs/using-overseerr/notifications/discord.md: -------------------------------------------------------------------------------- 1 | # Discord 2 | 3 | The Discord notification agent enables you to post notifications to a channel in a server you manage. 4 | 5 | {% hint style="info" %} 6 | Users can optionally opt-in to being mentioned in Discord notifications by configuring their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) in their user settings. 7 | {% endhint %} 8 | 9 | ## Configuration 10 | 11 | ### Webhook URL 12 | 13 | You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**. 14 | 15 | ### Bot Username (optional) 16 | 17 | If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like! 18 | 19 | ### Bot Avatar URL (optional) 20 | 21 | Similar to the bot username, you can override the avatar for your bot. 22 | -------------------------------------------------------------------------------- /.github/workflows/support.yml: -------------------------------------------------------------------------------- 1 | name: 'Support requests' 2 | 3 | on: 4 | issues: 5 | types: [labeled, unlabeled, reopened] 6 | 7 | jobs: 8 | support: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: dessant/support-requests@v2.0.1 12 | with: 13 | github-token: ${{ github.token }} 14 | support-label: 'support' 15 | issue-comment: > 16 | :wave: @{issue-author}, we use the issue tracker exclusively 17 | for bug reports and feature requests. However, this issue appears 18 | to be a support request. Please use our support channels 19 | to get help with Overseerr. 20 | 21 | - [Discord](https://discord.gg/overseerr) 22 | 23 | - [GitHub Discussions](https://github.com/sct/overseerr/discussions) 24 | close-issue: true 25 | lock-issue: true 26 | issue-lock-reason: 'off-topic' 27 | -------------------------------------------------------------------------------- /server/routes/search.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import TheMovieDb from '../api/themoviedb'; 3 | import Media from '../entity/Media'; 4 | import { mapSearchResults } from '../models/Search'; 5 | 6 | const searchRoutes = Router(); 7 | 8 | searchRoutes.get('/', async (req, res) => { 9 | const tmdb = new TheMovieDb(); 10 | 11 | const results = await tmdb.searchMulti({ 12 | query: req.query.query as string, 13 | page: Number(req.query.page), 14 | language: req.locale ?? (req.query.language as string), 15 | }); 16 | 17 | const media = await Media.getRelatedMedia( 18 | results.results.map((result) => result.id) 19 | ); 20 | 21 | return res.status(200).json({ 22 | page: results.page, 23 | totalPages: results.total_pages, 24 | totalResults: results.total_results, 25 | results: mapSearchResults(results.results, media), 26 | }); 27 | }); 28 | 29 | export default searchRoutes; 30 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/email.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../../components/UserProfile/UserSettings'; 4 | import UserNotificationSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings'; 5 | import UserNotificationsEmail from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail'; 6 | import useRouteGuard from '../../../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_USERS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/discord.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../../components/UserProfile/UserSettings'; 4 | import UserNotificationSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings'; 5 | import UserNotificationsDiscord from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord'; 6 | import useRouteGuard from '../../../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_USERS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/hooks/useLockBodyScroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | /** 4 | * Hook to lock the body scroll whenever a component is mounted or 5 | * whenever isLocked is set to true. 6 | * 7 | * You can pass in true always to cause a lock on mount/dismount of the component 8 | * using this hook. 9 | * 10 | * @param isLocked Toggle the scroll lock 11 | * @param disabled Disables the entire hook (allows conditional skipping of the lock) 12 | */ 13 | export const useLockBodyScroll = ( 14 | isLocked: boolean, 15 | disabled?: boolean 16 | ): void => { 17 | useEffect(() => { 18 | const originalStyle = window.getComputedStyle(document.body).overflow; 19 | if (isLocked && !disabled) { 20 | document.body.style.overflow = 'hidden'; 21 | } 22 | return () => { 23 | if (!disabled) { 24 | document.body.style.overflow = originalStyle; 25 | } 26 | }; 27 | }, [isLocked, disabled]); 28 | }; 29 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/telegram.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../../components/UserProfile/UserSettings'; 4 | import UserNotificationSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings'; 5 | import UserNotificationsTelegram from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram'; 6 | import useRouteGuard from '../../../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../../../hooks/useUser'; 8 | 9 | const NotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_USERS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default NotificationsPage; 21 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/webpush.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import React from 'react'; 3 | import UserSettings from '../../../../../components/UserProfile/UserSettings'; 4 | import UserNotificationSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings'; 5 | import UserWebPushSettings from '../../../../../components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush'; 6 | import useRouteGuard from '../../../../../hooks/useRouteGuard'; 7 | import { Permission } from '../../../../../hooks/useUser'; 8 | 9 | const WebPushNotificationsPage: NextPage = () => { 10 | useRouteGuard(Permission.MANAGE_USERS); 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default WebPushNotificationsPage; 21 | -------------------------------------------------------------------------------- /docs/extending-overseerr/third-party.md: -------------------------------------------------------------------------------- 1 | # Third-Party Integrations 2 | 3 | {% hint style="warning" %} 4 | We do not officially support these third-party integrations. If you run into any issues, please seek help on the appropriate support channels for the integration itself! 5 | {% endhint %} 6 | 7 | - [Organizr](https://organizr.app/), a HTPC/homelab services organizer 8 | - [Heimdall](https://github.com/linuxserver/Heimdall), an application dashboard and launcher 9 | - [LunaSea](https://docs.lunasea.app/modules/overseerr), a self-hosted controller for mobile and macOS 10 | - [Requestrr](https://github.com/darkalfx/requestrr/wiki/Configuring-Overseerr), a Discord chatbot 11 | - [ha-overseerr](https://github.com/vaparr/ha-overseerr), a custom Home Assistant component 12 | - [OverCLIrr](https://github.com/WillFantom/OverCLIrr), a command-line tool 13 | - [Overseerr Exporter](https://github.com/WillFantom/overseerr-exporter), a Prometheus exporter 14 | -------------------------------------------------------------------------------- /server/lib/notifications/agents/agent.ts: -------------------------------------------------------------------------------- 1 | import { Notification } from '..'; 2 | import Media from '../../../entity/Media'; 3 | import { MediaRequest } from '../../../entity/MediaRequest'; 4 | import { User } from '../../../entity/User'; 5 | import { NotificationAgentConfig } from '../../settings'; 6 | 7 | export interface NotificationPayload { 8 | subject: string; 9 | notifyUser?: User; 10 | media?: Media; 11 | image?: string; 12 | message?: string; 13 | extra?: { name: string; value: string }[]; 14 | request?: MediaRequest; 15 | } 16 | 17 | export abstract class BaseAgent { 18 | protected settings?: T; 19 | public constructor(settings?: T) { 20 | this.settings = settings; 21 | } 22 | 23 | protected abstract getSettings(): T; 24 | } 25 | 26 | export interface NotificationAgent { 27 | shouldSend(): boolean; 28 | send(type: Notification, payload: NotificationPayload): Promise; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/JSONEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes } from 'react'; 2 | import AceEditor from 'react-ace'; 3 | import 'ace-builds/src-noconflict/mode-json'; 4 | import 'ace-builds/src-noconflict/theme-dracula'; 5 | 6 | interface JSONEditorProps extends HTMLAttributes { 7 | name: string; 8 | value: string; 9 | onUpdate: (value: string) => void; 10 | } 11 | 12 | const JSONEditor: React.FC = ({ 13 | name, 14 | value, 15 | onUpdate, 16 | onBlur, 17 | }) => { 18 | return ( 19 |
20 | 31 |
32 | ); 33 | }; 34 | 35 | export default JSONEditor; 36 | -------------------------------------------------------------------------------- /server/models/Collection.ts: -------------------------------------------------------------------------------- 1 | import type { TmdbCollection } from '../api/themoviedb/interfaces'; 2 | import { MediaType } from '../constants/media'; 3 | import Media from '../entity/Media'; 4 | import { mapMovieResult, MovieResult } from './Search'; 5 | 6 | export interface Collection { 7 | id: number; 8 | name: string; 9 | overview?: string; 10 | posterPath?: string; 11 | backdropPath?: string; 12 | parts: MovieResult[]; 13 | } 14 | 15 | export const mapCollection = ( 16 | collection: TmdbCollection, 17 | media: Media[] 18 | ): Collection => ({ 19 | id: collection.id, 20 | name: collection.name, 21 | overview: collection.overview, 22 | posterPath: collection.poster_path, 23 | backdropPath: collection.backdrop_path, 24 | parts: collection.parts.map((part) => 25 | mapMovieResult( 26 | part, 27 | media?.find( 28 | (req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE 29 | ) 30 | ) 31 | ), 32 | }); 33 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/index.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { NextPage } from 'next'; 3 | import React from 'react'; 4 | import type { TvDetails as TvDetailsType } from '../../../../server/models/Tv'; 5 | import TvDetails from '../../../components/TvDetails'; 6 | 7 | interface TvPageProps { 8 | tv?: TvDetailsType; 9 | } 10 | 11 | const TvPage: NextPage = ({ tv }) => { 12 | return ; 13 | }; 14 | 15 | TvPage.getInitialProps = async (ctx) => { 16 | if (ctx.req) { 17 | const response = await axios.get( 18 | `http://localhost:${process.env.PORT || 5055}/api/v1/tv/${ 19 | ctx.query.tvId 20 | }`, 21 | { 22 | headers: ctx.req?.headers?.cookie 23 | ? { cookie: ctx.req.headers.cookie } 24 | : undefined, 25 | } 26 | ); 27 | 28 | return { 29 | tv: response.data, 30 | }; 31 | } 32 | 33 | return {}; 34 | }; 35 | 36 | export default TvPage; 37 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | /** 4 | * useClickOutside 5 | * 6 | * Simple hook to add an event listener to the body and allow a callback to 7 | * be triggered when clicking outside of the target ref 8 | * 9 | * @param ref Any HTML Element ref 10 | * @param callback Callback triggered when clicking outside of ref element 11 | */ 12 | const useClickOutside = ( 13 | ref: React.RefObject, 14 | callback: (e: MouseEvent) => void 15 | ): void => { 16 | useEffect(() => { 17 | const handleBodyClick = (e: MouseEvent) => { 18 | if (ref.current && !ref.current.contains(e.target as Node)) { 19 | callback(e); 20 | } 21 | }; 22 | document.body.addEventListener('click', handleBodyClick, { capture: true }); 23 | 24 | return () => { 25 | document.body.removeEventListener('click', handleBodyClick); 26 | }; 27 | }, [ref, callback]); 28 | }; 29 | 30 | export default useClickOutside; 31 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // see 3 | // - https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions 4 | "recommendations": [ 5 | // https://marketplace.visualstudio.com/items?itemName=EditorConfig.editorconfig 6 | "EditorConfig.editorconfig", 7 | 8 | // https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint 9 | "dbaeumer.vscode-eslint", 10 | 11 | // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode 12 | "esbenp.prettier-vscode", 13 | 14 | // https://marketplace.visualstudio.com/items?itemName=eg2.vscode-npm-script 15 | "eg2.vscode-npm-script", 16 | 17 | // https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest 18 | "Orta.vscode-jest", 19 | 20 | "stylelint.vscode-stylelint", 21 | 22 | "bradlc.vscode-tailwindcss", 23 | 24 | // https://marketplace.visualstudio.com/items?itemName=heybourn.headwind 25 | "heybourn.headwind" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /server/entity/SeasonRequest.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | ManyToOne, 8 | } from 'typeorm'; 9 | import { MediaRequestStatus } from '../constants/media'; 10 | import { MediaRequest } from './MediaRequest'; 11 | 12 | @Entity() 13 | class SeasonRequest { 14 | @PrimaryGeneratedColumn() 15 | public id: number; 16 | 17 | @Column() 18 | public seasonNumber: number; 19 | 20 | @Column({ type: 'int', default: MediaRequestStatus.PENDING }) 21 | public status: MediaRequestStatus; 22 | 23 | @ManyToOne(() => MediaRequest, (request) => request.seasons, { 24 | onDelete: 'CASCADE', 25 | }) 26 | public request: MediaRequest; 27 | 28 | @CreateDateColumn() 29 | public createdAt: Date; 30 | 31 | @UpdateDateColumn() 32 | public updatedAt: Date; 33 | 34 | constructor(init?: Partial) { 35 | Object.assign(this, init); 36 | } 37 | } 38 | 39 | export default SeasonRequest; 40 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | DocumentContext, 3 | DocumentInitialProps, 4 | Head, 5 | Html, 6 | Main, 7 | NextScript, 8 | } from 'next/document'; 9 | import React from 'react'; 10 | 11 | class MyDocument extends Document { 12 | static async getInitialProps( 13 | ctx: DocumentContext 14 | ): Promise { 15 | const initialProps = await Document.getInitialProps(ctx); 16 | 17 | return initialProps; 18 | } 19 | 20 | render(): JSX.Element { 21 | return ( 22 | 23 | 24 | 25 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | ); 36 | } 37 | } 38 | 39 | export default MyDocument; 40 | -------------------------------------------------------------------------------- /server/entity/Season.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | ManyToOne, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm'; 9 | import { MediaStatus } from '../constants/media'; 10 | import Media from './Media'; 11 | 12 | @Entity() 13 | class Season { 14 | @PrimaryGeneratedColumn() 15 | public id: number; 16 | 17 | @Column() 18 | public seasonNumber: number; 19 | 20 | @Column({ type: 'int', default: MediaStatus.UNKNOWN }) 21 | public status: MediaStatus; 22 | 23 | @Column({ type: 'int', default: MediaStatus.UNKNOWN }) 24 | public status4k: MediaStatus; 25 | 26 | @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' }) 27 | public media: Promise; 28 | 29 | @CreateDateColumn() 30 | public createdAt: Date; 31 | 32 | @UpdateDateColumn() 33 | public updatedAt: Date; 34 | 35 | constructor(init?: Partial) { 36 | Object.assign(this, init); 37 | } 38 | } 39 | 40 | export default Season; 41 | -------------------------------------------------------------------------------- /server/types/plex-api.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'plex-api' { 2 | export default class PlexAPI { 3 | constructor(intiialOptions: { 4 | hostname: string; 5 | port: number; 6 | token?: string; 7 | https?: boolean; 8 | timeout?: number; 9 | authenticator: { 10 | authenticate: ( 11 | _plexApi: PlexAPI, 12 | cb: (err?: string, token?: string) => void 13 | ) => void; 14 | }; 15 | options: { 16 | identifier: string; 17 | product: string; 18 | deviceName: string; 19 | platform: string; 20 | }; 21 | requestOptions?: Record; 22 | }); 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | query: >( 25 | endpoint: 26 | | string 27 | | { 28 | uri: string; 29 | extraHeaders?: Record; 30 | } 31 | ) => Promise; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/assets/extlogos/slack.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/using-overseerr/notifications/README.md: -------------------------------------------------------------------------------- 1 | # Notifications 2 | 3 | ## Supported Notification Agents 4 | 5 | Overseerr currently supports the following notification agents: 6 | 7 | - [Email](./email.md) 8 | - [Web Push](./webpush.md) 9 | - [Discord](./discord.md) 10 | - [LunaSea](./lunasea.md) 11 | - [Pushbullet](./pushbullet.md) 12 | - [Pushover](./pushover.md) 13 | - [Slack](./slack.md) 14 | - [Telegram](./telegram.md) 15 | - [Webhooks](./webhooks.md) 16 | 17 | ## Setting Up Notifications 18 | 19 | Simply configure your desired notification agents in **Settings → Notifications**. 20 | 21 | Users can customize their personal notification preferences in their own user notification settings. 22 | 23 | ## Requesting New Notification Agents 24 | 25 | If we do not currently support your preferred notification agent, feel free to [submit a feature request on GitHub](https://github.com/sct/overseerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent! 26 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/index.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { NextPage } from 'next'; 3 | import React from 'react'; 4 | import type { MovieDetails as MovieDetailsType } from '../../../../server/models/Movie'; 5 | import MovieDetails from '../../../components/MovieDetails'; 6 | 7 | interface MoviePageProps { 8 | movie?: MovieDetailsType; 9 | } 10 | 11 | const MoviePage: NextPage = ({ movie }) => { 12 | return ; 13 | }; 14 | 15 | MoviePage.getInitialProps = async (ctx) => { 16 | if (ctx.req) { 17 | const response = await axios.get( 18 | `http://localhost:${process.env.PORT || 5055}/api/v1/movie/${ 19 | ctx.query.movieId 20 | }`, 21 | { 22 | headers: ctx.req?.headers?.cookie 23 | ? { cookie: ctx.req.headers.cookie } 24 | : undefined, 25 | } 26 | ); 27 | 28 | return { 29 | movie: response.data, 30 | }; 31 | } 32 | 33 | return {}; 34 | }; 35 | 36 | export default MoviePage; 37 | -------------------------------------------------------------------------------- /src/hooks/useDebouncedState.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, Dispatch, SetStateAction } from 'react'; 2 | 3 | /** 4 | * A hook to help with debouncing state 5 | * 6 | * This hook basically acts the same as useState except it is also 7 | * returning a deobuncedValue that can be used for things like 8 | * debouncing input into a search field 9 | * 10 | * @param initialValue Initial state value 11 | * @param debounceTime Debounce time in ms 12 | */ 13 | const useDebouncedState = ( 14 | initialValue: S, 15 | debounceTime = 300 16 | ): [S, S, Dispatch>] => { 17 | const [value, setValue] = useState(initialValue); 18 | const [finalValue, setFinalValue] = useState(initialValue); 19 | 20 | useEffect(() => { 21 | const timeout = setTimeout(() => { 22 | setFinalValue(value); 23 | }, debounceTime); 24 | 25 | return () => { 26 | clearTimeout(timeout); 27 | }; 28 | }, [value, debounceTime]); 29 | 30 | return [value, finalValue, setValue]; 31 | }; 32 | 33 | export default useDebouncedState; 34 | -------------------------------------------------------------------------------- /src/components/Common/Badge/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface BadgeProps { 4 | badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success'; 5 | className?: string; 6 | } 7 | 8 | const Badge: React.FC = ({ 9 | badgeType = 'default', 10 | className, 11 | children, 12 | }) => { 13 | const badgeStyle = [ 14 | 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full cursor-default', 15 | ]; 16 | 17 | switch (badgeType) { 18 | case 'danger': 19 | badgeStyle.push('bg-red-600 text-red-100'); 20 | break; 21 | case 'warning': 22 | badgeStyle.push('bg-yellow-500 text-yellow-100'); 23 | break; 24 | case 'success': 25 | badgeStyle.push('bg-green-500 text-green-100'); 26 | break; 27 | default: 28 | badgeStyle.push('bg-indigo-500 text-indigo-100'); 29 | } 30 | 31 | if (className) { 32 | badgeStyle.push(className); 33 | } 34 | 35 | return {children}; 36 | }; 37 | 38 | export default Badge; 39 | -------------------------------------------------------------------------------- /server/interfaces/api/userSettingsInterfaces.ts: -------------------------------------------------------------------------------- 1 | import { NotificationAgentKey } from '../../lib/settings'; 2 | 3 | export interface UserSettingsGeneralResponse { 4 | username?: string; 5 | locale?: string; 6 | region?: string; 7 | originalLanguage?: string; 8 | movieQuotaLimit?: number; 9 | movieQuotaDays?: number; 10 | tvQuotaLimit?: number; 11 | tvQuotaDays?: number; 12 | globalMovieQuotaDays?: number; 13 | globalMovieQuotaLimit?: number; 14 | globalTvQuotaLimit?: number; 15 | globalTvQuotaDays?: number; 16 | } 17 | 18 | export type NotificationAgentTypes = Record; 19 | export interface UserSettingsNotificationsResponse { 20 | emailEnabled?: boolean; 21 | pgpKey?: string; 22 | discordEnabled?: boolean; 23 | discordEnabledTypes?: number; 24 | discordId?: string; 25 | telegramEnabled?: boolean; 26 | telegramBotUsername?: string; 27 | telegramChatId?: string; 28 | telegramSendSilently?: boolean; 29 | webPushEnabled?: boolean; 30 | notificationTypes: Partial; 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.17-alpine AS BUILD_IMAGE 2 | 3 | WORKDIR /app 4 | 5 | ARG TARGETPLATFORM 6 | ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} 7 | 8 | RUN \ 9 | case "${TARGETPLATFORM}" in \ 10 | 'linux/arm64') apk add --no-cache python make g++ ;; \ 11 | 'linux/arm/v7') apk add --no-cache python make g++ ;; \ 12 | esac 13 | 14 | COPY package.json yarn.lock ./ 15 | RUN yarn install --frozen-lockfile --network-timeout 1000000 16 | 17 | COPY . ./ 18 | 19 | ARG COMMIT_TAG 20 | ENV COMMIT_TAG=${COMMIT_TAG} 21 | 22 | RUN yarn build 23 | 24 | # remove development dependencies 25 | RUN yarn install --production --ignore-scripts --prefer-offline 26 | 27 | RUN rm -rf src server 28 | 29 | RUN touch config/DOCKER 30 | 31 | RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json 32 | 33 | 34 | FROM node:14.17-alpine 35 | 36 | WORKDIR /app 37 | 38 | RUN apk add --no-cache tzdata tini 39 | 40 | # copy from build image 41 | COPY --from=BUILD_IMAGE /app ./ 42 | 43 | ENTRYPOINT [ "/sbin/tini", "--" ] 44 | CMD [ "yarn", "start" ] 45 | 46 | EXPOSE 5055 47 | -------------------------------------------------------------------------------- /src/assets/services/plex.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/using-overseerr/notifications/webpush.md: -------------------------------------------------------------------------------- 1 | # Web Push 2 | 3 | The web push notification agent enables you and your users to receive Overseerr notifications in a supported browser. 4 | 5 | This notification agent does not require any configuration, but is not enabled in Overseerr by default. 6 | 7 | {% hint style="warning" %} 8 | **The web push agent only works via HTTPS.** Refer to our [reverse proxy examples](../../extending-overseerr/reverse-proxy.md) for help on proxying Overseerr traffic via HTTPS. 9 | {% endhint %} 10 | 11 | To set up web push notifications, simply enable the agent in **Settings → Notifications → Web Push**. You and your users will then be prompted to allow notifications in your web browser. 12 | 13 | Users can opt out of these notifications, or customize the notification types they would like to subscribe to, in their user settings. 14 | 15 | {% hint style="info" %} 16 | Web push notifications offer a native notification experience without the need to install an app. iOS devices do not have support for these notifications at this time, however. 17 | {% endhint %} 18 | -------------------------------------------------------------------------------- /src/pages/collection/[collectionId]/index.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { GetServerSideProps, NextPage } from 'next'; 3 | import React from 'react'; 4 | import type { Collection } from '../../../../server/models/Collection'; 5 | import CollectionDetails from '../../../components/CollectionDetails'; 6 | 7 | interface CollectionPageProps { 8 | collection?: Collection; 9 | } 10 | 11 | const CollectionPage: NextPage = ({ collection }) => { 12 | return ; 13 | }; 14 | 15 | export const getServerSideProps: GetServerSideProps = async ( 16 | ctx 17 | ) => { 18 | const response = await axios.get( 19 | `http://localhost:${process.env.PORT || 5055}/api/v1/collection/${ 20 | ctx.query.collectionId 21 | }`, 22 | { 23 | headers: ctx.req?.headers?.cookie 24 | ? { cookie: ctx.req.headers.cookie } 25 | : undefined, 26 | } 27 | ); 28 | 29 | return { 30 | props: { 31 | collection: response.data, 32 | }, 33 | }; 34 | }; 35 | 36 | export default CollectionPage; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 sct 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/interfaces/api/plexInterfaces.ts: -------------------------------------------------------------------------------- 1 | import { PlexSettings } from '../../lib/settings'; 2 | 3 | export interface PlexStatus { 4 | settings: PlexSettings; 5 | status: number; 6 | message: string; 7 | } 8 | 9 | export interface PlexConnection { 10 | protocol: string; 11 | address: string; 12 | port: number; 13 | uri: string; 14 | local: boolean; 15 | status?: number; 16 | message?: string; 17 | } 18 | 19 | export interface PlexDevice { 20 | name: string; 21 | product: string; 22 | productVersion: string; 23 | platform: string; 24 | platformVersion: string; 25 | device: string; 26 | clientIdentifier: string; 27 | createdAt: Date; 28 | lastSeenAt: Date; 29 | provides: string[]; 30 | owned: boolean; 31 | accessToken?: string; 32 | publicAddress?: string; 33 | httpsRequired?: boolean; 34 | synced?: boolean; 35 | relay?: boolean; 36 | dnsRebindingProtection?: boolean; 37 | natLoopbackSupported?: boolean; 38 | publicAddressMatches?: boolean; 39 | presence?: boolean; 40 | ownerID?: string; 41 | home?: boolean; 42 | sourceTitle?: string; 43 | connection: PlexConnection[]; 44 | } 45 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowCircleRightIcon } from '@heroicons/react/outline'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import PageTitle from '../components/Common/PageTitle'; 6 | 7 | const messages = defineMessages({ 8 | errormessagewithcode: '{statusCode} - {error}', 9 | pagenotfound: 'Page Not Found', 10 | returnHome: 'Return Home', 11 | }); 12 | 13 | const Custom404: React.FC = () => { 14 | const intl = useIntl(); 15 | 16 | return ( 17 |
18 | 19 |
20 | {intl.formatMessage(messages.errormessagewithcode, { 21 | statusCode: 404, 22 | error: intl.formatMessage(messages.pagenotfound), 23 | })} 24 |
25 | 26 | 27 | {intl.formatMessage(messages.returnHome)} 28 | 29 | 30 | 31 |
32 | ); 33 | }; 34 | 35 | export default Custom404; 36 | -------------------------------------------------------------------------------- /src/context/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { useUser, User } from '../hooks/useUser'; 3 | import { useRouter } from 'next/dist/client/router'; 4 | 5 | interface UserContextProps { 6 | initialUser: User; 7 | } 8 | 9 | /** 10 | * This UserContext serves the purpose of just preparing the useUser hooks 11 | * cache on server side render. It also will handle redirecting the user to 12 | * the login page if their session ever becomes invalid. 13 | */ 14 | export const UserContext: React.FC = ({ 15 | initialUser, 16 | children, 17 | }) => { 18 | const { user, error, revalidate } = useUser({ initialData: initialUser }); 19 | const router = useRouter(); 20 | const routing = useRef(false); 21 | 22 | useEffect(() => { 23 | revalidate(); 24 | }, [router.pathname, revalidate]); 25 | 26 | useEffect(() => { 27 | if ( 28 | !router.pathname.match(/(setup|login|resetpassword)/) && 29 | (!user || error) && 30 | !routing.current 31 | ) { 32 | routing.current = true; 33 | location.href = '/login'; 34 | } 35 | }, [router, user, error]); 36 | 37 | return <>{children}; 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/Settings/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { ClipboardCopyIcon } from '@heroicons/react/solid'; 2 | import React, { useEffect } from 'react'; 3 | import { defineMessages, useIntl } from 'react-intl'; 4 | import { useToasts } from 'react-toast-notifications'; 5 | import useClipboard from 'react-use-clipboard'; 6 | 7 | const messages = defineMessages({ 8 | copied: 'Copied API key to clipboard.', 9 | }); 10 | 11 | const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => { 12 | const intl = useIntl(); 13 | const [isCopied, setCopied] = useClipboard(textToCopy, { 14 | successDuration: 1000, 15 | }); 16 | const { addToast } = useToasts(); 17 | 18 | useEffect(() => { 19 | if (isCopied) { 20 | addToast(intl.formatMessage(messages.copied), { 21 | appearance: 'info', 22 | autoDismiss: true, 23 | }); 24 | } 25 | }, [isCopied, addToast, intl]); 26 | 27 | return ( 28 | 37 | ); 38 | }; 39 | 40 | export default CopyButton; 41 | -------------------------------------------------------------------------------- /ormconfig.js: -------------------------------------------------------------------------------- 1 | const devConfig = { 2 | type: 'sqlite', 3 | database: process.env.CONFIG_DIRECTORY 4 | ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` 5 | : 'config/db/db.sqlite3', 6 | synchronize: true, 7 | migrationsRun: false, 8 | logging: false, 9 | enableWAL: true, 10 | entities: ['server/entity/**/*.ts'], 11 | migrations: ['server/migration/**/*.ts'], 12 | subscribers: ['server/subscriber/**/*.ts'], 13 | cli: { 14 | entitiesDir: 'server/entity', 15 | migrationsDir: 'server/migration', 16 | }, 17 | }; 18 | 19 | const prodConfig = { 20 | type: 'sqlite', 21 | database: process.env.CONFIG_DIRECTORY 22 | ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` 23 | : 'config/db/db.sqlite3', 24 | synchronize: false, 25 | logging: false, 26 | enableWAL: true, 27 | entities: ['dist/entity/**/*.js'], 28 | migrations: ['dist/migration/**/*.js'], 29 | migrationsRun: false, 30 | subscribers: ['dist/subscriber/**/*.js'], 31 | cli: { 32 | entitiesDir: 'dist/entity', 33 | migrationsDir: 'dist/migration', 34 | }, 35 | }; 36 | 37 | const finalConfig = 38 | process.env.NODE_ENV !== 'production' ? devConfig : prodConfig; 39 | 40 | module.exports = finalConfig; 41 | -------------------------------------------------------------------------------- /src/assets/rt_fresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/AppDataWarning/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { defineMessages, useIntl } from 'react-intl'; 3 | import useSWR from 'swr'; 4 | import Alert from '../Common/Alert'; 5 | 6 | const messages = defineMessages({ 7 | dockerVolumeMissingDescription: 8 | 'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.', 9 | }); 10 | 11 | const AppDataWarning: React.FC = () => { 12 | const intl = useIntl(); 13 | const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>( 14 | '/api/v1/status/appdata' 15 | ); 16 | 17 | if (!data && !error) { 18 | return null; 19 | } 20 | 21 | if (!data) { 22 | return null; 23 | } 24 | 25 | return ( 26 | <> 27 | {!data.appData && ( 28 | {msg}; 32 | }, 33 | appDataPath: data.appDataPath, 34 | })} 35 | /> 36 | )} 37 | 38 | ); 39 | }; 40 | 41 | export default AppDataWarning; 42 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | 3 | - [Introduction](README.md) 4 | 5 | ## Getting Started 6 | 7 | - [Installation](getting-started/installation.md) 8 | 9 | ## Using Overseerr 10 | 11 | - [Settings](using-overseerr/settings/README.md) 12 | - [Users](using-overseerr/users/README.md) 13 | - [Notifications](using-overseerr/notifications/README.md) 14 | - [Email](using-overseerr/notifications/email.md) 15 | - [Web Push](using-overseerr/notifications/webpush.md) 16 | - [Discord](using-overseerr/notifications/discord.md) 17 | - [LunaSea](using-overseerr/notifications/lunasea.md) 18 | - [Pushbullet](using-overseerr/notifications/pushbullet.md) 19 | - [Pushover](using-overseerr/notifications/pushover.md) 20 | - [Slack](using-overseerr/notifications/slack.md) 21 | - [Telegram](using-overseerr/notifications/telegram.md) 22 | - [Webhook](using-overseerr/notifications/webhooks.md) 23 | 24 | ## Support 25 | 26 | - [Frequently Asked Questions (FAQ)](support/faq.md) 27 | - [Need Help?](support/need-help.md) 28 | 29 | ## Extending Overseerr 30 | 31 | - [Reverse Proxy](extending-overseerr/reverse-proxy.md) 32 | - [Fail2ban](extending-overseerr/fail2ban.md) 33 | - [Third-Party Integrations](extending-overseerr/third-party.md) 34 | -------------------------------------------------------------------------------- /src/components/Common/List/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withProperties } from '../../../utils/typeHelpers'; 3 | 4 | interface ListItemProps { 5 | title: string; 6 | className?: string; 7 | } 8 | 9 | const ListItem: React.FC = ({ title, className, children }) => { 10 | return ( 11 |
12 |
13 |
{title}
14 |
15 | {children} 16 |
17 |
18 |
19 | ); 20 | }; 21 | 22 | interface ListProps { 23 | title: string; 24 | subTitle?: string; 25 | } 26 | 27 | const List: React.FC = ({ title, subTitle, children }) => { 28 | return ( 29 | <> 30 |
31 |

{title}

32 | {subTitle &&

{subTitle}

} 33 |
34 |
35 |
{children}
36 |
37 | 38 | ); 39 | }; 40 | 41 | export default withProperties(List, { Item: ListItem }); 42 | -------------------------------------------------------------------------------- /docs/using-overseerr/notifications/telegram.md: -------------------------------------------------------------------------------- 1 | # Telegram 2 | 3 | {% hint style="info" %} 4 | Users can optionally configure their own notifications in their user settings. 5 | {% endhint %} 6 | 7 | ## Configuration 8 | 9 | {% hint style="info" %} 10 | In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather). 11 | 12 | Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications. 13 | {% endhint %} 14 | 15 | ### Bot Username (optional) 16 | 17 | If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications. 18 | 19 | The bot username should end with `_bot`, and the `@` prefix should be omitted. 20 | 21 | ### Bot Authentication Token 22 | 23 | At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token. 24 | 25 | ### Chat ID 26 | 27 | To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command. 28 | 29 | ### Send Silently (optional) 30 | 31 | Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds. 32 | -------------------------------------------------------------------------------- /src/assets/extlogos/pushover.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 365 5 | 6 | # Skip issues and pull requests created before a given timestamp. Timestamp must 7 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 8 | skipCreatedBefore: false 9 | 10 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 11 | exemptLabels: 12 | - dependencies 13 | 14 | # Label to add before locking, such as `outdated`. Set to `false` to disable 15 | lockLabel: false 16 | 17 | # Comment to post before locking. Set to `false` to disable 18 | lockComment: > 19 | This thread has been automatically locked since there has not been 20 | any recent activity after it was closed. Please open a new issue for 21 | related bugs. 22 | 23 | # Assign `resolved` as the reason for locking. Set to `false` to disable 24 | setLockReason: true 25 | # Limit to only `issues` or `pulls` 26 | # only: issues 27 | 28 | # Optionally, specify configuration settings just for `issues` or `pulls` 29 | # issues: 30 | # exemptLabels: 31 | # - help-wanted 32 | # lockLabel: outdated 33 | 34 | # pulls: 35 | # daysUntilLock: 30 36 | 37 | # Repository to extend settings from 38 | # _extends: repo 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Submit a report to help us improve 4 | title: '' 5 | labels: 'awaiting-triage, type:bug' 6 | assignees: '' 7 | --- 8 | 9 | #### Description 10 | 11 | Please provide a clear and concise description of the bug or issue. 12 | 13 | #### Version 14 | 15 | What version of Overseerr are you running? (You can find this in Settings → About → Version.) 16 | 17 | #### Steps to Reproduce 18 | 19 | Please tell us how we can reproduce the undesired behavior. 20 | 21 | 1. Go to [...] 22 | 2. Click on [...] 23 | 3. Scroll down to [...] 24 | 4. See error in [...] 25 | 26 | #### Expected Behavior 27 | 28 | Please provide a clear and concise description of what you expected to happen. 29 | 30 | #### Screenshots 31 | 32 | If applicable, please provide screenshots depicting the problem. 33 | 34 | #### Device 35 | 36 | What device were you using when you encountered this issue? Please provide this information to help us reproduce and investigate the bug. 37 | 38 | - **Platform:** [e.g., desktop, smartphone, tablet] 39 | - **Device:** [e.g., iPhone X, Surface Pro, Samsung Galaxy Tab] 40 | - **OS:** [e.g., iOS 8.1, Windows 10, Android 11] 41 | - **Browser:** [e.g., Chrome, Safari, Edge, Firefox] 42 | 43 | #### Additional Context 44 | 45 | Please provide any additional information that may be relevant or helpful. 46 | -------------------------------------------------------------------------------- /src/components/Common/PlayButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import ButtonWithDropdown from '../ButtonWithDropdown'; 3 | 4 | interface PlayButtonProps { 5 | links: PlayButtonLink[]; 6 | } 7 | 8 | export interface PlayButtonLink { 9 | text: string; 10 | url: string; 11 | svg: ReactNode; 12 | } 13 | 14 | const PlayButton: React.FC = ({ links }) => { 15 | if (!links || !links.length) { 16 | return null; 17 | } 18 | 19 | return ( 20 | 24 | {links[0].svg} 25 | {links[0].text} 26 | 27 | } 28 | onClick={() => { 29 | window.open(links[0].url, '_blank'); 30 | }} 31 | > 32 | {links.length > 1 && 33 | links.slice(1).map((link, i) => { 34 | return ( 35 | { 38 | window.open(link.url, '_blank'); 39 | }} 40 | buttonType="ghost" 41 | > 42 | {link.svg} 43 | {link.text} 44 | 45 | ); 46 | })} 47 | 48 | ); 49 | }; 50 | 51 | export default PlayButton; 52 | -------------------------------------------------------------------------------- /src/hooks/useRequestOverride.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr'; 2 | import { MediaRequest } from '../../server/entity/MediaRequest'; 3 | import { ServiceCommonServer } from '../../server/interfaces/api/serviceInterfaces'; 4 | 5 | interface OverrideStatus { 6 | server: string | null; 7 | profile: number | null; 8 | rootFolder: string | null; 9 | } 10 | 11 | const useRequestOverride = (request: MediaRequest): OverrideStatus => { 12 | const { data } = useSWR( 13 | `/api/v1/service/${request.type === 'movie' ? 'radarr' : 'sonarr'}` 14 | ); 15 | 16 | if (!data) { 17 | return { 18 | server: null, 19 | profile: null, 20 | rootFolder: null, 21 | }; 22 | } 23 | 24 | const defaultServer = data.find( 25 | (server) => server.is4k === request.is4k && server.isDefault 26 | ); 27 | 28 | const activeServer = data.find((server) => server.id === request.serverId); 29 | 30 | return { 31 | server: 32 | activeServer && request.serverId !== defaultServer?.id 33 | ? activeServer.name 34 | : null, 35 | profile: 36 | defaultServer?.activeProfileId !== request.profileId 37 | ? request.profileId 38 | : null, 39 | rootFolder: 40 | defaultServer?.activeDirectory !== request.rootFolder 41 | ? request.rootFolder 42 | : null, 43 | }; 44 | }; 45 | 46 | export default useRequestOverride; 47 | -------------------------------------------------------------------------------- /src/assets/rt_rotten.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Discover/DiscoverTv.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { TvResult } from '../../../server/models/Search'; 3 | import ListView from '../Common/ListView'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import Header from '../Common/Header'; 6 | import PageTitle from '../Common/PageTitle'; 7 | import useDiscover from '../../hooks/useDiscover'; 8 | import Error from '../../pages/_error'; 9 | 10 | const messages = defineMessages({ 11 | discovertv: 'Popular Series', 12 | }); 13 | 14 | const DiscoverTv: React.FC = () => { 15 | const intl = useIntl(); 16 | 17 | const { 18 | isLoadingInitialData, 19 | isEmpty, 20 | isLoadingMore, 21 | isReachingEnd, 22 | titles, 23 | fetchMore, 24 | error, 25 | } = useDiscover('/api/v1/discover/tv'); 26 | 27 | if (error) { 28 | return ; 29 | } 30 | 31 | const title = intl.formatMessage(messages.discovertv); 32 | 33 | return ( 34 | <> 35 | 36 |
37 |
{title}
38 |
39 | 0) 45 | } 46 | onScrollBottom={fetchMore} 47 | /> 48 | 49 | ); 50 | }; 51 | 52 | export default DiscoverTv; 53 | -------------------------------------------------------------------------------- /src/components/Discover/DiscoverMovies.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { MovieResult } from '../../../server/models/Search'; 3 | import ListView from '../Common/ListView'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import Header from '../Common/Header'; 6 | import PageTitle from '../Common/PageTitle'; 7 | import useDiscover from '../../hooks/useDiscover'; 8 | import Error from '../../pages/_error'; 9 | 10 | const messages = defineMessages({ 11 | discovermovies: 'Popular Movies', 12 | }); 13 | 14 | const DiscoverMovies: React.FC = () => { 15 | const intl = useIntl(); 16 | 17 | const { 18 | isLoadingInitialData, 19 | isEmpty, 20 | isLoadingMore, 21 | isReachingEnd, 22 | titles, 23 | fetchMore, 24 | error, 25 | } = useDiscover('/api/v1/discover/movies'); 26 | 27 | if (error) { 28 | return ; 29 | } 30 | 31 | const title = intl.formatMessage(messages.discovermovies); 32 | 33 | return ( 34 | <> 35 | 36 |
37 |
{title}
38 |
39 | 0) 44 | } 45 | isReachingEnd={isReachingEnd} 46 | onScrollBottom={fetchMore} 47 | /> 48 | 49 | ); 50 | }; 51 | 52 | export default DiscoverMovies; 53 | -------------------------------------------------------------------------------- /src/components/Discover/DiscoverTvUpcoming.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { TvResult } from '../../../server/models/Search'; 3 | import ListView from '../Common/ListView'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import Header from '../Common/Header'; 6 | import PageTitle from '../Common/PageTitle'; 7 | import useDiscover from '../../hooks/useDiscover'; 8 | import Error from '../../pages/_error'; 9 | 10 | const messages = defineMessages({ 11 | upcomingtv: 'Upcoming Series', 12 | }); 13 | 14 | const DiscoverTvUpcoming: React.FC = () => { 15 | const intl = useIntl(); 16 | 17 | const { 18 | isLoadingInitialData, 19 | isEmpty, 20 | isLoadingMore, 21 | isReachingEnd, 22 | titles, 23 | fetchMore, 24 | error, 25 | } = useDiscover('/api/v1/discover/tv/upcoming'); 26 | 27 | if (error) { 28 | return ; 29 | } 30 | 31 | return ( 32 | <> 33 | 34 |
35 |
{intl.formatMessage(messages.upcomingtv)}
36 |
37 | 0) 43 | } 44 | onScrollBottom={fetchMore} 45 | /> 46 | 47 | ); 48 | }; 49 | 50 | export default DiscoverTvUpcoming; 51 | -------------------------------------------------------------------------------- /src/components/Discover/Upcoming.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { MovieResult } from '../../../server/models/Search'; 3 | import ListView from '../Common/ListView'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import Header from '../Common/Header'; 6 | import PageTitle from '../Common/PageTitle'; 7 | import useDiscover from '../../hooks/useDiscover'; 8 | import Error from '../../pages/_error'; 9 | 10 | const messages = defineMessages({ 11 | upcomingmovies: 'Upcoming Movies', 12 | }); 13 | 14 | const UpcomingMovies: React.FC = () => { 15 | const intl = useIntl(); 16 | 17 | const { 18 | isLoadingInitialData, 19 | isEmpty, 20 | isLoadingMore, 21 | isReachingEnd, 22 | titles, 23 | fetchMore, 24 | error, 25 | } = useDiscover('/api/v1/discover/movies/upcoming'); 26 | 27 | if (error) { 28 | return ; 29 | } 30 | 31 | return ( 32 | <> 33 | 34 |
35 |
{intl.formatMessage(messages.upcomingmovies)}
36 |
37 | 0) 42 | } 43 | isReachingEnd={isReachingEnd} 44 | onScrollBottom={fetchMore} 45 | /> 46 | 47 | ); 48 | }; 49 | 50 | export default UpcomingMovies; 51 | -------------------------------------------------------------------------------- /server/interfaces/api/settingsInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type { PaginatedResponse } from './common'; 2 | 3 | export type LogMessage = { 4 | timestamp: string; 5 | level: string; 6 | label: string; 7 | message: string; 8 | data?: Record; 9 | }; 10 | 11 | export interface LogsResultsResponse extends PaginatedResponse { 12 | results: LogMessage[]; 13 | } 14 | 15 | export interface SettingsAboutResponse { 16 | version: string; 17 | totalRequests: number; 18 | totalMediaItems: number; 19 | tz?: string; 20 | } 21 | 22 | export interface PublicSettingsResponse { 23 | jellyfinHost?: string; 24 | jellyfinServerName?: string; 25 | initialized: boolean; 26 | applicationTitle: string; 27 | applicationUrl: string; 28 | hideAvailable: boolean; 29 | localLogin: boolean; 30 | movie4kEnabled: boolean; 31 | series4kEnabled: boolean; 32 | region: string; 33 | originalLanguage: string; 34 | mediaServerType: number; 35 | partialRequestsEnabled: boolean; 36 | cacheImages: boolean; 37 | vapidPublic: string; 38 | enablePushRegistration: boolean; 39 | locale: string; 40 | emailEnabled: boolean; 41 | } 42 | 43 | export interface CacheItem { 44 | id: string; 45 | name: string; 46 | stats: { 47 | hits: number; 48 | misses: number; 49 | keys: number; 50 | ksize: number; 51 | vsize: number; 52 | }; 53 | } 54 | 55 | export interface StatusResponse { 56 | version: string; 57 | commitTag: string; 58 | updateAvailable: boolean; 59 | commitsBehind: number; 60 | } 61 | -------------------------------------------------------------------------------- /src/components/CompanyCard/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React, { useState } from 'react'; 3 | 4 | interface CompanyCardProps { 5 | name: string; 6 | image: string; 7 | url: string; 8 | } 9 | 10 | const CompanyCard: React.FC = ({ image, url, name }) => { 11 | const [isHovered, setHovered] = useState(false); 12 | 13 | return ( 14 | 15 | { 22 | setHovered(true); 23 | }} 24 | onMouseLeave={() => setHovered(false)} 25 | onKeyDown={(e) => { 26 | if (e.key === 'Enter') { 27 | setHovered(true); 28 | } 29 | }} 30 | role="link" 31 | tabIndex={0} 32 | > 33 | {name} 38 |
43 | 44 | 45 | ); 46 | }; 47 | 48 | export default CompanyCard; 49 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | name: Overseerr Preview 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'preview-*' 7 | 8 | jobs: 9 | build_and_push: 10 | name: Build & Publish Docker Preview Images 11 | runs-on: ubuntu-20.04 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2.3.4 15 | - name: Get the version 16 | id: get_version 17 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v1.2.0 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v1.3.0 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v1.9.0 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_TOKEN }} 27 | - name: Log in to GitHub Container Registry 28 | uses: docker/login-action@v1.9.0 29 | with: 30 | registry: ghcr.io 31 | username: ${{ github.repository_owner }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | - name: Build and push 34 | uses: docker/build-push-action@v2.5.0 35 | with: 36 | context: . 37 | file: ./Dockerfile 38 | platforms: linux/amd64 39 | push: true 40 | build-args: | 41 | COMMIT_TAG=${{ github.sha }} 42 | tags: | 43 | sctx/overseerr:${{ steps.get_version.outputs.VERSION }} 44 | ghcr.io/sct/overseerr:${{ steps.get_version.outputs.VERSION }} 45 | -------------------------------------------------------------------------------- /src/components/Discover/Trending.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { 3 | MovieResult, 4 | TvResult, 5 | PersonResult, 6 | } from '../../../server/models/Search'; 7 | import ListView from '../Common/ListView'; 8 | import { defineMessages, useIntl } from 'react-intl'; 9 | import Header from '../Common/Header'; 10 | import PageTitle from '../Common/PageTitle'; 11 | import useDiscover from '../../hooks/useDiscover'; 12 | import Error from '../../pages/_error'; 13 | 14 | const messages = defineMessages({ 15 | trending: 'Trending', 16 | }); 17 | 18 | const Trending: React.FC = () => { 19 | const intl = useIntl(); 20 | const { 21 | isLoadingInitialData, 22 | isEmpty, 23 | isLoadingMore, 24 | isReachingEnd, 25 | titles, 26 | fetchMore, 27 | error, 28 | } = useDiscover( 29 | '/api/v1/discover/trending' 30 | ); 31 | 32 | if (error) { 33 | return ; 34 | } 35 | 36 | return ( 37 | <> 38 | 39 |
40 |
{intl.formatMessage(messages.trending)}
41 |
42 | 0) 47 | } 48 | isReachingEnd={isReachingEnd} 49 | onScrollBottom={fetchMore} 50 | /> 51 | 52 | ); 53 | }; 54 | 55 | export default Trending; 56 | -------------------------------------------------------------------------------- /src/context/SettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import useSWR from 'swr'; 3 | import { MediaServerType } from '../../server/constants/server'; 4 | import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces'; 5 | 6 | export interface SettingsContextProps { 7 | currentSettings: PublicSettingsResponse; 8 | } 9 | 10 | const defaultSettings = { 11 | initialized: false, 12 | applicationTitle: 'Overseerr', 13 | applicationUrl: '', 14 | hideAvailable: false, 15 | localLogin: true, 16 | movie4kEnabled: false, 17 | series4kEnabled: false, 18 | region: '', 19 | originalLanguage: '', 20 | mediaServerType: MediaServerType.NOT_CONFIGURED, 21 | partialRequestsEnabled: true, 22 | cacheImages: false, 23 | vapidPublic: '', 24 | enablePushRegistration: false, 25 | locale: 'en', 26 | emailEnabled: false, 27 | }; 28 | 29 | export const SettingsContext = React.createContext({ 30 | currentSettings: defaultSettings, 31 | }); 32 | 33 | export const SettingsProvider: React.FC = ({ 34 | children, 35 | currentSettings, 36 | }) => { 37 | const { data, error } = useSWR( 38 | '/api/v1/settings/public', 39 | { initialData: currentSettings } 40 | ); 41 | 42 | let newSettings = defaultSettings; 43 | 44 | if (data && !error) { 45 | newSettings = data; 46 | } 47 | 48 | return ( 49 | 50 | {children} 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/i18n/globalMessages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from 'react-intl'; 2 | 3 | const globalMessages = defineMessages({ 4 | available: 'Available', 5 | partiallyavailable: 'Partially Available', 6 | processing: 'Processing', 7 | unavailable: 'Unavailable', 8 | notrequested: 'Not Requested', 9 | requested: 'Requested', 10 | requesting: 'Requesting…', 11 | request: 'Request', 12 | request4k: 'Request in 4K', 13 | failed: 'Failed', 14 | pending: 'Pending', 15 | declined: 'Declined', 16 | approved: 'Approved', 17 | movie: 'Movie', 18 | movies: 'Movies', 19 | tvshow: 'Series', 20 | tvshows: 'Series', 21 | cancel: 'Cancel', 22 | canceling: 'Canceling…', 23 | approve: 'Approve', 24 | decline: 'Decline', 25 | delete: 'Delete', 26 | retry: 'Retry', 27 | retrying: 'Retrying…', 28 | view: 'View', 29 | deleting: 'Deleting…', 30 | test: 'Test', 31 | testing: 'Testing…', 32 | save: 'Save Changes', 33 | saving: 'Saving…', 34 | close: 'Close', 35 | edit: 'Edit', 36 | areyousure: 'Are you sure?', 37 | back: 'Back', 38 | next: 'Next', 39 | previous: 'Previous', 40 | status: 'Status', 41 | all: 'All', 42 | experimental: 'Experimental', 43 | advanced: 'Advanced', 44 | loading: 'Loading…', 45 | settings: 'Settings', 46 | usersettings: 'User Settings', 47 | delimitedlist: '{a}, {b}', 48 | showingresults: 49 | 'Showing {from} to {to} of {total} results', 50 | resultsperpage: 'Display {pageSize} results per page', 51 | noresults: 'No results.', 52 | }); 53 | 54 | export default globalMessages; 55 | -------------------------------------------------------------------------------- /public/os_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/jellyfin.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosResponse } from 'axios'; 2 | 3 | interface JellyfinAuthenticationResult { 4 | Id: string; 5 | AccessToken: string; 6 | ServerId: string; 7 | } 8 | 9 | class JellyAPI { 10 | public login( 11 | Hostname?: string, 12 | Username?: string, 13 | Password?: string 14 | ): Promise { 15 | return new Promise( 16 | ( 17 | resolve: (result: JellyfinAuthenticationResult) => void, 18 | reject: (e: Error) => void 19 | ) => { 20 | axios 21 | .post( 22 | Hostname + '/Users/AuthenticateByName', 23 | { 24 | Username: Username, 25 | Pw: Password, 26 | }, 27 | { 28 | headers: { 29 | 'X-Emby-Authorization': 30 | 'MediaBrowser Client="Jellyfin Web", Device="Firefox", DeviceId="TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NDsgcnY6ODUuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC84NS4wfDE2MTI5MjcyMDM5NzM1", Version="10.8.0"', 31 | }, 32 | } 33 | ) 34 | .then((resp: AxiosResponse) => { 35 | const response: JellyfinAuthenticationResult = { 36 | Id: resp.data.User.Id, 37 | AccessToken: resp.data.AccessToken, 38 | ServerId: resp.data.ServerId, 39 | }; 40 | resolve(response); 41 | }) 42 | .catch((e: AxiosError) => { 43 | reject(e); 44 | }); 45 | } 46 | ); 47 | } 48 | } 49 | 50 | export default JellyAPI; 51 | -------------------------------------------------------------------------------- /src/components/Common/SensitiveInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid'; 2 | import { Field } from 'formik'; 3 | import React, { useState } from 'react'; 4 | 5 | interface CustomInputProps extends React.ComponentProps<'input'> { 6 | as?: 'input'; 7 | } 8 | 9 | interface CustomFieldProps extends React.ComponentProps { 10 | as?: 'field'; 11 | } 12 | 13 | type SensitiveInputProps = CustomInputProps | CustomFieldProps; 14 | 15 | const SensitiveInput: React.FC = ({ 16 | as = 'input', 17 | ...props 18 | }) => { 19 | const [isHidden, setHidden] = useState(true); 20 | const Component = as === 'input' ? 'input' : Field; 21 | const componentProps = 22 | as === 'input' 23 | ? props 24 | : { 25 | ...props, 26 | as: props.type === 'textarea' && !isHidden ? 'textarea' : undefined, 27 | }; 28 | return ( 29 | <> 30 | 41 | 51 | 52 | ); 53 | }; 54 | 55 | export default SensitiveInput; 56 | -------------------------------------------------------------------------------- /src/assets/extlogos/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/services/imdb.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Common/ConfirmButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import useClickOutside from '../../../hooks/useClickOutside'; 3 | import Button from '../Button'; 4 | 5 | interface ConfirmButtonProps { 6 | onClick: () => void; 7 | confirmText: React.ReactNode; 8 | className?: string; 9 | } 10 | 11 | const ConfirmButton: React.FC = ({ 12 | onClick, 13 | children, 14 | confirmText, 15 | className, 16 | }) => { 17 | const ref = useRef(null); 18 | useClickOutside(ref, () => setIsClicked(false)); 19 | const [isClicked, setIsClicked] = useState(false); 20 | return ( 21 | 54 | ); 55 | }; 56 | 57 | export default ConfirmButton; 58 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 7 | 'plugin:jsx-a11y/recommended', 8 | 'plugin:react/recommended', 9 | 'plugin:react-hooks/recommended', 10 | 'prettier', 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 6, 14 | sourceType: 'module', 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | }, 19 | rules: { 20 | '@typescript-eslint/camelcase': 0, 21 | '@typescript-eslint/no-use-before-define': 0, 22 | 'jsx-a11y/no-noninteractive-tabindex': 0, 23 | 'arrow-parens': 'off', 24 | 'jsx-a11y/anchor-is-valid': 'off', 25 | 'no-console': 1, 26 | 'react-hooks/rules-of-hooks': 'error', 27 | 'react-hooks/exhaustive-deps': 'warn', 28 | '@typescript-eslint/explicit-function-return-type': 'off', 29 | 'prettier/prettier': ['error', { endOfLine: 'auto' }], 30 | 'formatjs/no-offset': 'error', 31 | 'no-unused-vars': 'off', 32 | '@typescript-eslint/no-unused-vars': ['error'], 33 | 'jsx-a11y/no-onchange': 'off', 34 | }, 35 | overrides: [ 36 | { 37 | files: ['**/*.tsx'], 38 | rules: { 39 | 'react/prop-types': 'off', 40 | }, 41 | }, 42 | ], 43 | plugins: ['jsx-a11y', 'prettier', 'react-hooks', 'formatjs'], 44 | settings: { 45 | react: { 46 | pragma: 'React', 47 | version: '16.8', 48 | }, 49 | }, 50 | env: { 51 | browser: true, 52 | node: true, 53 | jest: true, 54 | es6: true, 55 | }, 56 | reportUnusedDisableDirectives: true, 57 | }; 58 | -------------------------------------------------------------------------------- /server/utils/asyncLock.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | // whenever you need to run async code on tv show or movie that does "get existing" / "check if need to create new" / "save" 4 | // then you need to put all of that code in "await asyncLock.dispatch" callback based on media id 5 | // this will guarantee that only one part of code will run at the same for this media id to avoid code 6 | // trying to create two or more entries for same movie/tvshow (which would result in sqlite unique constraint failrue) 7 | 8 | class AsyncLock { 9 | private locked: { [key: string]: boolean } = {}; 10 | private ee = new EventEmitter(); 11 | 12 | constructor() { 13 | this.ee.setMaxListeners(0); 14 | } 15 | 16 | private acquire = async (key: string) => { 17 | return new Promise((resolve) => { 18 | if (!this.locked[key]) { 19 | this.locked[key] = true; 20 | return resolve(undefined); 21 | } 22 | 23 | const nextAcquire = () => { 24 | if (!this.locked[key]) { 25 | this.locked[key] = true; 26 | this.ee.removeListener(key, nextAcquire); 27 | return resolve(undefined); 28 | } 29 | }; 30 | 31 | this.ee.on(key, nextAcquire); 32 | }); 33 | }; 34 | 35 | private release = (key: string): void => { 36 | delete this.locked[key]; 37 | setImmediate(() => this.ee.emit(key)); 38 | }; 39 | 40 | public dispatch = async ( 41 | key: string | number, 42 | callback: () => Promise 43 | ): Promise => { 44 | const skey = String(key); 45 | await this.acquire(skey); 46 | try { 47 | await callback(); 48 | } finally { 49 | this.release(skey); 50 | } 51 | }; 52 | } 53 | 54 | export default AsyncLock; 55 | -------------------------------------------------------------------------------- /src/components/Search/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import { 4 | TvResult, 5 | MovieResult, 6 | PersonResult, 7 | } from '../../../server/models/Search'; 8 | import ListView from '../Common/ListView'; 9 | import { defineMessages, useIntl } from 'react-intl'; 10 | import Header from '../Common/Header'; 11 | import PageTitle from '../Common/PageTitle'; 12 | import Error from '../../pages/_error'; 13 | import useDiscover from '../../hooks/useDiscover'; 14 | 15 | const messages = defineMessages({ 16 | search: 'Search', 17 | searchresults: 'Search Results', 18 | }); 19 | 20 | const Search: React.FC = () => { 21 | const intl = useIntl(); 22 | const router = useRouter(); 23 | 24 | const { 25 | isLoadingInitialData, 26 | isEmpty, 27 | isLoadingMore, 28 | isReachingEnd, 29 | titles, 30 | fetchMore, 31 | error, 32 | } = useDiscover( 33 | `/api/v1/search`, 34 | { 35 | query: router.query.query, 36 | }, 37 | { hideAvailable: false } 38 | ); 39 | 40 | if (error) { 41 | return ; 42 | } 43 | 44 | return ( 45 | <> 46 | 47 |
48 |
{intl.formatMessage(messages.searchresults)}
49 |
50 | 0) 55 | } 56 | isReachingEnd={isReachingEnd} 57 | onScrollBottom={fetchMore} 58 | /> 59 | 60 | ); 61 | }; 62 | 63 | export default Search; 64 | -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { getRepository } from 'typeorm'; 2 | import { User } from '../entity/User'; 3 | import { Permission, PermissionCheckOptions } from '../lib/permissions'; 4 | import { getSettings } from '../lib/settings'; 5 | 6 | export const checkUser: Middleware = async (req, _res, next) => { 7 | const settings = getSettings(); 8 | let user: User | undefined; 9 | 10 | if (req.header('X-API-Key') === settings.main.apiKey) { 11 | const userRepository = getRepository(User); 12 | 13 | let userId = 1; // Work on original administrator account 14 | 15 | // If a User ID is provided, we will act on that user's behalf 16 | if (req.header('X-API-User')) { 17 | userId = Number(req.header('X-API-User')); 18 | } 19 | 20 | user = await userRepository.findOne({ where: { id: userId } }); 21 | } else if (req.session?.userId) { 22 | const userRepository = getRepository(User); 23 | 24 | user = await userRepository.findOne({ 25 | where: { id: req.session.userId }, 26 | }); 27 | } 28 | 29 | if (user) { 30 | req.user = user; 31 | } 32 | 33 | req.locale = user?.settings?.locale 34 | ? user.settings.locale 35 | : settings.main.locale; 36 | 37 | next(); 38 | }; 39 | 40 | export const isAuthenticated = ( 41 | permissions?: Permission | Permission[], 42 | options?: PermissionCheckOptions 43 | ): Middleware => { 44 | const authMiddleware: Middleware = (req, res, next) => { 45 | if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) { 46 | res.status(403).json({ 47 | status: 403, 48 | error: 'You do not have permission to access this endpoint', 49 | }); 50 | } else { 51 | next(); 52 | } 53 | }; 54 | return authMiddleware; 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/StatusChacker/index.tsx: -------------------------------------------------------------------------------- 1 | import { SparklesIcon } from '@heroicons/react/outline'; 2 | import React from 'react'; 3 | import { defineMessages, useIntl } from 'react-intl'; 4 | import useSWR from 'swr'; 5 | import { StatusResponse } from '../../../server/interfaces/api/settingsInterfaces'; 6 | import Modal from '../Common/Modal'; 7 | import Transition from '../Transition'; 8 | 9 | const messages = defineMessages({ 10 | newversionavailable: 'Application Update', 11 | newversionDescription: 12 | 'Overseerr has been updated! Please click the button below to reload the page.', 13 | reloadOverseerr: 'Reload', 14 | }); 15 | 16 | const StatusChecker: React.FC = () => { 17 | const intl = useIntl(); 18 | const { data, error } = useSWR('/api/v1/status', { 19 | refreshInterval: 60 * 1000, 20 | }); 21 | 22 | if (!data && !error) { 23 | return null; 24 | } 25 | 26 | if (!data) { 27 | return null; 28 | } 29 | 30 | return ( 31 | 41 | } 43 | title={intl.formatMessage(messages.newversionavailable)} 44 | onOk={() => location.reload()} 45 | okText={intl.formatMessage(messages.reloadOverseerr)} 46 | backgroundClickable={false} 47 | > 48 | {intl.formatMessage(messages.newversionDescription)} 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default StatusChecker; 55 | -------------------------------------------------------------------------------- /src/components/ServiceWorkerSetup/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import axios from 'axios'; 3 | import React, { useEffect } from 'react'; 4 | import useSettings from '../../hooks/useSettings'; 5 | import { useUser } from '../../hooks/useUser'; 6 | 7 | const ServiceWorkerSetup: React.FC = () => { 8 | const { currentSettings } = useSettings(); 9 | const { user } = useUser(); 10 | useEffect(() => { 11 | if ('serviceWorker' in navigator && user?.id) { 12 | navigator.serviceWorker 13 | .register('/sw.js') 14 | .then(async (registration) => { 15 | console.log( 16 | '[SW] Registration successful, scope is:', 17 | registration.scope 18 | ); 19 | 20 | if (currentSettings.enablePushRegistration) { 21 | const sub = await registration.pushManager.subscribe({ 22 | userVisibleOnly: true, 23 | applicationServerKey: currentSettings.vapidPublic, 24 | }); 25 | 26 | const parsedSub = JSON.parse(JSON.stringify(sub)); 27 | 28 | if (parsedSub.keys.p256dh && parsedSub.keys.auth) { 29 | await axios.post('/api/v1/user/registerPushSubscription', { 30 | endpoint: parsedSub.endpoint, 31 | p256dh: parsedSub.keys.p256dh, 32 | auth: parsedSub.keys.auth, 33 | }); 34 | } 35 | } 36 | }) 37 | .catch(function (error) { 38 | console.log('[SW] Service worker registration failed, error:', error); 39 | }); 40 | } 41 | }, [ 42 | user, 43 | currentSettings.vapidPublic, 44 | currentSettings.enablePushRegistration, 45 | ]); 46 | return null; 47 | }; 48 | 49 | export default ServiceWorkerSetup; 50 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Welcome to the Overseerr Documentation. 4 | 5 | ## Features 6 | 7 | - **Full Plex integration**. Login and manage user access with Plex. 8 | - **Syncs to your Plex library** to show what titles you already have. 9 | - **Integrates with Sonarr and Radarr**. With more services to come in the future. 10 | - **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI. 11 | - **Simple request management UI**. Don't dig through the app to approve recent requests. 12 | - **Mobile-friendly design**, for when you need to approve requests on the go. 13 | - Granular permission system. 14 | - Localization into other languages. 15 | 16 | ## Motivation 17 | 18 | The primary motivation for starting this project was to have an incredibly performant and easy to use application. There is a heavy focus on the user experience for both the server owner and the users. We feel requesting should be **effortless for the user**. Find the media you want, click request, and branch off efficiently into other titles that interest you, all in one seamless flow. For the server owner, Overseerr takes all the hassle out of approving your users' requests. 19 | 20 | ## We need your help! 21 | 22 | Overseerr is an ambitious project. We have already poured a lot of work into this, and have a lot more to do. We need your valuable feedback and help to find and fix bugs. Also, with Overseerr being an open-source project, anyone is welcome to contribute. Contribution includes building new features, patching bugs, translating the application, or even just writing documentation. 23 | 24 | If you would like to contribute, please be sure to review our [contribution guidelines](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md). 25 | -------------------------------------------------------------------------------- /server/logger.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | import 'winston-daily-rotate-file'; 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | 6 | // Migrate away from old log 7 | const OLD_LOG_FILE = path.join(__dirname, '../config/logs/overseerr.log'); 8 | if (fs.existsSync(OLD_LOG_FILE)) { 9 | const file = fs.lstatSync(OLD_LOG_FILE); 10 | 11 | if (!file.isSymbolicLink()) { 12 | fs.unlinkSync(OLD_LOG_FILE); 13 | } 14 | } 15 | 16 | const hformat = winston.format.printf( 17 | ({ level, label, message, timestamp, ...metadata }) => { 18 | let msg = `${timestamp} [${level}]${ 19 | label ? `[${label}]` : '' 20 | }: ${message} `; 21 | if (Object.keys(metadata).length > 0) { 22 | msg += JSON.stringify(metadata); 23 | } 24 | return msg; 25 | } 26 | ); 27 | 28 | const logger = winston.createLogger({ 29 | level: process.env.LOG_LEVEL || 'debug', 30 | format: winston.format.combine( 31 | winston.format.splat(), 32 | winston.format.timestamp(), 33 | hformat 34 | ), 35 | transports: [ 36 | new winston.transports.Console({ 37 | format: winston.format.combine( 38 | winston.format.colorize(), 39 | winston.format.splat(), 40 | winston.format.timestamp(), 41 | hformat 42 | ), 43 | }), 44 | new winston.transports.DailyRotateFile({ 45 | filename: process.env.CONFIG_DIRECTORY 46 | ? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log` 47 | : path.join(__dirname, '../config/logs/overseerr-%DATE%.log'), 48 | datePattern: 'YYYY-MM-DD', 49 | zippedArchive: true, 50 | maxSize: '20m', 51 | maxFiles: '7d', 52 | createSymlink: true, 53 | symlinkName: 'overseerr.log', 54 | }), 55 | ], 56 | }); 57 | 58 | export default logger; 59 | -------------------------------------------------------------------------------- /src/components/Common/Alert/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ExclamationIcon, 3 | InformationCircleIcon, 4 | XCircleIcon, 5 | } from '@heroicons/react/solid'; 6 | import React from 'react'; 7 | 8 | interface AlertProps { 9 | title?: React.ReactNode; 10 | type?: 'warning' | 'info' | 'error'; 11 | } 12 | 13 | const Alert: React.FC = ({ title, children, type }) => { 14 | let design = { 15 | bgColor: 'bg-yellow-600', 16 | titleColor: 'text-yellow-100', 17 | textColor: 'text-yellow-300', 18 | svg: , 19 | }; 20 | 21 | switch (type) { 22 | case 'info': 23 | design = { 24 | bgColor: 'bg-indigo-600', 25 | titleColor: 'text-indigo-100', 26 | textColor: 'text-indigo-300', 27 | svg: , 28 | }; 29 | break; 30 | case 'error': 31 | design = { 32 | bgColor: 'bg-red-600', 33 | titleColor: 'text-red-100', 34 | textColor: 'text-red-300', 35 | svg: , 36 | }; 37 | break; 38 | } 39 | 40 | return ( 41 |
42 |
43 |
{design.svg}
44 |
45 | {title && ( 46 |
47 | {title} 48 |
49 | )} 50 | {children && ( 51 |
52 | {children} 53 |
54 | )} 55 |
56 |
57 |
58 | ); 59 | }; 60 | 61 | export default Alert; 62 | -------------------------------------------------------------------------------- /server/lib/email/index.ts: -------------------------------------------------------------------------------- 1 | import Email from 'email-templates'; 2 | import nodemailer from 'nodemailer'; 3 | import { URL } from 'url'; 4 | import { getSettings, NotificationAgentEmail } from '../settings'; 5 | import { openpgpEncrypt } from './openpgpEncrypt'; 6 | 7 | class PreparedEmail extends Email { 8 | public constructor(settings: NotificationAgentEmail, pgpKey?: string) { 9 | const { applicationUrl } = getSettings().main; 10 | 11 | const transport = nodemailer.createTransport({ 12 | name: applicationUrl ? new URL(applicationUrl).hostname : undefined, 13 | host: settings.options.smtpHost, 14 | port: settings.options.smtpPort, 15 | secure: settings.options.secure, 16 | ignoreTLS: settings.options.ignoreTls, 17 | requireTLS: settings.options.requireTls, 18 | tls: settings.options.allowSelfSigned 19 | ? { 20 | rejectUnauthorized: false, 21 | } 22 | : undefined, 23 | auth: 24 | settings.options.authUser && settings.options.authPass 25 | ? { 26 | user: settings.options.authUser, 27 | pass: settings.options.authPass, 28 | } 29 | : undefined, 30 | }); 31 | 32 | if (pgpKey) { 33 | transport.use( 34 | 'stream', 35 | openpgpEncrypt({ 36 | signingKey: settings.options.pgpPrivateKey, 37 | password: settings.options.pgpPassword, 38 | encryptionKeys: [pgpKey], 39 | }) 40 | ); 41 | } 42 | 43 | super({ 44 | message: { 45 | from: { 46 | name: settings.options.senderName, 47 | address: settings.options.emailFrom, 48 | }, 49 | }, 50 | send: true, 51 | transport: transport, 52 | }); 53 | } 54 | } 55 | 56 | export default PreparedEmail; 57 | -------------------------------------------------------------------------------- /src/components/Discover/TvGenreList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { defineMessages, useIntl } from 'react-intl'; 3 | import useSWR from 'swr'; 4 | import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; 5 | import Error from '../../../pages/_error'; 6 | import Header from '../../Common/Header'; 7 | import LoadingSpinner from '../../Common/LoadingSpinner'; 8 | import PageTitle from '../../Common/PageTitle'; 9 | import GenreCard from '../../GenreCard'; 10 | import { genreColorMap } from '../constants'; 11 | 12 | const messages = defineMessages({ 13 | seriesgenres: 'Series Genres', 14 | }); 15 | 16 | const TvGenreList: React.FC = () => { 17 | const intl = useIntl(); 18 | const { data, error } = useSWR( 19 | `/api/v1/discover/genreslider/tv` 20 | ); 21 | 22 | if (!data && !error) { 23 | return ; 24 | } 25 | 26 | if (!data) { 27 | return ; 28 | } 29 | 30 | return ( 31 | <> 32 | 33 |
34 |
{intl.formatMessage(messages.seriesgenres)}
35 |
36 |
    37 | {data.map((genre, index) => ( 38 |
  • 39 | 47 |
  • 48 | ))} 49 |
50 | 51 | ); 52 | }; 53 | 54 | export default TvGenreList; 55 | -------------------------------------------------------------------------------- /src/components/Discover/MovieGenreList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { defineMessages, useIntl } from 'react-intl'; 3 | import useSWR from 'swr'; 4 | import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; 5 | import Error from '../../../pages/_error'; 6 | import Header from '../../Common/Header'; 7 | import LoadingSpinner from '../../Common/LoadingSpinner'; 8 | import PageTitle from '../../Common/PageTitle'; 9 | import GenreCard from '../../GenreCard'; 10 | import { genreColorMap } from '../constants'; 11 | 12 | const messages = defineMessages({ 13 | moviegenres: 'Movie Genres', 14 | }); 15 | 16 | const MovieGenreList: React.FC = () => { 17 | const intl = useIntl(); 18 | const { data, error } = useSWR( 19 | `/api/v1/discover/genreslider/movie` 20 | ); 21 | 22 | if (!data && !error) { 23 | return ; 24 | } 25 | 26 | if (!data) { 27 | return ; 28 | } 29 | 30 | return ( 31 | <> 32 | 33 |
34 |
{intl.formatMessage(messages.moviegenres)}
35 |
36 |
    37 | {data.map((genre, index) => ( 38 |
  • 39 | 47 |
  • 48 | ))} 49 |
50 | 51 | ); 52 | }; 53 | 54 | export default MovieGenreList; 55 | -------------------------------------------------------------------------------- /src/assets/tmdb_logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/TitleCard/TmdbTitleCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInView } from 'react-intersection-observer'; 3 | import useSWR from 'swr'; 4 | import TitleCard from '.'; 5 | import type { MovieDetails } from '../../../server/models/Movie'; 6 | import type { TvDetails } from '../../../server/models/Tv'; 7 | 8 | interface TmdbTitleCardProps { 9 | tmdbId: number; 10 | type: 'movie' | 'tv'; 11 | } 12 | 13 | const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { 14 | return (movie as MovieDetails).title !== undefined; 15 | }; 16 | 17 | const TmdbTitleCard: React.FC = ({ tmdbId, type }) => { 18 | const { ref, inView } = useInView({ 19 | triggerOnce: true, 20 | }); 21 | const url = 22 | type === 'movie' ? `/api/v1/movie/${tmdbId}` : `/api/v1/tv/${tmdbId}`; 23 | const { data: title, error } = useSWR( 24 | inView ? `${url}` : null 25 | ); 26 | 27 | if (!title && !error) { 28 | return ( 29 |
30 | 31 |
32 | ); 33 | } 34 | 35 | if (!title) { 36 | return ; 37 | } 38 | 39 | return isMovie(title) ? ( 40 | 50 | ) : ( 51 | 61 | ); 62 | }; 63 | 64 | export default TmdbTitleCard; 65 | -------------------------------------------------------------------------------- /src/components/Discover/TvGenreSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowCircleRightIcon } from '@heroicons/react/outline'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import useSWR from 'swr'; 6 | import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; 7 | import GenreCard from '../../GenreCard'; 8 | import Slider from '../../Slider'; 9 | import { genreColorMap } from '../constants'; 10 | 11 | const messages = defineMessages({ 12 | tvgenres: 'Series Genres', 13 | }); 14 | 15 | const TvGenreSlider: React.FC = () => { 16 | const intl = useIntl(); 17 | const { data, error } = useSWR( 18 | `/api/v1/discover/genreslider/tv`, 19 | { 20 | refreshInterval: 0, 21 | revalidateOnFocus: false, 22 | } 23 | ); 24 | 25 | return ( 26 | <> 27 | 35 | ( 40 | 48 | ))} 49 | placeholder={} 50 | emptyMessage="" 51 | /> 52 | 53 | ); 54 | }; 55 | 56 | export default React.memo(TvGenreSlider); 57 | -------------------------------------------------------------------------------- /src/components/Discover/DiscoverTvGenre/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { TvResult } from '../../../../server/models/Search'; 3 | import ListView from '../../Common/ListView'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import Header from '../../Common/Header'; 6 | import PageTitle from '../../Common/PageTitle'; 7 | import { useRouter } from 'next/router'; 8 | import globalMessages from '../../../i18n/globalMessages'; 9 | import useDiscover from '../../../hooks/useDiscover'; 10 | import Error from '../../../pages/_error'; 11 | 12 | const messages = defineMessages({ 13 | genreSeries: '{genre} Series', 14 | }); 15 | 16 | const DiscoverTvGenre: React.FC = () => { 17 | const router = useRouter(); 18 | const intl = useIntl(); 19 | 20 | const { 21 | isLoadingInitialData, 22 | isEmpty, 23 | isLoadingMore, 24 | isReachingEnd, 25 | titles, 26 | fetchMore, 27 | error, 28 | firstResultData, 29 | } = useDiscover( 30 | `/api/v1/discover/tv/genre/${router.query.genreId}` 31 | ); 32 | 33 | if (error) { 34 | return ; 35 | } 36 | 37 | const title = isLoadingInitialData 38 | ? intl.formatMessage(globalMessages.loading) 39 | : intl.formatMessage(messages.genreSeries, { 40 | genre: firstResultData?.genre.name, 41 | }); 42 | 43 | return ( 44 | <> 45 | 46 |
47 |
{title}
48 |
49 | 0) 54 | } 55 | isReachingEnd={isReachingEnd} 56 | onScrollBottom={fetchMore} 57 | /> 58 | 59 | ); 60 | }; 61 | 62 | export default DiscoverTvGenre; 63 | -------------------------------------------------------------------------------- /src/components/Discover/MovieGenreSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowCircleRightIcon } from '@heroicons/react/outline'; 2 | import Link from 'next/link'; 3 | import React from 'react'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import useSWR from 'swr'; 6 | import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces'; 7 | import GenreCard from '../../GenreCard'; 8 | import Slider from '../../Slider'; 9 | import { genreColorMap } from '../constants'; 10 | 11 | const messages = defineMessages({ 12 | moviegenres: 'Movie Genres', 13 | }); 14 | 15 | const MovieGenreSlider: React.FC = () => { 16 | const intl = useIntl(); 17 | const { data, error } = useSWR( 18 | `/api/v1/discover/genreslider/movie`, 19 | { 20 | refreshInterval: 0, 21 | revalidateOnFocus: false, 22 | } 23 | ); 24 | 25 | return ( 26 | <> 27 | 35 | ( 40 | 48 | ))} 49 | placeholder={} 50 | emptyMessage="" 51 | /> 52 | 53 | ); 54 | }; 55 | 56 | export default React.memo(MovieGenreSlider); 57 | -------------------------------------------------------------------------------- /src/components/Discover/DiscoverMovieGenre/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { MovieResult } from '../../../../server/models/Search'; 3 | import ListView from '../../Common/ListView'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | import Header from '../../Common/Header'; 6 | import PageTitle from '../../Common/PageTitle'; 7 | import { useRouter } from 'next/router'; 8 | import globalMessages from '../../../i18n/globalMessages'; 9 | import useDiscover from '../../../hooks/useDiscover'; 10 | import Error from '../../../pages/_error'; 11 | 12 | const messages = defineMessages({ 13 | genreMovies: '{genre} Movies', 14 | }); 15 | 16 | const DiscoverMovieGenre: React.FC = () => { 17 | const router = useRouter(); 18 | const intl = useIntl(); 19 | 20 | const { 21 | isLoadingInitialData, 22 | isEmpty, 23 | isLoadingMore, 24 | isReachingEnd, 25 | titles, 26 | fetchMore, 27 | error, 28 | firstResultData, 29 | } = useDiscover( 30 | `/api/v1/discover/movies/genre/${router.query.genreId}` 31 | ); 32 | 33 | if (error) { 34 | return ; 35 | } 36 | 37 | const title = isLoadingInitialData 38 | ? intl.formatMessage(globalMessages.loading) 39 | : intl.formatMessage(messages.genreMovies, { 40 | genre: firstResultData?.genre.name, 41 | }); 42 | 43 | return ( 44 | <> 45 | 46 |
47 |
{title}
48 |
49 | 0) 54 | } 55 | isReachingEnd={isReachingEnd} 56 | onScrollBottom={fetchMore} 57 | /> 58 | 59 | ); 60 | }; 61 | 62 | export default DiscoverMovieGenre; 63 | -------------------------------------------------------------------------------- /src/components/PlexLoginButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { LoginIcon } from '@heroicons/react/outline'; 2 | import React, { useState } from 'react'; 3 | import { defineMessages, useIntl } from 'react-intl'; 4 | import globalMessages from '../../i18n/globalMessages'; 5 | import PlexOAuth from '../../utils/plex'; 6 | 7 | const messages = defineMessages({ 8 | signinwithplex: 'Sign In', 9 | signingin: 'Signing In…', 10 | }); 11 | 12 | const plexOAuth = new PlexOAuth(); 13 | 14 | interface PlexLoginButtonProps { 15 | onAuthToken: (authToken: string) => void; 16 | isProcessing?: boolean; 17 | onError?: (message: string) => void; 18 | } 19 | 20 | const PlexLoginButton: React.FC = ({ 21 | onAuthToken, 22 | onError, 23 | isProcessing, 24 | }) => { 25 | const intl = useIntl(); 26 | const [loading, setLoading] = useState(false); 27 | 28 | const getPlexLogin = async () => { 29 | setLoading(true); 30 | try { 31 | const authToken = await plexOAuth.login(); 32 | setLoading(false); 33 | onAuthToken(authToken); 34 | } catch (e) { 35 | if (onError) { 36 | onError(e.message); 37 | } 38 | setLoading(false); 39 | } 40 | }; 41 | return ( 42 | 43 | 61 | 62 | ); 63 | }; 64 | 65 | export default PlexLoginButton; 66 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const defaultTheme = require('tailwindcss/defaultTheme'); 3 | 4 | module.exports = { 5 | mode: 'jit', 6 | purge: ['./src/pages/**/*.{ts,tsx}', './src/components/**/*.{ts,tsx}'], 7 | theme: { 8 | extend: { 9 | transitionProperty: { 10 | 'max-height': 'max-height', 11 | width: 'width', 12 | }, 13 | fontFamily: { 14 | sans: ['Inter', ...defaultTheme.fontFamily.sans], 15 | }, 16 | typography: (theme) => ({ 17 | DEFAULT: { 18 | css: { 19 | color: theme('colors.gray.300'), 20 | a: { 21 | color: theme('colors.indigo.500'), 22 | '&:hover': { 23 | color: theme('colors.indigo.400'), 24 | }, 25 | }, 26 | 27 | h1: { 28 | color: theme('colors.gray.300'), 29 | }, 30 | h2: { 31 | color: theme('colors.gray.300'), 32 | }, 33 | h3: { 34 | color: theme('colors.gray.300'), 35 | }, 36 | h4: { 37 | color: theme('colors.gray.300'), 38 | }, 39 | h5: { 40 | color: theme('colors.gray.300'), 41 | }, 42 | h6: { 43 | color: theme('colors.gray.300'), 44 | }, 45 | 46 | strong: { 47 | color: theme('colors.gray.400'), 48 | }, 49 | 50 | code: { 51 | color: theme('colors.gray.300'), 52 | }, 53 | 54 | figcaption: { 55 | color: theme('colors.gray.500'), 56 | }, 57 | }, 58 | }, 59 | }), 60 | }, 61 | }, 62 | plugins: [ 63 | require('@tailwindcss/forms'), 64 | require('@tailwindcss/typography'), 65 | require('@tailwindcss/aspect-ratio'), 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /server/lib/cache.ts: -------------------------------------------------------------------------------- 1 | import NodeCache from 'node-cache'; 2 | 3 | export type AvailableCacheIds = 4 | | 'tmdb' 5 | | 'radarr' 6 | | 'sonarr' 7 | | 'rt' 8 | | 'github' 9 | | 'plexguid'; 10 | 11 | const DEFAULT_TTL = 300; 12 | const DEFAULT_CHECK_PERIOD = 120; 13 | 14 | class Cache { 15 | public id: AvailableCacheIds; 16 | public data: NodeCache; 17 | public name: string; 18 | 19 | constructor( 20 | id: AvailableCacheIds, 21 | name: string, 22 | options: { stdTtl?: number; checkPeriod?: number } = {} 23 | ) { 24 | this.id = id; 25 | this.name = name; 26 | this.data = new NodeCache({ 27 | stdTTL: options.stdTtl ?? DEFAULT_TTL, 28 | checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD, 29 | }); 30 | } 31 | 32 | public getStats() { 33 | return this.data.getStats(); 34 | } 35 | 36 | public flush(): void { 37 | this.data.flushAll(); 38 | } 39 | } 40 | 41 | class CacheManager { 42 | private availableCaches: Record = { 43 | tmdb: new Cache('tmdb', 'TMDb API', { 44 | stdTtl: 21600, 45 | checkPeriod: 60 * 30, 46 | }), 47 | radarr: new Cache('radarr', 'Radarr API'), 48 | sonarr: new Cache('sonarr', 'Sonarr API'), 49 | rt: new Cache('rt', 'Rotten Tomatoes API', { 50 | stdTtl: 43200, 51 | checkPeriod: 60 * 30, 52 | }), 53 | github: new Cache('github', 'GitHub API', { 54 | stdTtl: 21600, 55 | checkPeriod: 60 * 30, 56 | }), 57 | plexguid: new Cache('plexguid', 'Plex GUID Cache', { 58 | stdTtl: 86400 * 7, // 1 week cache 59 | checkPeriod: 60 * 30, 60 | }), 61 | }; 62 | 63 | public getCache(id: AvailableCacheIds): Cache { 64 | return this.availableCaches[id]; 65 | } 66 | 67 | public getAllCaches(): Record { 68 | return this.availableCaches; 69 | } 70 | } 71 | 72 | const cacheManager = new CacheManager(); 73 | 74 | export default cacheManager; 75 | -------------------------------------------------------------------------------- /server/lib/notifications/index.ts: -------------------------------------------------------------------------------- 1 | import logger from '../../logger'; 2 | import type { NotificationAgent, NotificationPayload } from './agents/agent'; 3 | 4 | export enum Notification { 5 | NONE = 0, 6 | MEDIA_PENDING = 2, 7 | MEDIA_APPROVED = 4, 8 | MEDIA_AVAILABLE = 8, 9 | MEDIA_FAILED = 16, 10 | TEST_NOTIFICATION = 32, 11 | MEDIA_DECLINED = 64, 12 | MEDIA_AUTO_APPROVED = 128, 13 | } 14 | 15 | export const hasNotificationType = ( 16 | types: Notification | Notification[], 17 | value: number 18 | ): boolean => { 19 | let total = 0; 20 | 21 | // If we are not checking any notifications, bail out and return true 22 | if (types === 0) { 23 | return true; 24 | } 25 | 26 | if (Array.isArray(types)) { 27 | // Combine all notification values into one 28 | total = types.reduce((a, v) => a + v, 0); 29 | } else { 30 | total = types; 31 | } 32 | 33 | // Test notifications don't need to be enabled 34 | if (!(value & Notification.TEST_NOTIFICATION)) { 35 | value += Notification.TEST_NOTIFICATION; 36 | } 37 | 38 | return !!(value & total); 39 | }; 40 | 41 | class NotificationManager { 42 | private activeAgents: NotificationAgent[] = []; 43 | 44 | public registerAgents = (agents: NotificationAgent[]): void => { 45 | this.activeAgents = [...this.activeAgents, ...agents]; 46 | logger.info('Registered notification agents', { label: 'Notifications' }); 47 | }; 48 | 49 | public sendNotification( 50 | type: Notification, 51 | payload: NotificationPayload 52 | ): void { 53 | logger.info(`Sending notification(s) for ${Notification[type]}`, { 54 | label: 'Notifications', 55 | subject: payload.subject, 56 | }); 57 | 58 | this.activeAgents.forEach((agent) => { 59 | if (agent.shouldSend()) { 60 | agent.send(type, payload); 61 | } 62 | }); 63 | } 64 | } 65 | 66 | const notificationManager = new NotificationManager(); 67 | 68 | export default notificationManager; 69 | -------------------------------------------------------------------------------- /server/lib/permissions.ts: -------------------------------------------------------------------------------- 1 | export enum Permission { 2 | NONE = 0, 3 | ADMIN = 2, 4 | MANAGE_SETTINGS = 4, 5 | MANAGE_USERS = 8, 6 | MANAGE_REQUESTS = 16, 7 | REQUEST = 32, 8 | VOTE = 64, 9 | AUTO_APPROVE = 128, 10 | AUTO_APPROVE_MOVIE = 256, 11 | AUTO_APPROVE_TV = 512, 12 | REQUEST_4K = 1024, 13 | REQUEST_4K_MOVIE = 2048, 14 | REQUEST_4K_TV = 4096, 15 | REQUEST_ADVANCED = 8192, 16 | REQUEST_VIEW = 16384, 17 | AUTO_APPROVE_4K = 32768, 18 | AUTO_APPROVE_4K_MOVIE = 65536, 19 | AUTO_APPROVE_4K_TV = 131072, 20 | REQUEST_MOVIE = 262144, 21 | REQUEST_TV = 524288, 22 | } 23 | 24 | export interface PermissionCheckOptions { 25 | type: 'and' | 'or'; 26 | } 27 | 28 | /** 29 | * Takes a Permission and the users permission value and determines 30 | * if the user has access to the permission provided. If the user has 31 | * the admin permission, true will always be returned from this check! 32 | * 33 | * @param permissions Single permission or array of permissions 34 | * @param value users current permission value 35 | * @param options Extra options to control permission check behavior (mainly for arrays) 36 | */ 37 | export const hasPermission = ( 38 | permissions: Permission | Permission[], 39 | value: number, 40 | options: PermissionCheckOptions = { type: 'and' } 41 | ): boolean => { 42 | let total = 0; 43 | 44 | // If we are not checking any permissions, bail out and return true 45 | if (permissions === 0) { 46 | return true; 47 | } 48 | 49 | if (Array.isArray(permissions)) { 50 | if (value & Permission.ADMIN) { 51 | return true; 52 | } 53 | switch (options.type) { 54 | case 'and': 55 | return permissions.every((permission) => !!(value & permission)); 56 | case 'or': 57 | return permissions.some((permission) => !!(value & permission)); 58 | } 59 | } else { 60 | total = permissions; 61 | } 62 | 63 | return !!(value & Permission.ADMIN) || !!(value & total); 64 | }; 65 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Overseerr", 3 | "short_name": "Overseerr", 4 | "start_url": "./", 5 | "icons": [ 6 | { 7 | "src": "./android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png", 10 | "purpose": "any" 11 | }, 12 | { 13 | "src": "./android-chrome-192x192_maskable.png", 14 | "sizes": "192x192", 15 | "type": "image/png", 16 | "purpose": "maskable" 17 | }, 18 | { 19 | "src": "./android-chrome-512x512.png", 20 | "sizes": "512x512", 21 | "type": "image/png", 22 | "purpose": "any" 23 | }, 24 | { 25 | "src": "./android-chrome-512x512_maskable.png", 26 | "sizes": "512x512", 27 | "type": "image/png", 28 | "purpose": "maskable" 29 | } 30 | ], 31 | "theme_color": "#1f2937", 32 | "background_color": "#1f2937", 33 | "display": "standalone", 34 | "shortcuts": [ 35 | { 36 | "name": "Discover", 37 | "url": "./", 38 | "icons": [ 39 | { 40 | "src": "./sparkles-icon-192x192.png", 41 | "sizes": "192x192", 42 | "type": "image/png" 43 | } 44 | ] 45 | }, 46 | { 47 | "name": "Requests", 48 | "url": "./requests", 49 | "icons": [ 50 | { 51 | "src": "./clock-icon-192x192.png", 52 | "sizes": "192x192", 53 | "type": "image/png" 54 | } 55 | ] 56 | }, 57 | { 58 | "name": "Profile", 59 | "url": "./profile", 60 | "icons": [ 61 | { 62 | "src": "./user-icon-192x192.png", 63 | "sizes": "192x192", 64 | "type": "image/png" 65 | } 66 | ] 67 | }, 68 | { 69 | "name": "Settings", 70 | "url": "./profile/settings", 71 | "icons": [ 72 | { 73 | "src": "./cog-icon-192x192.png", 74 | "sizes": "192x192", 75 | "type": "image/png" 76 | } 77 | ] 78 | } 79 | ] 80 | } 81 | --------------------------------------------------------------------------------