├── .eslintrc.js ├── .prettierrc.js ├── .stoplight.json ├── Dockerfile ├── LICENSE ├── babel.config.js ├── cypress.config.ts ├── cypress ├── config │ └── settings.cypress.json ├── e2e │ ├── discover.cy.ts │ ├── login.cy.ts │ ├── movie-details.cy.ts │ ├── pull-to-refresh.cy.ts │ ├── settings │ │ ├── discover-customization.cy.ts │ │ └── general-settings.cy.ts │ ├── tv-details.cy.ts │ └── user │ │ ├── auto-request-settings.cy.ts │ │ ├── profile.cy.ts │ │ └── user-list.cy.ts ├── fixtures │ └── watchlist.json ├── support │ ├── commands.ts │ ├── e2e.ts │ └── index.ts └── tsconfig.json ├── merged-prettier-plugin.js ├── next-env.d.ts ├── next.config.js ├── overseerr-api.yml ├── package.json ├── postcss.config.js ├── public ├── android-chrome-192x192.png ├── android-chrome-192x192_maskable.png ├── android-chrome-512x512.png ├── android-chrome-512x512_maskable.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 ├── apple-touch-icon.png ├── badge-128x128.png ├── clock-icon-192x192.png ├── cog-icon-192x192.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── images │ ├── overseerr_poster_not_found.png │ ├── overseerr_poster_not_found_logo_center.png │ └── overseerr_poster_not_found_logo_top.png ├── logo_full.png ├── logo_full.svg ├── logo_stacked.svg ├── offline.html ├── os_icon.svg ├── site.webmanifest ├── sparkles-icon-192x192.png ├── sw.js └── user-icon-192x192.png ├── renovate.json ├── server ├── api │ ├── animelist.ts │ ├── externalapi.ts │ ├── github.ts │ ├── plexapi.ts │ ├── plextv.ts │ ├── pushover.ts │ ├── rating │ │ ├── imdbRadarrProxy.ts │ │ └── rottentomatoes.ts │ ├── ratings.ts │ ├── servarr │ │ ├── base.ts │ │ ├── radarr.ts │ │ └── sonarr.ts │ ├── tautulli.ts │ └── themoviedb │ │ ├── constants.ts │ │ ├── index.ts │ │ └── interfaces.ts ├── constants │ ├── discover.ts │ ├── issue.ts │ ├── media.ts │ └── user.ts ├── datasource.ts ├── entity │ ├── DiscoverSlider.ts │ ├── Issue.ts │ ├── IssueComment.ts │ ├── Media.ts │ ├── MediaRequest.ts │ ├── Season.ts │ ├── SeasonRequest.ts │ ├── Session.ts │ ├── User.ts │ ├── UserPushSubscription.ts │ └── UserSettings.ts ├── index.ts ├── interfaces │ └── api │ │ ├── common.ts │ │ ├── discoverInterfaces.ts │ │ ├── issueInterfaces.ts │ │ ├── mediaInterfaces.ts │ │ ├── personInterfaces.ts │ │ ├── plexInterfaces.ts │ │ ├── requestInterfaces.ts │ │ ├── serviceInterfaces.ts │ │ ├── settingsInterfaces.ts │ │ ├── userInterfaces.ts │ │ └── userSettingsInterfaces.ts ├── job │ └── schedule.ts ├── lib │ ├── availabilitySync.ts │ ├── cache.ts │ ├── downloadtracker.ts │ ├── email │ │ ├── index.ts │ │ └── openpgpEncrypt.ts │ ├── imageproxy.ts │ ├── notifications │ │ ├── agents │ │ │ ├── agent.ts │ │ │ ├── discord.ts │ │ │ ├── email.ts │ │ │ ├── gotify.ts │ │ │ ├── lunasea.ts │ │ │ ├── pushbullet.ts │ │ │ ├── pushover.ts │ │ │ ├── slack.ts │ │ │ ├── telegram.ts │ │ │ ├── webhook.ts │ │ │ └── webpush.ts │ │ └── index.ts │ ├── permissions.ts │ ├── refreshToken.ts │ ├── scanners │ │ ├── baseScanner.ts │ │ ├── plex │ │ │ └── index.ts │ │ ├── radarr │ │ │ └── index.ts │ │ └── sonarr │ │ │ └── index.ts │ ├── search.ts │ ├── settings.ts │ └── watchlistsync.ts ├── logger.ts ├── middleware │ ├── auth.ts │ └── clearcookies.ts ├── migration │ ├── 1603944374840-InitialMigration.ts │ ├── 1605085519544-SeasonStatus.ts │ ├── 1606730060700-CascadeMigration.ts │ ├── 1607928251245-DropImdbIdConstraint.ts │ ├── 1608217312474-AddUserRequestDeleteCascades.ts │ ├── 1608477467935-AddLastSeasonChangeMedia.ts │ ├── 1608477467936-ForceDropImdbUniqueConstraint.ts │ ├── 1609236552057-RemoveTmdbIdUniqueConstraint.ts │ ├── 1610070934506-LocalUsers.ts │ ├── 1610370640747-Add4kStatusFields.ts │ ├── 1610522845513-AddMediaAddedFieldToMedia.ts │ ├── 1611508672722-AddDisplayNameToUser.ts │ ├── 1611757511674-SonarrRadarrSyncServiceFields.ts │ ├── 1611801511397-AddRatingKeysToMedia.ts │ ├── 1612482778137-AddResetPasswordGuidAndExpiryDate.ts │ ├── 1612571545781-AddLanguageProfileId.ts │ ├── 1613615266968-CreateUserSettings.ts │ ├── 1613955393450-UpdateUserSettingsRegions.ts │ ├── 1614334195680-AddTelegramSettingsToUserSettings.ts │ ├── 1615333940450-AddPGPToUserSettings.ts │ ├── 1616576677254-AddUserQuotaFields.ts │ ├── 1617624225464-CreateTagsFieldonMediaRequest.ts │ ├── 1617730837489-AddUserSettingsNotificationAgentsField.ts │ ├── 1618912653565-CreateUserPushSubscriptions.ts │ ├── 1619239659754-AddUserSettingsLocale.ts │ ├── 1619339817343-AddUserSettingsNotificationTypes.ts │ ├── 1634904083966-AddIssues.ts │ ├── 1635079863457-AddPushbulletPushoverUserSettings.ts │ ├── 1660632269368-AddWatchlistSyncUserSetting.ts │ ├── 1660714479373-AddMediaRequestIsAutoRequestedField.ts │ ├── 1672041273674-AddDiscoverSlider.ts │ ├── 1697393491630-AddUserPushoverSound.ts │ └── 1740717744278-UpdateWebPush.ts ├── models │ ├── Collection.ts │ ├── Movie.ts │ ├── Person.ts │ ├── Search.ts │ ├── Tv.ts │ └── common.ts ├── routes │ ├── auth.ts │ ├── collection.ts │ ├── discover.ts │ ├── imageproxy.ts │ ├── index.ts │ ├── issue.ts │ ├── issueComment.ts │ ├── media.ts │ ├── movie.ts │ ├── person.ts │ ├── request.ts │ ├── search.ts │ ├── service.ts │ ├── settings │ │ ├── discover.ts │ │ ├── index.ts │ │ ├── notifications.ts │ │ ├── radarr.ts │ │ └── sonarr.ts │ ├── tv.ts │ └── user │ │ ├── index.ts │ │ └── usersettings.ts ├── scripts │ └── prepareTestDb.ts ├── subscriber │ ├── IssueCommentSubscriber.ts │ ├── IssueSubscriber.ts │ ├── MediaRequestSubscriber.ts │ └── MediaSubscriber.ts ├── templates │ └── email │ │ ├── generatedpassword │ │ ├── html.pug │ │ └── subject.pug │ │ ├── media-issue │ │ ├── html.pug │ │ └── subject.pug │ │ ├── media-request │ │ ├── html.pug │ │ └── subject.pug │ │ ├── resetpassword │ │ ├── html.pug │ │ └── subject.pug │ │ └── test-email │ │ ├── html.pug │ │ └── subject.pug ├── tsconfig.json ├── types │ ├── express-session.d.ts │ ├── express.d.ts │ └── plex-api.d.ts └── utils │ ├── appDataVolume.ts │ ├── appVersion.ts │ ├── asyncLock.ts │ ├── dateHelpers.ts │ ├── restartFlag.ts │ └── typeHelpers.ts ├── snap └── snapcraft.yaml ├── src ├── assets │ ├── ellipsis.svg │ ├── extlogos │ │ ├── discord.svg │ │ ├── gotify.svg │ │ ├── lunasea.svg │ │ ├── pushbullet.svg │ │ ├── pushover.svg │ │ ├── slack.svg │ │ └── telegram.svg │ ├── infinity.svg │ ├── rt_aud_fresh.svg │ ├── rt_aud_rotten.svg │ ├── rt_fresh.svg │ ├── rt_rotten.svg │ ├── services │ │ ├── imdb.svg │ │ ├── plex.svg │ │ ├── radarr.svg │ │ ├── rt.svg │ │ ├── sonarr.svg │ │ ├── tmdb.svg │ │ ├── trakt.svg │ │ └── tvdb.svg │ ├── spinner.svg │ └── tmdb_logo.svg ├── components │ ├── AirDateBadge │ │ └── index.tsx │ ├── AppDataWarning │ │ └── index.tsx │ ├── CollectionDetails │ │ └── index.tsx │ ├── Common │ │ ├── Accordion │ │ │ └── index.tsx │ │ ├── Alert │ │ │ └── index.tsx │ │ ├── Badge │ │ │ └── index.tsx │ │ ├── Button │ │ │ └── index.tsx │ │ ├── ButtonWithDropdown │ │ │ └── index.tsx │ │ ├── CachedImage │ │ │ └── index.tsx │ │ ├── ConfirmButton │ │ │ └── index.tsx │ │ ├── Header │ │ │ └── index.tsx │ │ ├── ImageFader │ │ │ └── index.tsx │ │ ├── List │ │ │ └── index.tsx │ │ ├── ListView │ │ │ └── index.tsx │ │ ├── LoadingSpinner │ │ │ └── index.tsx │ │ ├── Modal │ │ │ └── index.tsx │ │ ├── MultiRangeSlider │ │ │ └── index.tsx │ │ ├── PageTitle │ │ │ └── index.tsx │ │ ├── PlayButton │ │ │ └── index.tsx │ │ ├── ProgressCircle │ │ │ └── index.tsx │ │ ├── SensitiveInput │ │ │ └── index.tsx │ │ ├── SettingsTabs │ │ │ └── index.tsx │ │ ├── SlideCheckbox │ │ │ └── index.tsx │ │ ├── SlideOver │ │ │ └── index.tsx │ │ ├── StatusBadgeMini │ │ │ └── index.tsx │ │ ├── Table │ │ │ └── index.tsx │ │ ├── Tag │ │ │ └── index.tsx │ │ └── Tooltip │ │ │ └── index.tsx │ ├── CompanyCard │ │ └── index.tsx │ ├── CompanyTag │ │ └── index.tsx │ ├── Discover │ │ ├── CreateSlider │ │ │ └── index.tsx │ │ ├── DiscoverMovieGenre │ │ │ └── index.tsx │ │ ├── DiscoverMovieKeyword │ │ │ └── index.tsx │ │ ├── DiscoverMovieLanguage │ │ │ └── index.tsx │ │ ├── DiscoverMovies │ │ │ └── index.tsx │ │ ├── DiscoverNetwork │ │ │ └── index.tsx │ │ ├── DiscoverSliderEdit │ │ │ └── index.tsx │ │ ├── DiscoverStudio │ │ │ └── index.tsx │ │ ├── DiscoverTv │ │ │ └── index.tsx │ │ ├── DiscoverTvGenre │ │ │ └── index.tsx │ │ ├── DiscoverTvKeyword │ │ │ └── index.tsx │ │ ├── DiscoverTvLanguage │ │ │ └── index.tsx │ │ ├── DiscoverTvUpcoming.tsx │ │ ├── DiscoverWatchlist │ │ │ └── index.tsx │ │ ├── FilterSlideover │ │ │ └── index.tsx │ │ ├── MovieGenreList │ │ │ └── index.tsx │ │ ├── MovieGenreSlider │ │ │ └── index.tsx │ │ ├── NetworkSlider │ │ │ └── index.tsx │ │ ├── PlexWatchlistSlider │ │ │ └── index.tsx │ │ ├── RecentRequestsSlider │ │ │ └── index.tsx │ │ ├── RecentlyAddedSlider │ │ │ └── index.tsx │ │ ├── StudioSlider │ │ │ └── index.tsx │ │ ├── Trending.tsx │ │ ├── TvGenreList │ │ │ └── index.tsx │ │ ├── TvGenreSlider │ │ │ └── index.tsx │ │ ├── Upcoming.tsx │ │ ├── constants.ts │ │ └── index.tsx │ ├── DownloadBlock │ │ └── index.tsx │ ├── ExternalLinkBlock │ │ └── index.tsx │ ├── GenreCard │ │ └── index.tsx │ ├── GenreTag │ │ └── index.tsx │ ├── IssueBlock │ │ └── index.tsx │ ├── IssueDetails │ │ ├── IssueComment │ │ │ └── index.tsx │ │ ├── IssueDescription │ │ │ └── index.tsx │ │ └── index.tsx │ ├── IssueList │ │ ├── IssueItem │ │ │ └── index.tsx │ │ └── index.tsx │ ├── IssueModal │ │ ├── CreateIssueModal │ │ │ └── index.tsx │ │ ├── constants.ts │ │ └── index.tsx │ ├── JSONEditor │ │ └── index.tsx │ ├── KeywordTag │ │ └── index.tsx │ ├── LanguageSelector │ │ └── index.tsx │ ├── Layout │ │ ├── LanguagePicker │ │ │ └── index.tsx │ │ ├── MobileMenu │ │ │ └── index.tsx │ │ ├── Notifications │ │ │ └── index.tsx │ │ ├── PullToRefresh │ │ │ └── index.tsx │ │ ├── SearchInput │ │ │ └── index.tsx │ │ ├── Sidebar │ │ │ └── index.tsx │ │ ├── UserDropdown │ │ │ ├── MiniQuotaDisplay │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── VersionStatus │ │ │ └── index.tsx │ │ └── index.tsx │ ├── LoadingBar │ │ └── index.tsx │ ├── Login │ │ ├── LocalLogin.tsx │ │ └── index.tsx │ ├── ManageSlideOver │ │ └── index.tsx │ ├── MediaSlider │ │ ├── ShowMoreCard │ │ │ └── index.tsx │ │ └── index.tsx │ ├── MovieDetails │ │ ├── MovieCast │ │ │ └── index.tsx │ │ ├── MovieCrew │ │ │ └── index.tsx │ │ ├── MovieRecommendations.tsx │ │ ├── MovieSimilar.tsx │ │ └── index.tsx │ ├── NotificationTypeSelector │ │ ├── NotificationType │ │ │ └── index.tsx │ │ └── index.tsx │ ├── PWAHeader │ │ └── index.tsx │ ├── PermissionEdit │ │ └── index.tsx │ ├── PermissionOption │ │ └── index.tsx │ ├── PersonCard │ │ └── index.tsx │ ├── PersonDetails │ │ └── index.tsx │ ├── PlexLoginButton │ │ └── index.tsx │ ├── QuotaSelector │ │ └── index.tsx │ ├── RegionSelector │ │ └── index.tsx │ ├── RequestBlock │ │ └── index.tsx │ ├── RequestButton │ │ └── index.tsx │ ├── RequestCard │ │ └── index.tsx │ ├── RequestList │ │ ├── RequestItem │ │ │ └── index.tsx │ │ └── index.tsx │ ├── RequestModal │ │ ├── AdvancedRequester │ │ │ └── index.tsx │ │ ├── CollectionRequestModal.tsx │ │ ├── MovieRequestModal.tsx │ │ ├── QuotaDisplay │ │ │ └── index.tsx │ │ ├── SearchByNameModal │ │ │ └── index.tsx │ │ ├── TvRequestModal.tsx │ │ └── index.tsx │ ├── ResetPassword │ │ ├── RequestResetLink.tsx │ │ └── index.tsx │ ├── Search │ │ └── index.tsx │ ├── Selector │ │ └── index.tsx │ ├── ServiceWorkerSetup │ │ └── index.tsx │ ├── Settings │ │ ├── CopyButton.tsx │ │ ├── LibraryItem.tsx │ │ ├── Notifications │ │ │ ├── NotificationsDiscord.tsx │ │ │ ├── NotificationsEmail.tsx │ │ │ ├── NotificationsGotify │ │ │ │ └── index.tsx │ │ │ ├── NotificationsLunaSea │ │ │ │ └── index.tsx │ │ │ ├── NotificationsPushbullet │ │ │ │ └── index.tsx │ │ │ ├── NotificationsPushover │ │ │ │ └── index.tsx │ │ │ ├── NotificationsSlack │ │ │ │ └── index.tsx │ │ │ ├── NotificationsTelegram.tsx │ │ │ ├── NotificationsWebPush │ │ │ │ └── index.tsx │ │ │ └── NotificationsWebhook │ │ │ │ └── index.tsx │ │ ├── RadarrModal │ │ │ └── index.tsx │ │ ├── SettingsAbout │ │ │ ├── Releases │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── SettingsBadge.tsx │ │ ├── SettingsJobsCache │ │ │ └── index.tsx │ │ ├── SettingsLayout.tsx │ │ ├── SettingsLogs │ │ │ └── index.tsx │ │ ├── SettingsMain │ │ │ └── index.tsx │ │ ├── SettingsNotifications.tsx │ │ ├── SettingsPlex.tsx │ │ ├── SettingsServices.tsx │ │ ├── SettingsUsers │ │ │ └── index.tsx │ │ └── SonarrModal │ │ │ └── index.tsx │ ├── Setup │ │ ├── LoginWithPlex.tsx │ │ ├── SetupSteps.tsx │ │ └── index.tsx │ ├── Slider │ │ └── index.tsx │ ├── StatusBadge │ │ └── index.tsx │ ├── StatusChecker │ │ └── index.tsx │ ├── TitleCard │ │ ├── ErrorCard.tsx │ │ ├── Placeholder.tsx │ │ ├── TmdbTitleCard.tsx │ │ └── index.tsx │ ├── Toast │ │ └── index.tsx │ ├── ToastContainer │ │ └── index.tsx │ ├── TvDetails │ │ ├── Season │ │ │ └── index.tsx │ │ ├── TvCast │ │ │ └── index.tsx │ │ ├── TvCrew │ │ │ └── index.tsx │ │ ├── TvRecommendations.tsx │ │ ├── TvSimilar.tsx │ │ └── index.tsx │ ├── UserList │ │ ├── BulkEditModal.tsx │ │ ├── PlexImportModal.tsx │ │ └── index.tsx │ └── UserProfile │ │ ├── ProfileHeader │ │ └── index.tsx │ │ ├── UserSettings │ │ ├── UserGeneralSettings │ │ │ └── index.tsx │ │ ├── UserNotificationSettings │ │ │ ├── UserNotificationsDiscord.tsx │ │ │ ├── UserNotificationsEmail.tsx │ │ │ ├── UserNotificationsPushbullet.tsx │ │ │ ├── UserNotificationsPushover.tsx │ │ │ ├── UserNotificationsTelegram.tsx │ │ │ ├── UserNotificationsWebPush │ │ │ │ ├── DeviceItem.tsx │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── UserPasswordChange │ │ │ └── index.tsx │ │ ├── UserPermissions │ │ │ └── index.tsx │ │ └── index.tsx │ │ └── index.tsx ├── context │ ├── InteractionContext.tsx │ ├── LanguageContext.tsx │ ├── SettingsContext.tsx │ └── UserContext.tsx ├── hooks │ ├── useClickOutside.ts │ ├── useDebouncedState.ts │ ├── useDeepLinks.ts │ ├── useDiscover.ts │ ├── useInteraction.ts │ ├── useIsTouch.ts │ ├── useLocale.ts │ ├── useLockBodyScroll.ts │ ├── useRequestOverride.ts │ ├── useRouteGuard.ts │ ├── useSearchInput.ts │ ├── useSettings.ts │ ├── useUpdateQueryParams.ts │ ├── useUser.ts │ └── useVerticalScroll.ts ├── i18n │ ├── globalMessages.ts │ └── locale │ │ ├── ar.json │ │ ├── bg.json │ │ ├── ca.json │ │ ├── cs.json │ │ ├── da.json │ │ ├── de.json │ │ ├── el.json │ │ ├── en.json │ │ ├── es.json │ │ ├── fi.json │ │ ├── fr.json │ │ ├── he.json │ │ ├── hi.json │ │ ├── hr.json │ │ ├── hu.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── lt.json │ │ ├── nb_NO.json │ │ ├── nl.json │ │ ├── pl.json │ │ ├── pt_BR.json │ │ ├── pt_PT.json │ │ ├── ro.json │ │ ├── ru.json │ │ ├── sl.json │ │ ├── sq.json │ │ ├── sr.json │ │ ├── sv.json │ │ ├── uk.json │ │ ├── zh_Hans.json │ │ └── zh_Hant.json ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── _error.tsx │ ├── collection │ │ └── [collectionId] │ │ │ └── index.tsx │ ├── discover │ │ ├── movies │ │ │ ├── genre │ │ │ │ └── [genreId] │ │ │ │ │ └── index.tsx │ │ │ ├── genres.tsx │ │ │ ├── index.tsx │ │ │ ├── keyword │ │ │ │ └── index.tsx │ │ │ ├── language │ │ │ │ └── [language] │ │ │ │ │ └── index.tsx │ │ │ ├── studio │ │ │ │ └── [studioId] │ │ │ │ │ └── index.tsx │ │ │ └── upcoming.tsx │ │ ├── trending.tsx │ │ ├── tv │ │ │ ├── genre │ │ │ │ └── [genreId] │ │ │ │ │ └── index.tsx │ │ │ ├── genres.tsx │ │ │ ├── index.tsx │ │ │ ├── keyword │ │ │ │ └── index.tsx │ │ │ ├── language │ │ │ │ └── [language] │ │ │ │ │ └── index.tsx │ │ │ ├── network │ │ │ │ └── [networkId] │ │ │ │ │ └── index.tsx │ │ │ └── upcoming.tsx │ │ └── watchlist.tsx │ ├── index.tsx │ ├── issues │ │ ├── [issueId] │ │ │ └── index.tsx │ │ └── index.tsx │ ├── login │ │ ├── index.tsx │ │ └── plex │ │ │ └── loading.tsx │ ├── movie │ │ └── [movieId] │ │ │ ├── cast.tsx │ │ │ ├── crew.tsx │ │ │ ├── index.tsx │ │ │ ├── recommendations.tsx │ │ │ └── similar.tsx │ ├── person │ │ └── [personId] │ │ │ └── index.tsx │ ├── profile │ │ ├── index.tsx │ │ ├── requests.tsx │ │ ├── settings │ │ │ ├── index.tsx │ │ │ ├── main.tsx │ │ │ ├── notifications │ │ │ │ ├── discord.tsx │ │ │ │ ├── email.tsx │ │ │ │ ├── pushbullet.tsx │ │ │ │ ├── pushover.tsx │ │ │ │ ├── telegram.tsx │ │ │ │ └── webpush.tsx │ │ │ ├── password.tsx │ │ │ └── permissions.tsx │ │ └── watchlist.tsx │ ├── requests │ │ └── index.tsx │ ├── resetpassword │ │ ├── [guid] │ │ │ └── index.tsx │ │ └── index.tsx │ ├── search.tsx │ ├── settings │ │ ├── about.tsx │ │ ├── index.tsx │ │ ├── jobs.tsx │ │ ├── logs.tsx │ │ ├── main.tsx │ │ ├── notifications │ │ │ ├── discord.tsx │ │ │ ├── email.tsx │ │ │ ├── gotify.tsx │ │ │ ├── lunasea.tsx │ │ │ ├── pushbullet.tsx │ │ │ ├── pushover.tsx │ │ │ ├── slack.tsx │ │ │ ├── telegram.tsx │ │ │ ├── webhook.tsx │ │ │ └── webpush.tsx │ │ ├── plex.tsx │ │ ├── services.tsx │ │ └── users.tsx │ ├── setup.tsx │ ├── tv │ │ └── [tvId] │ │ │ ├── cast.tsx │ │ │ ├── crew.tsx │ │ │ ├── index.tsx │ │ │ ├── recommendations.tsx │ │ │ └── similar.tsx │ └── users │ │ ├── [userId] │ │ ├── index.tsx │ │ ├── requests.tsx │ │ ├── settings │ │ │ ├── index.tsx │ │ │ ├── main.tsx │ │ │ ├── notifications │ │ │ │ ├── discord.tsx │ │ │ │ ├── email.tsx │ │ │ │ ├── pushbullet.tsx │ │ │ │ ├── pushover.tsx │ │ │ │ ├── telegram.tsx │ │ │ │ └── webpush.tsx │ │ │ ├── password.tsx │ │ │ └── permissions.tsx │ │ └── watchlist.tsx │ │ └── index.tsx ├── styles │ └── globals.css ├── types │ ├── custom.d.ts │ └── react-intl-auto.d.ts └── utils │ ├── creditHelpers.ts │ ├── numberHelpers.ts │ ├── plex.ts │ ├── polyfillIntl.ts │ ├── refreshIntervalHelper.ts │ └── typeHelpers.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('./merged-prettier-plugin.js')], 3 | singleQuote: true, 4 | trailingComma: 'es5', 5 | }; 6 | -------------------------------------------------------------------------------- /.stoplight.json: -------------------------------------------------------------------------------- 1 | { 2 | "formats": { 3 | "openapi": { 4 | "rootDir": ".", 5 | "include": ["**"] 6 | } 7 | }, 8 | "exclude": ["docs"] 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.18.2-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' | 'linux/arm/v7') \ 11 | apk update && \ 12 | apk add --no-cache python3 make g++ gcc libc6-compat bash && \ 13 | yarn global add node-gyp \ 14 | ;; \ 15 | esac 16 | 17 | COPY package.json yarn.lock ./ 18 | RUN CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000 19 | 20 | COPY . ./ 21 | 22 | ARG COMMIT_TAG 23 | ENV COMMIT_TAG=${COMMIT_TAG} 24 | 25 | RUN yarn build 26 | 27 | # remove development dependencies 28 | RUN yarn install --production --ignore-scripts --prefer-offline 29 | 30 | RUN rm -rf src server .next/cache 31 | 32 | RUN touch config/DOCKER 33 | 34 | RUN echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json 35 | 36 | 37 | FROM node:18.18.2-alpine 38 | 39 | WORKDIR /app 40 | 41 | RUN apk add --no-cache tzdata tini && rm -rf /tmp/* 42 | 43 | # copy from build image 44 | COPY --from=BUILD_IMAGE /app ./ 45 | 46 | ENTRYPOINT [ "/sbin/tini", "--" ] 47 | CMD [ "yarn", "start" ] 48 | 49 | EXPOSE 5055 50 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | projectId: 'onnqy3', 5 | e2e: { 6 | baseUrl: 'http://localhost:5055', 7 | experimentalSessionAndOrigin: true, 8 | }, 9 | env: { 10 | ADMIN_EMAIL: 'admin@seerr.dev', 11 | ADMIN_PASSWORD: 'test1234', 12 | USER_EMAIL: 'friend@seerr.dev', 13 | USER_PASSWORD: 'test1234', 14 | }, 15 | retries: { 16 | runMode: 2, 17 | openMode: 0, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /cypress/e2e/login.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Login Page', () => { 2 | it('succesfully logs in as an admin', () => { 3 | cy.loginAsAdmin(); 4 | cy.visit('/'); 5 | cy.contains('Trending'); 6 | }); 7 | 8 | it('succesfully logs in as a local user', () => { 9 | cy.loginAsUser(); 10 | cy.visit('/'); 11 | cy.contains('Trending'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /cypress/e2e/movie-details.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Movie Details', () => { 2 | it('loads a movie page', () => { 3 | cy.loginAsAdmin(); 4 | // Try to load minions: rise of gru 5 | cy.visit('/movie/438148'); 6 | 7 | cy.get('[data-testid=media-title]').should( 8 | 'contain', 9 | 'Minions: The Rise of Gru (2022)' 10 | ); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /cypress/e2e/pull-to-refresh.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Pull To Refresh', () => { 2 | beforeEach(() => { 3 | cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); 4 | cy.viewport(390, 844); 5 | cy.visitMobile('/'); 6 | }); 7 | 8 | it('reloads the current page', () => { 9 | cy.wait(500); 10 | 11 | cy.intercept({ 12 | method: 'GET', 13 | url: '/api/v1/*', 14 | }).as('apiCall'); 15 | 16 | cy.get('.searchbar').swipe('bottom', [190, 500]); 17 | 18 | cy.wait('@apiCall').then((interception) => { 19 | assert.isNotNull( 20 | interception.response.body, 21 | 'API was called and received data' 22 | ); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /cypress/e2e/settings/general-settings.cy.ts: -------------------------------------------------------------------------------- 1 | describe('General Settings', () => { 2 | beforeEach(() => { 3 | cy.loginAsAdmin(); 4 | }); 5 | 6 | it('opens the settings page from the home page', () => { 7 | cy.visit('/'); 8 | 9 | cy.get('[data-testid=sidebar-toggle]').click(); 10 | cy.get('[data-testid=sidebar-menu-settings-mobile]').click(); 11 | 12 | cy.get('.heading').should('contain', 'General Settings'); 13 | }); 14 | 15 | it('modifies setting that requires restart', () => { 16 | cy.visit('/settings'); 17 | 18 | cy.get('#trustProxy').click(); 19 | cy.get('[data-testid=settings-main-form]').submit(); 20 | cy.get('[data-testid=modal-title]').should( 21 | 'contain', 22 | 'Server Restart Required' 23 | ); 24 | 25 | cy.get('[data-testid=modal-ok-button]').click(); 26 | cy.get('[data-testid=modal-title]').should('not.exist'); 27 | 28 | cy.get('[type=checkbox]#trustProxy').click(); 29 | cy.get('[data-testid=settings-main-form]').submit(); 30 | cy.get('[data-testid=modal-title]').should('not.exist'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /cypress/e2e/tv-details.cy.ts: -------------------------------------------------------------------------------- 1 | describe('TV Details', () => { 2 | it('loads a tv details page', () => { 3 | cy.loginAsAdmin(); 4 | // Try to load stranger things 5 | cy.visit('/tv/66732'); 6 | 7 | cy.get('[data-testid=media-title]').should( 8 | 'contain', 9 | 'Stranger Things (2016)' 10 | ); 11 | }); 12 | 13 | it('shows seasons and expands episodes', () => { 14 | cy.loginAsAdmin(); 15 | 16 | // Try to load stranger things 17 | cy.visit('/tv/66732'); 18 | 19 | // intercept request for season info 20 | cy.intercept('/api/v1/tv/66732/season/4').as('season4'); 21 | 22 | cy.contains('Season 4').should('be.visible').scrollIntoView().click(); 23 | 24 | cy.wait('@season4'); 25 | 26 | cy.contains('Chapter Nine').should('be.visible'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /cypress/e2e/user/profile.cy.ts: -------------------------------------------------------------------------------- 1 | describe('User Profile', () => { 2 | beforeEach(() => { 3 | cy.loginAsAdmin(); 4 | }); 5 | 6 | it('opens user profile page from the home page', () => { 7 | cy.visit('/'); 8 | 9 | cy.get('[data-testid=user-menu]').click(); 10 | cy.get('[data-testid=user-menu-profile]').click(); 11 | 12 | cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL')); 13 | }); 14 | 15 | it('loads plex watchlist', () => { 16 | cy.intercept('/api/v1/user/[0-9]*/watchlist', { 17 | fixture: 'watchlist.json', 18 | }).as('getWatchlist'); 19 | // Wait for one of the watchlist movies to resolve 20 | cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); 21 | 22 | cy.visit('/profile'); 23 | 24 | cy.wait('@getWatchlist'); 25 | 26 | const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist'); 27 | 28 | sliderHeader.scrollIntoView(); 29 | 30 | cy.wait('@getTmdbMovie'); 31 | // Wait a little longer to make sure the movie component reloaded 32 | cy.wait(500); 33 | 34 | sliderHeader 35 | .next('[data-testid=media-slider]') 36 | .find('[data-testid=title-card]') 37 | .first() 38 | .trigger('mouseover') 39 | .find('[data-testid=title-card-title]') 40 | .invoke('text') 41 | .then((text) => { 42 | cy.contains('.slider-header', 'Plex Watchlist') 43 | .next('[data-testid=media-slider]') 44 | .find('[data-testid=title-card]') 45 | .first() 46 | .click(); 47 | cy.get('[data-testid=media-title]').should('contain', text); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /cypress/fixtures/watchlist.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "totalPages": 1, 4 | "totalResults": 3, 5 | "results": [ 6 | { 7 | "ratingKey": "5d776be17a53e9001e732ab9", 8 | "title": "Top Gun: Maverick", 9 | "mediaType": "movie", 10 | "tmdbId": 361743 11 | }, 12 | { 13 | "ratingKey": "5e16338fbc1372003ea68ab3", 14 | "title": "Nope", 15 | "mediaType": "movie", 16 | "tmdbId": 762504 17 | }, 18 | { 19 | "ratingKey": "5f409b8452f200004161e126", 20 | "title": "Hocus Pocus 2", 21 | "mediaType": "movie", 22 | "tmdbId": 642885 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import 'cy-mobile-commands'; 3 | 4 | Cypress.Commands.add('login', (email, password) => { 5 | cy.session( 6 | [email, password], 7 | () => { 8 | cy.visit('/login'); 9 | cy.contains('Use your Overseerr account').click(); 10 | 11 | cy.get('[data-testid=email]').type(email); 12 | cy.get('[data-testid=password]').type(password); 13 | 14 | cy.intercept('/api/v1/auth/local').as('localLogin'); 15 | cy.get('[data-testid=local-signin-button]').click(); 16 | 17 | cy.wait('@localLogin'); 18 | 19 | cy.url().should('contain', '/'); 20 | }, 21 | { 22 | validate() { 23 | cy.request('/api/v1/auth/me').its('status').should('eq', 200); 24 | }, 25 | } 26 | ); 27 | }); 28 | 29 | Cypress.Commands.add('loginAsAdmin', () => { 30 | cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); 31 | }); 32 | 33 | Cypress.Commands.add('loginAsUser', () => { 34 | cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD')); 35 | }); 36 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | import './commands'; 2 | 3 | before(() => { 4 | if (Cypress.env('SEED_DATABASE')) { 5 | cy.exec('yarn cypress:prepare'); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /cypress/support/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | /// 3 | 4 | declare global { 5 | namespace Cypress { 6 | interface Chainable { 7 | login(email?: string, password?: string): Chainable; 8 | loginAsAdmin(): Chainable; 9 | loginAsUser(): Chainable; 10 | } 11 | } 12 | } 13 | 14 | export {}; 15 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es5", "dom"], 5 | "types": ["cypress", "node"], 6 | "resolveJsonModule": true, 7 | "esModuleInterop": true 8 | }, 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /merged-prettier-plugin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const tailwind = require('prettier-plugin-tailwindcss'); 3 | const organizeImports = require('prettier-plugin-organize-imports'); 4 | 5 | const combinedFormatter = { 6 | ...tailwind, 7 | parsers: { 8 | ...tailwind.parsers, 9 | ...Object.keys(organizeImports.parsers).reduce((acc, key) => { 10 | acc[key] = { 11 | ...tailwind.parsers[key], 12 | preprocess(code, options) { 13 | return organizeImports.parsers[key].preprocess(code, options); 14 | }, 15 | }; 16 | return acc; 17 | }, {}), 18 | }, 19 | }; 20 | 21 | module.exports = combinedFormatter; 22 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('next').NextConfig} 3 | */ 4 | module.exports = { 5 | env: { 6 | commitTag: process.env.COMMIT_TAG || 'local', 7 | }, 8 | images: { 9 | domains: ['image.tmdb.org'], 10 | }, 11 | webpack(config) { 12 | config.module.rules.push({ 13 | test: /\.svg$/, 14 | issuer: /\.(js|ts)x?$/, 15 | use: ['@svgr/webpack'], 16 | }); 17 | 18 | return config; 19 | }, 20 | experimental: { 21 | scrollRestoration: true, 22 | largePageDataBytes: 256000, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-192x192_maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/android-chrome-192x192_maskable.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/android-chrome-512x512_maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/android-chrome-512x512_maskable.png -------------------------------------------------------------------------------- /public/apple-splash-1125-2436.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1125-2436.jpg -------------------------------------------------------------------------------- /public/apple-splash-1136-640.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1136-640.jpg -------------------------------------------------------------------------------- /public/apple-splash-1170-2532.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1170-2532.jpg -------------------------------------------------------------------------------- /public/apple-splash-1242-2208.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1242-2208.jpg -------------------------------------------------------------------------------- /public/apple-splash-1242-2688.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1242-2688.jpg -------------------------------------------------------------------------------- /public/apple-splash-1284-2778.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1284-2778.jpg -------------------------------------------------------------------------------- /public/apple-splash-1334-750.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1334-750.jpg -------------------------------------------------------------------------------- /public/apple-splash-1536-2048.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1536-2048.jpg -------------------------------------------------------------------------------- /public/apple-splash-1620-2160.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1620-2160.jpg -------------------------------------------------------------------------------- /public/apple-splash-1668-2224.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1668-2224.jpg -------------------------------------------------------------------------------- /public/apple-splash-1668-2388.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1668-2388.jpg -------------------------------------------------------------------------------- /public/apple-splash-1792-828.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-1792-828.jpg -------------------------------------------------------------------------------- /public/apple-splash-2048-1536.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2048-1536.jpg -------------------------------------------------------------------------------- /public/apple-splash-2048-2732.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2048-2732.jpg -------------------------------------------------------------------------------- /public/apple-splash-2160-1620.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2160-1620.jpg -------------------------------------------------------------------------------- /public/apple-splash-2208-1242.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2208-1242.jpg -------------------------------------------------------------------------------- /public/apple-splash-2224-1668.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2224-1668.jpg -------------------------------------------------------------------------------- /public/apple-splash-2388-1668.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2388-1668.jpg -------------------------------------------------------------------------------- /public/apple-splash-2436-1125.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2436-1125.jpg -------------------------------------------------------------------------------- /public/apple-splash-2532-1170.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2532-1170.jpg -------------------------------------------------------------------------------- /public/apple-splash-2688-1242.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2688-1242.jpg -------------------------------------------------------------------------------- /public/apple-splash-2732-2048.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2732-2048.jpg -------------------------------------------------------------------------------- /public/apple-splash-2778-1284.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-2778-1284.jpg -------------------------------------------------------------------------------- /public/apple-splash-640-1136.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-640-1136.jpg -------------------------------------------------------------------------------- /public/apple-splash-750-1334.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-750-1334.jpg -------------------------------------------------------------------------------- /public/apple-splash-828-1792.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-splash-828-1792.jpg -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/badge-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/badge-128x128.png -------------------------------------------------------------------------------- /public/clock-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/clock-icon-192x192.png -------------------------------------------------------------------------------- /public/cog-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/cog-icon-192x192.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/favicon.ico -------------------------------------------------------------------------------- /public/images/overseerr_poster_not_found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/images/overseerr_poster_not_found.png -------------------------------------------------------------------------------- /public/images/overseerr_poster_not_found_logo_center.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/images/overseerr_poster_not_found_logo_center.png -------------------------------------------------------------------------------- /public/images/overseerr_poster_not_found_logo_top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/images/overseerr_poster_not_found_logo_top.png -------------------------------------------------------------------------------- /public/logo_full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/logo_full.png -------------------------------------------------------------------------------- /public/os_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/sparkles-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/sparkles-icon-192x192.png -------------------------------------------------------------------------------- /public/user-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sct/overseerr/07dc8d755a0e94d100ecd8b1e950e43da1c0a7dd/public/user-icon-192x192.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:js-app", 5 | "group:allNonMajor", 6 | "docker:disableMajor", 7 | "helpers:disableTypesNodeMajor" 8 | ], 9 | "packageRules": [ 10 | { 11 | "matchManagers": ["github-actions"], 12 | "groupName": "GitHub Actions", 13 | "groupSlug": "github-actions" 14 | }, 15 | { 16 | "matchPackageNames": ["node"], 17 | "groupName": "Node.js", 18 | "groupSlug": "node" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /server/api/pushover.ts: -------------------------------------------------------------------------------- 1 | import ExternalAPI from './externalapi'; 2 | 3 | interface PushoverSoundsResponse { 4 | sounds: { 5 | [name: string]: string; 6 | }; 7 | status: number; 8 | request: string; 9 | } 10 | 11 | export interface PushoverSound { 12 | name: string; 13 | description: string; 14 | } 15 | 16 | export const mapSounds = (sounds: { 17 | [name: string]: string; 18 | }): PushoverSound[] => 19 | Object.entries(sounds).map( 20 | ([name, description]) => 21 | ({ 22 | name, 23 | description, 24 | } as PushoverSound) 25 | ); 26 | 27 | class PushoverAPI extends ExternalAPI { 28 | constructor() { 29 | super( 30 | 'https://api.pushover.net/1', 31 | {}, 32 | { 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | Accept: 'application/json', 36 | }, 37 | } 38 | ); 39 | } 40 | 41 | public async getSounds(appToken: string): Promise { 42 | try { 43 | const data = await this.get('/sounds.json', { 44 | params: { 45 | token: appToken, 46 | }, 47 | }); 48 | 49 | return mapSounds(data.sounds); 50 | } catch (e) { 51 | throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`); 52 | } 53 | } 54 | } 55 | 56 | export default PushoverAPI; 57 | -------------------------------------------------------------------------------- /server/api/ratings.ts: -------------------------------------------------------------------------------- 1 | import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy'; 2 | import { type RTRating } from '@server/api/rating/rottentomatoes'; 3 | 4 | export interface RatingResponse { 5 | rt?: RTRating; 6 | imdb?: IMDBRating; 7 | } 8 | -------------------------------------------------------------------------------- /server/api/themoviedb/constants.ts: -------------------------------------------------------------------------------- 1 | export const ANIME_KEYWORD_ID = 210024; 2 | -------------------------------------------------------------------------------- /server/constants/issue.ts: -------------------------------------------------------------------------------- 1 | export enum IssueType { 2 | VIDEO = 1, 3 | AUDIO = 2, 4 | SUBTITLES = 3, 5 | OTHER = 4, 6 | } 7 | 8 | export enum IssueStatus { 9 | OPEN = 1, 10 | RESOLVED = 2, 11 | } 12 | 13 | export const IssueTypeName = { 14 | [IssueType.AUDIO]: 'Audio', 15 | [IssueType.VIDEO]: 'Video', 16 | [IssueType.SUBTITLES]: 'Subtitle', 17 | [IssueType.OTHER]: 'Other', 18 | }; 19 | -------------------------------------------------------------------------------- /server/constants/media.ts: -------------------------------------------------------------------------------- 1 | export enum MediaRequestStatus { 2 | PENDING = 1, 3 | APPROVED, 4 | DECLINED, 5 | FAILED, 6 | COMPLETED, 7 | } 8 | 9 | export enum MediaType { 10 | MOVIE = 'movie', 11 | TV = 'tv', 12 | } 13 | 14 | export enum MediaStatus { 15 | UNKNOWN = 1, 16 | PENDING, 17 | PROCESSING, 18 | PARTIALLY_AVAILABLE, 19 | AVAILABLE, 20 | DELETED, 21 | } 22 | -------------------------------------------------------------------------------- /server/constants/user.ts: -------------------------------------------------------------------------------- 1 | export enum UserType { 2 | PLEX = 1, 3 | LOCAL = 2, 4 | } 5 | -------------------------------------------------------------------------------- /server/datasource.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm'; 3 | import { DataSource } from 'typeorm'; 4 | 5 | const devConfig: DataSourceOptions = { 6 | type: 'sqlite', 7 | database: process.env.CONFIG_DIRECTORY 8 | ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` 9 | : 'config/db/db.sqlite3', 10 | synchronize: true, 11 | migrationsRun: false, 12 | logging: false, 13 | enableWAL: true, 14 | entities: ['server/entity/**/*.ts'], 15 | migrations: ['server/migration/**/*.ts'], 16 | subscribers: ['server/subscriber/**/*.ts'], 17 | }; 18 | 19 | const prodConfig: DataSourceOptions = { 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 | migrationsRun: false, 26 | logging: false, 27 | enableWAL: true, 28 | entities: ['dist/entity/**/*.js'], 29 | migrations: ['dist/migration/**/*.js'], 30 | subscribers: ['dist/subscriber/**/*.js'], 31 | }; 32 | 33 | const dataSource = new DataSource( 34 | process.env.NODE_ENV !== 'production' ? devConfig : prodConfig 35 | ); 36 | 37 | export const getRepository = ( 38 | target: EntityTarget 39 | ): Repository => { 40 | return dataSource.getRepository(target); 41 | }; 42 | 43 | export default dataSource; 44 | -------------------------------------------------------------------------------- /server/entity/DiscoverSlider.ts: -------------------------------------------------------------------------------- 1 | import type { DiscoverSliderType } from '@server/constants/discover'; 2 | import { defaultSliders } from '@server/constants/discover'; 3 | import { getRepository } from '@server/datasource'; 4 | import logger from '@server/logger'; 5 | import { 6 | Column, 7 | CreateDateColumn, 8 | Entity, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | 13 | @Entity() 14 | class DiscoverSlider { 15 | public static async bootstrapSliders(): Promise { 16 | const sliderRepository = getRepository(DiscoverSlider); 17 | 18 | for (const slider of defaultSliders) { 19 | const existingSlider = await sliderRepository.findOne({ 20 | where: { 21 | type: slider.type, 22 | }, 23 | }); 24 | 25 | if (!existingSlider) { 26 | logger.info('Creating built-in discovery slider', { 27 | label: 'Discover Slider', 28 | slider, 29 | }); 30 | await sliderRepository.save(new DiscoverSlider(slider)); 31 | } 32 | } 33 | } 34 | 35 | @PrimaryGeneratedColumn() 36 | public id: number; 37 | 38 | @Column({ type: 'int' }) 39 | public type: DiscoverSliderType; 40 | 41 | @Column({ type: 'int' }) 42 | public order: number; 43 | 44 | @Column({ default: false }) 45 | public isBuiltIn: boolean; 46 | 47 | @Column({ default: true }) 48 | public enabled: boolean; 49 | 50 | @Column({ nullable: true }) 51 | // Title is not required for built in sliders because we will 52 | // use translations for them. 53 | public title?: string; 54 | 55 | @Column({ nullable: true }) 56 | public data?: string; 57 | 58 | @CreateDateColumn() 59 | public createdAt: Date; 60 | 61 | @UpdateDateColumn() 62 | public updatedAt: Date; 63 | 64 | constructor(init?: Partial) { 65 | Object.assign(this, init); 66 | } 67 | } 68 | 69 | export default DiscoverSlider; 70 | -------------------------------------------------------------------------------- /server/entity/Issue.ts: -------------------------------------------------------------------------------- 1 | import type { IssueType } from '@server/constants/issue'; 2 | import { IssueStatus } from '@server/constants/issue'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | ManyToOne, 8 | OneToMany, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | import IssueComment from './IssueComment'; 13 | import Media from './Media'; 14 | import { User } from './User'; 15 | 16 | @Entity() 17 | class Issue { 18 | @PrimaryGeneratedColumn() 19 | public id: number; 20 | 21 | @Column({ type: 'int' }) 22 | public issueType: IssueType; 23 | 24 | @Column({ type: 'int', default: IssueStatus.OPEN }) 25 | public status: IssueStatus; 26 | 27 | @Column({ type: 'int', default: 0 }) 28 | public problemSeason: number; 29 | 30 | @Column({ type: 'int', default: 0 }) 31 | public problemEpisode: number; 32 | 33 | @ManyToOne(() => Media, (media) => media.issues, { 34 | eager: true, 35 | onDelete: 'CASCADE', 36 | }) 37 | public media: Media; 38 | 39 | @ManyToOne(() => User, (user) => user.createdIssues, { 40 | eager: true, 41 | onDelete: 'CASCADE', 42 | }) 43 | public createdBy: User; 44 | 45 | @ManyToOne(() => User, { 46 | eager: true, 47 | onDelete: 'CASCADE', 48 | nullable: true, 49 | }) 50 | public modifiedBy?: User; 51 | 52 | @OneToMany(() => IssueComment, (comment) => comment.issue, { 53 | cascade: true, 54 | eager: true, 55 | }) 56 | public comments: IssueComment[]; 57 | 58 | @CreateDateColumn() 59 | public createdAt: Date; 60 | 61 | @UpdateDateColumn() 62 | public updatedAt: Date; 63 | 64 | constructor(init?: Partial) { 65 | Object.assign(this, init); 66 | } 67 | } 68 | 69 | export default Issue; 70 | -------------------------------------------------------------------------------- /server/entity/IssueComment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | UpdateDateColumn, 8 | } from 'typeorm'; 9 | import Issue from './Issue'; 10 | import { User } from './User'; 11 | 12 | @Entity() 13 | class IssueComment { 14 | @PrimaryGeneratedColumn() 15 | public id: number; 16 | 17 | @ManyToOne(() => User, { 18 | eager: true, 19 | onDelete: 'CASCADE', 20 | }) 21 | public user: User; 22 | 23 | @ManyToOne(() => Issue, (issue) => issue.comments, { 24 | onDelete: 'CASCADE', 25 | }) 26 | public issue: Issue; 27 | 28 | @Column({ type: 'text' }) 29 | public message: string; 30 | 31 | @CreateDateColumn() 32 | public createdAt: Date; 33 | 34 | @UpdateDateColumn() 35 | public updatedAt: Date; 36 | 37 | constructor(init?: Partial) { 38 | Object.assign(this, init); 39 | } 40 | } 41 | 42 | export default IssueComment; 43 | -------------------------------------------------------------------------------- /server/entity/Season.ts: -------------------------------------------------------------------------------- 1 | import { MediaStatus } from '@server/constants/media'; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 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/entity/SeasonRequest.ts: -------------------------------------------------------------------------------- 1 | import { MediaRequestStatus } from '@server/constants/media'; 2 | import { 3 | Column, 4 | CreateDateColumn, 5 | Entity, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm'; 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 | -------------------------------------------------------------------------------- /server/entity/Session.ts: -------------------------------------------------------------------------------- 1 | import type { ISession } from 'connect-typeorm'; 2 | import { Column, Entity, Index, PrimaryColumn } 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 | -------------------------------------------------------------------------------- /server/entity/UserPushSubscription.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | import { User } from './User'; 9 | 10 | @Entity() 11 | export class UserPushSubscription { 12 | @PrimaryGeneratedColumn() 13 | public id: number; 14 | 15 | @ManyToOne(() => User, (user) => user.pushSubscriptions, { 16 | eager: true, 17 | onDelete: 'CASCADE', 18 | }) 19 | public user: User; 20 | 21 | @Column() 22 | public endpoint: string; 23 | 24 | @Column() 25 | public p256dh: string; 26 | 27 | @Column() 28 | public auth: string; 29 | 30 | @Column({ nullable: true }) 31 | public userAgent: string; 32 | 33 | @CreateDateColumn({ nullable: true }) 34 | public createdAt: Date; 35 | 36 | constructor(init?: Partial) { 37 | Object.assign(this, init); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /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/discoverInterfaces.ts: -------------------------------------------------------------------------------- 1 | export interface GenreSliderItem { 2 | id: number; 3 | name: string; 4 | backdrops: string[]; 5 | } 6 | 7 | export interface WatchlistItem { 8 | ratingKey: string; 9 | tmdbId: number; 10 | mediaType: 'movie' | 'tv'; 11 | title: string; 12 | } 13 | 14 | export interface WatchlistResponse { 15 | page: number; 16 | totalPages: number; 17 | totalResults: number; 18 | results: WatchlistItem[]; 19 | } 20 | -------------------------------------------------------------------------------- /server/interfaces/api/issueInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type Issue from '@server/entity/Issue'; 2 | import type { PaginatedResponse } from './common'; 3 | 4 | export interface IssueResultsResponse extends PaginatedResponse { 5 | results: Issue[]; 6 | } 7 | -------------------------------------------------------------------------------- /server/interfaces/api/mediaInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type Media from '@server/entity/Media'; 2 | import type { User } from '@server/entity/User'; 3 | import type { PaginatedResponse } from './common'; 4 | 5 | export interface MediaResultsResponse extends PaginatedResponse { 6 | results: Media[]; 7 | } 8 | 9 | export interface MediaWatchDataResponse { 10 | data?: { 11 | users: User[]; 12 | playCount: number; 13 | playCount7Days: number; 14 | playCount30Days: number; 15 | }; 16 | data4k?: { 17 | users: User[]; 18 | playCount: number; 19 | playCount7Days: number; 20 | playCount30Days: number; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /server/interfaces/api/personInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person'; 2 | 3 | export interface PersonCombinedCreditsResponse { 4 | id: number; 5 | cast: PersonCreditCast[]; 6 | crew: PersonCreditCrew[]; 7 | } 8 | -------------------------------------------------------------------------------- /server/interfaces/api/plexInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type { PlexSettings } from '@server/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 | -------------------------------------------------------------------------------- /server/interfaces/api/requestInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type { MediaType } from '@server/constants/media'; 2 | import type { MediaRequest } from '@server/entity/MediaRequest'; 3 | import type { PaginatedResponse } from './common'; 4 | 5 | export interface RequestResultsResponse extends PaginatedResponse { 6 | results: MediaRequest[]; 7 | } 8 | 9 | export type MediaRequestBody = { 10 | mediaType: MediaType; 11 | mediaId: number; 12 | tvdbId?: number; 13 | seasons?: number[] | 'all'; 14 | is4k?: boolean; 15 | serverId?: number; 16 | profileId?: number; 17 | rootFolder?: string; 18 | languageProfileId?: number; 19 | userId?: number; 20 | tags?: number[]; 21 | }; 22 | -------------------------------------------------------------------------------- /server/interfaces/api/serviceInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base'; 2 | import type { LanguageProfile } from '@server/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 | -------------------------------------------------------------------------------- /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 | appDataPath: string; 21 | } 22 | 23 | export interface PublicSettingsResponse { 24 | initialized: boolean; 25 | applicationTitle: string; 26 | applicationUrl: string; 27 | hideAvailable: boolean; 28 | localLogin: boolean; 29 | movie4kEnabled: boolean; 30 | series4kEnabled: boolean; 31 | region: string; 32 | originalLanguage: string; 33 | partialRequestsEnabled: boolean; 34 | cacheImages: boolean; 35 | vapidPublic: string; 36 | enablePushRegistration: boolean; 37 | locale: string; 38 | emailEnabled: boolean; 39 | newPlexLogin: boolean; 40 | } 41 | 42 | export interface CacheItem { 43 | id: string; 44 | name: string; 45 | stats: { 46 | hits: number; 47 | misses: number; 48 | keys: number; 49 | ksize: number; 50 | vsize: number; 51 | }; 52 | } 53 | 54 | export interface CacheResponse { 55 | apiCaches: CacheItem[]; 56 | imageCache: Record<'tmdb', { size: number; imageCount: number }>; 57 | } 58 | 59 | export interface StatusResponse { 60 | version: string; 61 | commitTag: string; 62 | updateAvailable: boolean; 63 | commitsBehind: number; 64 | restartRequired: boolean; 65 | } 66 | -------------------------------------------------------------------------------- /server/interfaces/api/userInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type Media from '@server/entity/Media'; 2 | import type { MediaRequest } from '@server/entity/MediaRequest'; 3 | import type { User } from '@server/entity/User'; 4 | import type { PaginatedResponse } from './common'; 5 | 6 | export interface UserResultsResponse extends PaginatedResponse { 7 | results: User[]; 8 | } 9 | 10 | export interface UserRequestsResponse extends PaginatedResponse { 11 | results: MediaRequest[]; 12 | } 13 | 14 | export interface QuotaStatus { 15 | days?: number; 16 | limit?: number; 17 | used: number; 18 | remaining?: number; 19 | restricted: boolean; 20 | } 21 | 22 | export interface QuotaResponse { 23 | movie: QuotaStatus; 24 | tv: QuotaStatus; 25 | } 26 | 27 | export interface UserWatchDataResponse { 28 | recentlyWatched: Media[]; 29 | playCount: number; 30 | } 31 | -------------------------------------------------------------------------------- /server/interfaces/api/userSettingsInterfaces.ts: -------------------------------------------------------------------------------- 1 | import type { NotificationAgentKey } from '@server/lib/settings'; 2 | 3 | export interface UserSettingsGeneralResponse { 4 | username?: string; 5 | discordId?: string; 6 | locale?: string; 7 | region?: string; 8 | originalLanguage?: string; 9 | movieQuotaLimit?: number; 10 | movieQuotaDays?: number; 11 | tvQuotaLimit?: number; 12 | tvQuotaDays?: number; 13 | globalMovieQuotaDays?: number; 14 | globalMovieQuotaLimit?: number; 15 | globalTvQuotaLimit?: number; 16 | globalTvQuotaDays?: number; 17 | watchlistSyncMovies?: boolean; 18 | watchlistSyncTv?: boolean; 19 | } 20 | 21 | export type NotificationAgentTypes = Record; 22 | export interface UserSettingsNotificationsResponse { 23 | emailEnabled?: boolean; 24 | pgpKey?: string; 25 | discordEnabled?: boolean; 26 | discordEnabledTypes?: number; 27 | discordId?: string; 28 | pushbulletAccessToken?: string; 29 | pushoverApplicationToken?: string; 30 | pushoverUserKey?: string; 31 | pushoverSound?: string; 32 | telegramEnabled?: boolean; 33 | telegramBotUsername?: string; 34 | telegramChatId?: string; 35 | telegramSendSilently?: boolean; 36 | webPushEnabled?: boolean; 37 | notificationTypes: Partial; 38 | } 39 | -------------------------------------------------------------------------------- /server/lib/email/index.ts: -------------------------------------------------------------------------------- 1 | import type { NotificationAgentEmail } from '@server/lib/settings'; 2 | import { getSettings } from '@server/lib/settings'; 3 | import Email from 'email-templates'; 4 | import nodemailer from 'nodemailer'; 5 | import { URL } from 'url'; 6 | import { openpgpEncrypt } from './openpgpEncrypt'; 7 | 8 | class PreparedEmail extends Email { 9 | public constructor(settings: NotificationAgentEmail, pgpKey?: string) { 10 | const { applicationUrl } = getSettings().main; 11 | 12 | const transport = nodemailer.createTransport({ 13 | name: applicationUrl ? new URL(applicationUrl).hostname : undefined, 14 | host: settings.options.smtpHost, 15 | port: settings.options.smtpPort, 16 | secure: settings.options.secure, 17 | ignoreTLS: settings.options.ignoreTls, 18 | requireTLS: settings.options.requireTls, 19 | tls: settings.options.allowSelfSigned 20 | ? { 21 | rejectUnauthorized: false, 22 | } 23 | : undefined, 24 | auth: 25 | settings.options.authUser && settings.options.authPass 26 | ? { 27 | user: settings.options.authUser, 28 | pass: settings.options.authPass, 29 | } 30 | : undefined, 31 | }); 32 | 33 | if (pgpKey) { 34 | transport.use( 35 | 'stream', 36 | openpgpEncrypt({ 37 | signingKey: settings.options.pgpPrivateKey, 38 | password: settings.options.pgpPassword, 39 | encryptionKeys: [pgpKey], 40 | }) 41 | ); 42 | } 43 | 44 | super({ 45 | message: { 46 | from: { 47 | name: settings.options.senderName, 48 | address: settings.options.emailFrom, 49 | }, 50 | }, 51 | send: true, 52 | transport: transport, 53 | }); 54 | } 55 | } 56 | 57 | export default PreparedEmail; 58 | -------------------------------------------------------------------------------- /server/lib/notifications/agents/agent.ts: -------------------------------------------------------------------------------- 1 | import type Issue from '@server/entity/Issue'; 2 | import type IssueComment from '@server/entity/IssueComment'; 3 | import type Media from '@server/entity/Media'; 4 | import type { MediaRequest } from '@server/entity/MediaRequest'; 5 | import type { User } from '@server/entity/User'; 6 | import type { NotificationAgentConfig } from '@server/lib/settings'; 7 | import type { Notification } from '..'; 8 | 9 | export interface NotificationPayload { 10 | event?: string; 11 | subject: string; 12 | notifySystem: boolean; 13 | notifyAdmin: boolean; 14 | notifyUser?: User; 15 | media?: Media; 16 | image?: string; 17 | message?: string; 18 | extra?: { name: string; value: string }[]; 19 | request?: MediaRequest; 20 | issue?: Issue; 21 | comment?: IssueComment; 22 | pendingRequestsCount?: number; 23 | isAdmin?: boolean; 24 | } 25 | 26 | export abstract class BaseAgent { 27 | protected settings?: T; 28 | public constructor(settings?: T) { 29 | this.settings = settings; 30 | } 31 | 32 | protected abstract getSettings(): T; 33 | } 34 | 35 | export interface NotificationAgent { 36 | shouldSend(): boolean; 37 | send(type: Notification, payload: NotificationPayload): Promise; 38 | } 39 | -------------------------------------------------------------------------------- /server/lib/refreshToken.ts: -------------------------------------------------------------------------------- 1 | import PlexTvAPI from '@server/api/plextv'; 2 | import { getRepository } from '@server/datasource'; 3 | import { User } from '@server/entity/User'; 4 | import logger from '@server/logger'; 5 | 6 | class RefreshToken { 7 | public async run() { 8 | const userRepository = getRepository(User); 9 | 10 | const users = await userRepository 11 | .createQueryBuilder('user') 12 | .addSelect('user.plexToken') 13 | .where("user.plexToken != ''") 14 | .getMany(); 15 | 16 | for (const user of users) { 17 | await this.refreshUserToken(user); 18 | } 19 | } 20 | 21 | private async refreshUserToken(user: User) { 22 | if (!user.plexToken) { 23 | logger.warn('Skipping user refresh token for user without plex token', { 24 | label: 'Plex Refresh Token', 25 | user: user.displayName, 26 | }); 27 | return; 28 | } 29 | 30 | const plexTvApi = new PlexTvAPI(user.plexToken); 31 | plexTvApi.pingToken(); 32 | } 33 | } 34 | 35 | const refreshToken = new RefreshToken(); 36 | 37 | export default refreshToken; 38 | -------------------------------------------------------------------------------- /server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { getRepository } from '@server/datasource'; 2 | import { User } from '@server/entity/User'; 3 | import type { 4 | Permission, 5 | PermissionCheckOptions, 6 | } from '@server/lib/permissions'; 7 | import { getSettings } from '@server/lib/settings'; 8 | 9 | export const checkUser: Middleware = async (req, _res, next) => { 10 | const settings = getSettings(); 11 | let user: User | undefined | null; 12 | 13 | if (req.header('X-API-Key') === settings.main.apiKey) { 14 | const userRepository = getRepository(User); 15 | 16 | let userId = 1; // Work on original administrator account 17 | 18 | // If a User ID is provided, we will act on that user's behalf 19 | if (req.header('X-API-User')) { 20 | userId = Number(req.header('X-API-User')); 21 | } 22 | 23 | user = await userRepository.findOne({ where: { id: userId } }); 24 | } else if (req.session?.userId) { 25 | const userRepository = getRepository(User); 26 | 27 | user = await userRepository.findOne({ 28 | where: { id: req.session.userId }, 29 | }); 30 | } 31 | 32 | if (user) { 33 | req.user = user; 34 | } 35 | 36 | req.locale = user?.settings?.locale 37 | ? user.settings.locale 38 | : settings.main.locale; 39 | 40 | next(); 41 | }; 42 | 43 | export const isAuthenticated = ( 44 | permissions?: Permission | Permission[], 45 | options?: PermissionCheckOptions 46 | ): Middleware => { 47 | const authMiddleware: Middleware = (req, res, next) => { 48 | if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) { 49 | res.status(403).json({ 50 | status: 403, 51 | error: 'You do not have permission to access this endpoint', 52 | }); 53 | } else { 54 | next(); 55 | } 56 | }; 57 | return authMiddleware; 58 | }; 59 | -------------------------------------------------------------------------------- /server/middleware/clearcookies.ts: -------------------------------------------------------------------------------- 1 | const clearCookies: Middleware = (_req, res, next) => { 2 | res.removeHeader('Set-Cookie'); 3 | next(); 4 | }; 5 | 6 | export default clearCookies; 7 | -------------------------------------------------------------------------------- /server/migration/1605085519544-SeasonStatus.ts: -------------------------------------------------------------------------------- 1 | import type { 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 | -------------------------------------------------------------------------------- /server/migration/1607928251245-DropImdbIdConstraint.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationInterface, QueryRunner } from 'typeorm'; 2 | import { TableUnique } from 'typeorm'; 3 | 4 | export class DropImdbIdConstraint1607928251245 implements MigrationInterface { 5 | public async up(queryRunner: QueryRunner): Promise { 6 | await queryRunner.dropUniqueConstraint( 7 | 'media', 8 | 'UQ_7ff2d11f6a83cb52386eaebe74b' 9 | ); 10 | } 11 | 12 | public async down(queryRunner: QueryRunner): Promise { 13 | await queryRunner.createUniqueConstraint( 14 | 'media', 15 | new TableUnique({ 16 | name: 'UQ_7ff2d11f6a83cb52386eaebe74b', 17 | columnNames: ['imdbId'], 18 | }) 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/migration/1672041273674-AddDiscoverSlider.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class AddDiscoverSlider1672041273674 implements MigrationInterface { 4 | name = 'AddDiscoverSlider1672041273674'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TABLE "discover_slider" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT (0), "enabled" boolean NOT NULL DEFAULT (1), "title" varchar, "data" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` 9 | ); 10 | } 11 | 12 | public async down(queryRunner: QueryRunner): Promise { 13 | await queryRunner.query(`DROP TABLE "discover_slider"`); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/models/Collection.ts: -------------------------------------------------------------------------------- 1 | import type { TmdbCollection } from '@server/api/themoviedb/interfaces'; 2 | import { MediaType } from '@server/constants/media'; 3 | import type Media from '@server/entity/Media'; 4 | import { sortBy } from 'lodash'; 5 | import type { MovieResult } from './Search'; 6 | import { mapMovieResult } from './Search'; 7 | 8 | export interface Collection { 9 | id: number; 10 | name: string; 11 | overview?: string; 12 | posterPath?: string; 13 | backdropPath?: string; 14 | parts: MovieResult[]; 15 | } 16 | 17 | export const mapCollection = ( 18 | collection: TmdbCollection, 19 | media: Media[] 20 | ): Collection => ({ 21 | id: collection.id, 22 | name: collection.name, 23 | overview: collection.overview, 24 | posterPath: collection.poster_path, 25 | backdropPath: collection.backdrop_path, 26 | parts: sortBy(collection.parts, 'release_date').map((part) => 27 | mapMovieResult( 28 | part, 29 | media?.find( 30 | (req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE 31 | ) 32 | ) 33 | ), 34 | }); 35 | -------------------------------------------------------------------------------- /server/routes/collection.ts: -------------------------------------------------------------------------------- 1 | import TheMovieDb from '@server/api/themoviedb'; 2 | import Media from '@server/entity/Media'; 3 | import logger from '@server/logger'; 4 | import { mapCollection } from '@server/models/Collection'; 5 | import { Router } from 'express'; 6 | 7 | const collectionRoutes = Router(); 8 | 9 | collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { 10 | const tmdb = new TheMovieDb(); 11 | 12 | try { 13 | const collection = await tmdb.getCollection({ 14 | collectionId: Number(req.params.id), 15 | language: (req.query.language as string) ?? req.locale, 16 | }); 17 | 18 | const media = await Media.getRelatedMedia( 19 | collection.parts.map((part) => part.id) 20 | ); 21 | 22 | return res.status(200).json(mapCollection(collection, media)); 23 | } catch (e) { 24 | logger.debug('Something went wrong retrieving collection', { 25 | label: 'API', 26 | errorMessage: e.message, 27 | collectionId: req.params.id, 28 | }); 29 | return next({ 30 | status: 500, 31 | message: 'Unable to retrieve collection.', 32 | }); 33 | } 34 | }); 35 | 36 | export default collectionRoutes; 37 | -------------------------------------------------------------------------------- /server/routes/imageproxy.ts: -------------------------------------------------------------------------------- 1 | import ImageProxy from '@server/lib/imageproxy'; 2 | import logger from '@server/logger'; 3 | import { Router } from 'express'; 4 | 5 | const router = Router(); 6 | const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { 7 | rateLimitOptions: { 8 | maxRequests: 20, 9 | maxRPS: 50, 10 | }, 11 | }); 12 | 13 | /** 14 | * Image Proxy 15 | */ 16 | router.get('/*', async (req, res) => { 17 | const imagePath = req.path.replace('/image', ''); 18 | try { 19 | const imageData = await tmdbImageProxy.getImage(imagePath); 20 | 21 | res.writeHead(200, { 22 | 'Content-Type': `image/${imageData.meta.extension}`, 23 | 'Content-Length': imageData.imageBuffer.length, 24 | 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, 25 | 'OS-Cache-Key': imageData.meta.cacheKey, 26 | 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', 27 | }); 28 | 29 | res.end(imageData.imageBuffer); 30 | } catch (e) { 31 | logger.error('Failed to proxy image', { 32 | imagePath, 33 | errorMessage: e.message, 34 | }); 35 | res.status(500).send(); 36 | } 37 | }); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /server/templates/email/generatedpassword/subject.pug: -------------------------------------------------------------------------------- 1 | != `Account Information [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /server/templates/email/media-issue/subject.pug: -------------------------------------------------------------------------------- 1 | != `${event} - ${mediaName} [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /server/templates/email/media-request/subject.pug: -------------------------------------------------------------------------------- 1 | != `${event} - ${mediaName} [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /server/templates/email/resetpassword/subject.pug: -------------------------------------------------------------------------------- 1 | != `Password Reset [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /server/templates/email/test-email/subject.pug: -------------------------------------------------------------------------------- 1 | != `Test Notification [${applicationTitle}]` 2 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "module": "commonjs", 6 | "outDir": "../dist", 7 | "noEmit": false, 8 | "baseUrl": ".", 9 | "paths": { 10 | "@server/*": ["*"] 11 | } 12 | }, 13 | "include": ["**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /server/types/express-session.d.ts: -------------------------------------------------------------------------------- 1 | import 'express-session'; 2 | 3 | // Declaration merging to apply our own types to SessionData 4 | // See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23) 5 | declare module 'express-session' { 6 | interface SessionData { 7 | userId: number; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/types/express.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import type { User } from '@server/entity/User'; 3 | import type { NextFunction, Request, Response } from 'express'; 4 | import 'express-session'; 5 | 6 | declare global { 7 | namespace Express { 8 | export interface Request { 9 | user?: User; 10 | locale?: string; 11 | } 12 | } 13 | 14 | export type Middleware = ( 15 | req: Request, 16 | res: Response, 17 | next: NextFunction 18 | ) => Promise | void | NextFunction; 19 | } 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /server/utils/appVersion.ts: -------------------------------------------------------------------------------- 1 | import logger from '@server/logger'; 2 | import { existsSync } from 'fs'; 3 | import path from 'path'; 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 | -------------------------------------------------------------------------------- /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 | ) => { 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 | -------------------------------------------------------------------------------- /server/utils/dateHelpers.ts: -------------------------------------------------------------------------------- 1 | import { addYears } from 'date-fns'; 2 | import { Between } from 'typeorm'; 3 | 4 | export const AfterDate = (date: Date) => Between(date, addYears(date, 100)); 5 | -------------------------------------------------------------------------------- /server/utils/restartFlag.ts: -------------------------------------------------------------------------------- 1 | import type { MainSettings } from '@server/lib/settings'; 2 | import { getSettings } from '@server/lib/settings'; 3 | 4 | class RestartFlag { 5 | private settings: MainSettings; 6 | 7 | public initializeSettings(settings: MainSettings): void { 8 | this.settings = { ...settings }; 9 | } 10 | 11 | public isSet(): boolean { 12 | const settings = getSettings().main; 13 | 14 | return ( 15 | this.settings.csrfProtection !== settings.csrfProtection || 16 | this.settings.trustProxy !== settings.trustProxy 17 | ); 18 | } 19 | } 20 | 21 | const restartFlag = new RestartFlag(); 22 | 23 | export default restartFlag; 24 | -------------------------------------------------------------------------------- /server/utils/typeHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TmdbCollectionResult, 3 | TmdbMovieDetails, 4 | TmdbMovieResult, 5 | TmdbPersonDetails, 6 | TmdbPersonResult, 7 | TmdbTvDetails, 8 | TmdbTvResult, 9 | } from '@server/api/themoviedb/interfaces'; 10 | 11 | export const isMovie = ( 12 | movie: 13 | | TmdbMovieResult 14 | | TmdbTvResult 15 | | TmdbPersonResult 16 | | TmdbCollectionResult 17 | ): movie is TmdbMovieResult => { 18 | return (movie as TmdbMovieResult).title !== undefined; 19 | }; 20 | 21 | export const isPerson = ( 22 | person: 23 | | TmdbMovieResult 24 | | TmdbTvResult 25 | | TmdbPersonResult 26 | | TmdbCollectionResult 27 | ): person is TmdbPersonResult => { 28 | return (person as TmdbPersonResult).known_for !== undefined; 29 | }; 30 | 31 | export const isCollection = ( 32 | collection: 33 | | TmdbMovieResult 34 | | TmdbTvResult 35 | | TmdbPersonResult 36 | | TmdbCollectionResult 37 | ): collection is TmdbCollectionResult => { 38 | return (collection as TmdbCollectionResult).media_type === 'collection'; 39 | }; 40 | 41 | export const isMovieDetails = ( 42 | movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails 43 | ): movie is TmdbMovieDetails => { 44 | return (movie as TmdbMovieDetails).title !== undefined; 45 | }; 46 | 47 | export const isTvDetails = ( 48 | tv: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails 49 | ): tv is TmdbTvDetails => { 50 | return (tv as TmdbTvDetails).number_of_seasons !== undefined; 51 | }; 52 | -------------------------------------------------------------------------------- /src/assets/ellipsis.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/extlogos/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/extlogos/lunasea.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/extlogos/pushbullet.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/extlogos/pushover.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/extlogos/slack.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/extlogos/telegram.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/infinity.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/rt_fresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/rt_rotten.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/services/imdb.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/services/radarr.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/services/trakt.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/spinner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/tmdb_logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/AirDateBadge/index.tsx: -------------------------------------------------------------------------------- 1 | import Badge from '@app/components/Common/Badge'; 2 | import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; 3 | 4 | const messages = defineMessages({ 5 | airedrelative: 'Aired {relativeTime}', 6 | airsrelative: 'Airing {relativeTime}', 7 | }); 8 | 9 | type AirDateBadgeProps = { 10 | airDate: string; 11 | }; 12 | 13 | const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { 14 | const WEEK = 1000 * 60 * 60 * 24 * 8; 15 | const intl = useIntl(); 16 | const dAirDate = new Date(airDate); 17 | const nowDate = new Date(); 18 | const alreadyAired = dAirDate.getTime() < nowDate.getTime(); 19 | 20 | const compareWeek = new Date( 21 | alreadyAired ? Date.now() - WEEK : Date.now() + WEEK 22 | ); 23 | 24 | let showRelative = false; 25 | 26 | if ( 27 | (alreadyAired && dAirDate.getTime() > compareWeek.getTime()) || 28 | (!alreadyAired && dAirDate.getTime() < compareWeek.getTime()) 29 | ) { 30 | showRelative = true; 31 | } 32 | 33 | return ( 34 |
35 | 36 | {intl.formatDate(dAirDate, { 37 | year: 'numeric', 38 | month: 'long', 39 | day: 'numeric', 40 | timeZone: 'UTC', 41 | })} 42 | 43 | {showRelative && ( 44 | 45 | {intl.formatMessage( 46 | alreadyAired ? messages.airedrelative : messages.airsrelative, 47 | { 48 | relativeTime: ( 49 | 54 | ), 55 | } 56 | )} 57 | 58 | )} 59 |
60 | ); 61 | }; 62 | 63 | export default AirDateBadge; 64 | -------------------------------------------------------------------------------- /src/components/AppDataWarning/index.tsx: -------------------------------------------------------------------------------- 1 | import Alert from '@app/components/Common/Alert'; 2 | import { defineMessages, useIntl } from 'react-intl'; 3 | import useSWR from 'swr'; 4 | 5 | const messages = defineMessages({ 6 | dockerVolumeMissingDescription: 7 | 'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.', 8 | }); 9 | 10 | const AppDataWarning = () => { 11 | const intl = useIntl(); 12 | const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>( 13 | '/api/v1/status/appdata' 14 | ); 15 | 16 | if (!data && !error) { 17 | return null; 18 | } 19 | 20 | if (!data) { 21 | return null; 22 | } 23 | 24 | return ( 25 | <> 26 | {!data.appData && ( 27 | ( 30 | {msg} 31 | ), 32 | appDataPath: data.appDataPath, 33 | })} 34 | /> 35 | )} 36 | 37 | ); 38 | }; 39 | 40 | export default AppDataWarning; 41 | -------------------------------------------------------------------------------- /src/components/Common/Alert/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ExclamationTriangleIcon, 3 | InformationCircleIcon, 4 | XCircleIcon, 5 | } from '@heroicons/react/24/solid'; 6 | 7 | interface AlertProps { 8 | title?: React.ReactNode; 9 | type?: 'warning' | 'info' | 'error'; 10 | children?: React.ReactNode; 11 | } 12 | 13 | const Alert = ({ title, children, type }: AlertProps) => { 14 | let design = { 15 | bgColor: 16 | 'border border-yellow-500 backdrop-blur bg-yellow-400 bg-opacity-20', 17 | titleColor: 'text-yellow-100', 18 | textColor: 'text-yellow-300', 19 | svg: , 20 | }; 21 | 22 | switch (type) { 23 | case 'info': 24 | design = { 25 | bgColor: 26 | 'border border-indigo-500 backdrop-blur bg-indigo-400 bg-opacity-20', 27 | titleColor: 'text-gray-100', 28 | textColor: 'text-gray-300', 29 | svg: , 30 | }; 31 | break; 32 | case 'error': 33 | design = { 34 | bgColor: 'bg-red-600', 35 | titleColor: 'text-red-100', 36 | textColor: 'text-red-300', 37 | svg: , 38 | }; 39 | break; 40 | } 41 | 42 | return ( 43 |
44 |
45 |
{design.svg}
46 |
47 | {title && ( 48 |
49 | {title} 50 |
51 | )} 52 | {children && ( 53 |
54 | {children} 55 |
56 | )} 57 |
58 |
59 |
60 | ); 61 | }; 62 | 63 | export default Alert; 64 | -------------------------------------------------------------------------------- /src/components/Common/CachedImage/index.tsx: -------------------------------------------------------------------------------- 1 | import useSettings from '@app/hooks/useSettings'; 2 | import type { ImageLoader, ImageProps } from 'next/image'; 3 | import Image from 'next/image'; 4 | 5 | const imageLoader: ImageLoader = ({ src }) => src; 6 | 7 | /** 8 | * The CachedImage component should be used wherever 9 | * we want to offer the option to locally cache images. 10 | **/ 11 | const CachedImage = ({ src, ...props }: ImageProps) => { 12 | const { currentSettings } = useSettings(); 13 | 14 | let imageUrl = src; 15 | 16 | if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) { 17 | const parsedUrl = new URL(imageUrl); 18 | 19 | if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) { 20 | imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy'); 21 | } 22 | } 23 | 24 | return ; 25 | }; 26 | 27 | export default CachedImage; 28 | -------------------------------------------------------------------------------- /src/components/Common/ConfirmButton/index.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@app/components/Common/Button'; 2 | import useClickOutside from '@app/hooks/useClickOutside'; 3 | import { forwardRef, useRef, useState } from 'react'; 4 | 5 | interface ConfirmButtonProps { 6 | onClick: () => void; 7 | confirmText: React.ReactNode; 8 | className?: string; 9 | children: React.ReactNode; 10 | } 11 | 12 | const ConfirmButton = forwardRef( 13 | ({ onClick, children, confirmText, className }, parentRef) => { 14 | const ref = useRef(null); 15 | useClickOutside(ref, () => setIsClicked(false)); 16 | const [isClicked, setIsClicked] = useState(false); 17 | return ( 18 | 53 | ); 54 | } 55 | ); 56 | 57 | ConfirmButton.displayName = 'ConfirmButton'; 58 | 59 | export default ConfirmButton; 60 | -------------------------------------------------------------------------------- /src/components/Common/Header/index.tsx: -------------------------------------------------------------------------------- 1 | interface HeaderProps { 2 | extraMargin?: number; 3 | subtext?: React.ReactNode; 4 | children: React.ReactNode; 5 | } 6 | 7 | const Header = ({ children, extraMargin = 0, subtext }: HeaderProps) => { 8 | return ( 9 |
10 |
11 |

15 | {children} 16 |

17 | {subtext &&
{subtext}
} 18 |
19 |
20 | ); 21 | }; 22 | 23 | export default Header; 24 | -------------------------------------------------------------------------------- /src/components/Common/List/index.tsx: -------------------------------------------------------------------------------- 1 | import { withProperties } from '@app/utils/typeHelpers'; 2 | 3 | interface ListItemProps { 4 | title: string; 5 | className?: string; 6 | children: React.ReactNode; 7 | } 8 | 9 | const ListItem = ({ title, className, children }: ListItemProps) => { 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 | children: React.ReactNode; 26 | } 27 | 28 | const List = ({ title, subTitle, children }: ListProps) => { 29 | return ( 30 | <> 31 |
32 |

{title}

33 | {subTitle &&

{subTitle}

} 34 |
35 |
36 |
{children}
37 |
38 | 39 | ); 40 | }; 41 | 42 | export default withProperties(List, { Item: ListItem }); 43 | -------------------------------------------------------------------------------- /src/components/Common/LoadingSpinner/index.tsx: -------------------------------------------------------------------------------- 1 | export const SmallLoadingSpinner = () => { 2 | return ( 3 |
4 | 10 | 11 | 12 | 13 | 14 | 22 | 23 | 24 | 25 | 26 |
27 | ); 28 | }; 29 | 30 | const LoadingSpinner = () => { 31 | return ( 32 |
33 | 39 | 40 | 41 | 42 | 43 | 51 | 52 | 53 | 54 | 55 |
56 | ); 57 | }; 58 | 59 | export default LoadingSpinner; 60 | -------------------------------------------------------------------------------- /src/components/Common/PageTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import useSettings from '@app/hooks/useSettings'; 2 | import Head from 'next/head'; 3 | 4 | interface PageTitleProps { 5 | title: string | (string | undefined)[]; 6 | } 7 | 8 | const PageTitle = ({ title }: PageTitleProps) => { 9 | const settings = useSettings(); 10 | 11 | const titleText = `${ 12 | Array.isArray(title) ? title.filter(Boolean).join(' - ') : title 13 | } - ${settings.currentSettings.applicationTitle}`; 14 | 15 | return ( 16 | 17 | {titleText} 18 | 19 | ); 20 | }; 21 | 22 | export default PageTitle; 23 | -------------------------------------------------------------------------------- /src/components/Common/PlayButton/index.tsx: -------------------------------------------------------------------------------- 1 | import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; 2 | 3 | interface PlayButtonProps { 4 | links: PlayButtonLink[]; 5 | } 6 | 7 | export interface PlayButtonLink { 8 | text: string; 9 | url: string; 10 | svg: React.ReactNode; 11 | } 12 | 13 | const PlayButton = ({ links }: PlayButtonProps) => { 14 | if (!links || !links.length) { 15 | return null; 16 | } 17 | 18 | return ( 19 | 23 | {links[0].svg} 24 | {links[0].text} 25 | 26 | } 27 | onClick={() => { 28 | window.open(links[0].url, '_blank'); 29 | }} 30 | > 31 | {links.length > 1 && 32 | links.slice(1).map((link, i) => { 33 | return ( 34 | { 37 | window.open(link.url, '_blank'); 38 | }} 39 | buttonType="ghost" 40 | > 41 | {link.svg} 42 | {link.text} 43 | 44 | ); 45 | })} 46 | 47 | ); 48 | }; 49 | 50 | export default PlayButton; 51 | -------------------------------------------------------------------------------- /src/components/Common/ProgressCircle/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | interface ProgressCircleProps { 4 | className?: string; 5 | progress?: number; 6 | useHeatLevel?: boolean; 7 | } 8 | 9 | const ProgressCircle = ({ 10 | className, 11 | progress = 0, 12 | useHeatLevel, 13 | }: ProgressCircleProps) => { 14 | const ref = useRef(null); 15 | 16 | let color = ''; 17 | let emptyColor = 'text-gray-300'; 18 | 19 | if (useHeatLevel) { 20 | color = 'text-green-500'; 21 | 22 | if (progress <= 50) { 23 | color = 'text-yellow-500'; 24 | } 25 | 26 | if (progress <= 10) { 27 | color = 'text-red-500'; 28 | } 29 | 30 | if (progress === 0) { 31 | emptyColor = 'text-red-600'; 32 | } 33 | } 34 | 35 | useEffect(() => { 36 | if (ref && ref.current) { 37 | const radius = ref.current?.r.baseVal.value; 38 | const circumference = (radius ?? 0) * 2 * Math.PI; 39 | const offset = circumference - (progress / 100) * circumference; 40 | ref.current.style.strokeDashoffset = `${offset}`; 41 | ref.current.style.strokeDasharray = `${circumference} ${circumference}`; 42 | } 43 | }); 44 | 45 | return ( 46 | 47 | 56 | 70 | 71 | ); 72 | }; 73 | 74 | export default ProgressCircle; 75 | -------------------------------------------------------------------------------- /src/components/Common/SensitiveInput/index.tsx: -------------------------------------------------------------------------------- 1 | import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/solid'; 2 | import { Field } from 'formik'; 3 | import { 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 = ({ as = 'input', ...props }: SensitiveInputProps) => { 16 | const [isHidden, setHidden] = useState(true); 17 | const Component = as === 'input' ? 'input' : Field; 18 | const componentProps = 19 | as === 'input' 20 | ? props 21 | : { 22 | ...props, 23 | as: props.type === 'textarea' && !isHidden ? 'textarea' : undefined, 24 | }; 25 | return ( 26 | <> 27 | 42 | 52 | 53 | ); 54 | }; 55 | 56 | export default SensitiveInput; 57 | -------------------------------------------------------------------------------- /src/components/Common/SlideCheckbox/index.tsx: -------------------------------------------------------------------------------- 1 | type SlideCheckboxProps = { 2 | onClick: () => void; 3 | checked?: boolean; 4 | }; 5 | 6 | const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => { 7 | return ( 8 | { 13 | onClick(); 14 | }} 15 | onKeyDown={(e) => { 16 | if (e.key === 'Enter' || e.key === 'Space') { 17 | onClick(); 18 | } 19 | }} 20 | className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`} 21 | > 22 | 28 | 34 | 35 | ); 36 | }; 37 | 38 | export default SlideCheckbox; 39 | -------------------------------------------------------------------------------- /src/components/Common/Tag/index.tsx: -------------------------------------------------------------------------------- 1 | import { TagIcon } from '@heroicons/react/24/outline'; 2 | import React from 'react'; 3 | 4 | type TagProps = { 5 | children: React.ReactNode; 6 | iconSvg?: JSX.Element; 7 | }; 8 | 9 | const Tag = ({ children, iconSvg }: TagProps) => { 10 | return ( 11 |
12 | {iconSvg ? ( 13 | React.cloneElement(iconSvg, { 14 | className: 'mr-1 h-4 w-4', 15 | }) 16 | ) : ( 17 | 18 | )} 19 | {children} 20 |
21 | ); 22 | }; 23 | 24 | export default Tag; 25 | -------------------------------------------------------------------------------- /src/components/Common/Tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import type { Config } from 'react-popper-tooltip'; 4 | import { usePopperTooltip } from 'react-popper-tooltip'; 5 | 6 | type TooltipProps = { 7 | content: React.ReactNode; 8 | children: React.ReactElement; 9 | tooltipConfig?: Partial; 10 | className?: string; 11 | }; 12 | 13 | const Tooltip = ({ 14 | children, 15 | content, 16 | tooltipConfig, 17 | className, 18 | }: TooltipProps) => { 19 | const { getTooltipProps, setTooltipRef, setTriggerRef, visible } = 20 | usePopperTooltip({ 21 | followCursor: true, 22 | offset: [-28, 6], 23 | placement: 'auto-end', 24 | ...tooltipConfig, 25 | }); 26 | 27 | const tooltipStyle = [ 28 | 'z-50 text-sm absolute font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100', 29 | ]; 30 | 31 | if (className) { 32 | tooltipStyle.push(className); 33 | } 34 | 35 | return ( 36 | <> 37 | {React.cloneElement(children, { ref: setTriggerRef })} 38 | {visible && 39 | content && 40 | ReactDOM.createPortal( 41 |
47 | {content} 48 |
, 49 | document.body 50 | )} 51 | 52 | ); 53 | }; 54 | 55 | export default Tooltip; 56 | -------------------------------------------------------------------------------- /src/components/CompanyCard/index.tsx: -------------------------------------------------------------------------------- 1 | import CachedImage from '@app/components/Common/CachedImage'; 2 | import Link from 'next/link'; 3 | import { useState } from 'react'; 4 | 5 | interface CompanyCardProps { 6 | name: string; 7 | image: string; 8 | url: string; 9 | } 10 | 11 | const CompanyCard = ({ image, url, name }: CompanyCardProps) => { 12 | const [isHovered, setHovered] = useState(false); 13 | 14 | return ( 15 | 16 | { 23 | setHovered(true); 24 | }} 25 | onMouseLeave={() => setHovered(false)} 26 | onKeyDown={(e) => { 27 | if (e.key === 'Enter') { 28 | setHovered(true); 29 | } 30 | }} 31 | role="link" 32 | tabIndex={0} 33 | > 34 |
35 | 42 |
43 |
48 | 49 | 50 | ); 51 | }; 52 | 53 | export default CompanyCard; 54 | -------------------------------------------------------------------------------- /src/components/CompanyTag/index.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from '@app/assets/spinner.svg'; 2 | import Tag from '@app/components/Common/Tag'; 3 | import { BuildingOffice2Icon } from '@heroicons/react/24/outline'; 4 | import type { ProductionCompany, TvNetwork } from '@server/models/common'; 5 | import useSWR from 'swr'; 6 | 7 | type CompanyTagProps = { 8 | type: 'studio' | 'network'; 9 | companyId: number; 10 | }; 11 | 12 | const CompanyTag = ({ companyId, type }: CompanyTagProps) => { 13 | const { data, error } = useSWR( 14 | `/api/v1/${type}/${companyId}` 15 | ); 16 | 17 | if (!data && !error) { 18 | return ( 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | return }>{data?.name}; 26 | }; 27 | 28 | export default CompanyTag; 29 | -------------------------------------------------------------------------------- /src/components/Discover/DiscoverMovieGenre/index.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@app/components/Common/Header'; 2 | import ListView from '@app/components/Common/ListView'; 3 | import PageTitle from '@app/components/Common/PageTitle'; 4 | import useDiscover from '@app/hooks/useDiscover'; 5 | import globalMessages from '@app/i18n/globalMessages'; 6 | import Error from '@app/pages/_error'; 7 | import type { MovieResult } from '@server/models/Search'; 8 | import { useRouter } from 'next/router'; 9 | import { defineMessages, useIntl } from 'react-intl'; 10 | 11 | const messages = defineMessages({ 12 | genreMovies: '{genre} Movies', 13 | }); 14 | 15 | const DiscoverMovieGenre = () => { 16 | const router = useRouter(); 17 | const intl = useIntl(); 18 | 19 | const { 20 | isLoadingInitialData, 21 | isEmpty, 22 | isLoadingMore, 23 | isReachingEnd, 24 | titles, 25 | fetchMore, 26 | error, 27 | firstResultData, 28 | } = useDiscover( 29 | `/api/v1/discover/movies/genre/${router.query.genreId}` 30 | ); 31 | 32 | if (error) { 33 | return ; 34 | } 35 | 36 | const title = isLoadingInitialData 37 | ? intl.formatMessage(globalMessages.loading) 38 | : intl.formatMessage(messages.genreMovies, { 39 | genre: firstResultData?.genre.name, 40 | }); 41 | 42 | return ( 43 | <> 44 | 45 |
46 |
{title}
47 |
48 | 0) 53 | } 54 | isReachingEnd={isReachingEnd} 55 | onScrollBottom={fetchMore} 56 | /> 57 | 58 | ); 59 | }; 60 | 61 | export default DiscoverMovieGenre; 62 | -------------------------------------------------------------------------------- /src/components/Discover/DiscoverTvGenre/index.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@app/components/Common/Header'; 2 | import ListView from '@app/components/Common/ListView'; 3 | import PageTitle from '@app/components/Common/PageTitle'; 4 | import useDiscover from '@app/hooks/useDiscover'; 5 | import globalMessages from '@app/i18n/globalMessages'; 6 | import Error from '@app/pages/_error'; 7 | import type { TvResult } from '@server/models/Search'; 8 | import { useRouter } from 'next/router'; 9 | import { defineMessages, useIntl } from 'react-intl'; 10 | 11 | const messages = defineMessages({ 12 | genreSeries: '{genre} Series', 13 | }); 14 | 15 | const DiscoverTvGenre = () => { 16 | const router = useRouter(); 17 | const intl = useIntl(); 18 | 19 | const { 20 | isLoadingInitialData, 21 | isEmpty, 22 | isLoadingMore, 23 | isReachingEnd, 24 | titles, 25 | fetchMore, 26 | error, 27 | firstResultData, 28 | } = useDiscover( 29 | `/api/v1/discover/tv/genre/${router.query.genreId}` 30 | ); 31 | 32 | if (error) { 33 | return ; 34 | } 35 | 36 | const title = isLoadingInitialData 37 | ? intl.formatMessage(globalMessages.loading) 38 | : intl.formatMessage(messages.genreSeries, { 39 | genre: firstResultData?.genre.name, 40 | }); 41 | 42 | return ( 43 | <> 44 | 45 |
46 |
{title}
47 |
48 | 0) 53 | } 54 | isReachingEnd={isReachingEnd} 55 | onScrollBottom={fetchMore} 56 | /> 57 | 58 | ); 59 | }; 60 | 61 | export default DiscoverTvGenre; 62 | -------------------------------------------------------------------------------- /src/components/Discover/DiscoverTvUpcoming.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@app/components/Common/Header'; 2 | import ListView from '@app/components/Common/ListView'; 3 | import PageTitle from '@app/components/Common/PageTitle'; 4 | import useDiscover from '@app/hooks/useDiscover'; 5 | import Error from '@app/pages/_error'; 6 | import type { TvResult } from '@server/models/Search'; 7 | import { defineMessages, useIntl } from 'react-intl'; 8 | 9 | const messages = defineMessages({ 10 | upcomingtv: 'Upcoming Series', 11 | }); 12 | 13 | const DiscoverTvUpcoming = () => { 14 | const intl = useIntl(); 15 | 16 | const { 17 | isLoadingInitialData, 18 | isEmpty, 19 | isLoadingMore, 20 | isReachingEnd, 21 | titles, 22 | fetchMore, 23 | error, 24 | } = useDiscover('/api/v1/discover/tv/upcoming'); 25 | 26 | if (error) { 27 | return ; 28 | } 29 | 30 | return ( 31 | <> 32 | 33 |
34 |
{intl.formatMessage(messages.upcomingtv)}
35 |
36 | 0) 42 | } 43 | onScrollBottom={fetchMore} 44 | /> 45 | 46 | ); 47 | }; 48 | 49 | export default DiscoverTvUpcoming; 50 | -------------------------------------------------------------------------------- /src/components/Discover/MovieGenreList/index.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@app/components/Common/Header'; 2 | import LoadingSpinner from '@app/components/Common/LoadingSpinner'; 3 | import PageTitle from '@app/components/Common/PageTitle'; 4 | import { genreColorMap } from '@app/components/Discover/constants'; 5 | import GenreCard from '@app/components/GenreCard'; 6 | import Error from '@app/pages/_error'; 7 | import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; 8 | import { defineMessages, useIntl } from 'react-intl'; 9 | import useSWR from 'swr'; 10 | 11 | const messages = defineMessages({ 12 | moviegenres: 'Movie Genres', 13 | }); 14 | 15 | const MovieGenreList = () => { 16 | const intl = useIntl(); 17 | const { data, error } = useSWR( 18 | `/api/v1/discover/genreslider/movie` 19 | ); 20 | 21 | if (!data && !error) { 22 | return ; 23 | } 24 | 25 | if (!data) { 26 | return ; 27 | } 28 | 29 | return ( 30 | <> 31 | 32 |
33 |
{intl.formatMessage(messages.moviegenres)}
34 |
35 |
    36 | {data.map((genre, index) => ( 37 |
  • 38 | 46 |
  • 47 | ))} 48 |
49 | 50 | ); 51 | }; 52 | 53 | export default MovieGenreList; 54 | -------------------------------------------------------------------------------- /src/components/Discover/MovieGenreSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import { genreColorMap } from '@app/components/Discover/constants'; 2 | import GenreCard from '@app/components/GenreCard'; 3 | import Slider from '@app/components/Slider'; 4 | import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; 5 | import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; 6 | import Link from 'next/link'; 7 | import React from 'react'; 8 | import { defineMessages, useIntl } from 'react-intl'; 9 | import useSWR from 'swr'; 10 | 11 | const messages = defineMessages({ 12 | moviegenres: 'Movie Genres', 13 | }); 14 | 15 | const MovieGenreSlider = () => { 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/RecentRequestsSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import { sliderTitles } from '@app/components/Discover/constants'; 2 | import RequestCard from '@app/components/RequestCard'; 3 | import Slider from '@app/components/Slider'; 4 | import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; 5 | import type { RequestResultsResponse } from '@server/interfaces/api/requestInterfaces'; 6 | import Link from 'next/link'; 7 | import { useIntl } from 'react-intl'; 8 | import useSWR from 'swr'; 9 | 10 | const RecentRequestsSlider = () => { 11 | const intl = useIntl(); 12 | const { data: requests, error: requestError } = 13 | useSWR( 14 | '/api/v1/request?filter=all&take=10&sort=modified&skip=0', 15 | { 16 | revalidateOnMount: true, 17 | } 18 | ); 19 | 20 | if (requests && requests.results.length === 0 && !requestError) { 21 | return null; 22 | } 23 | 24 | return ( 25 | <> 26 | 34 | ( 38 | 42 | ))} 43 | placeholder={} 44 | /> 45 | 46 | ); 47 | }; 48 | 49 | export default RecentRequestsSlider; 50 | -------------------------------------------------------------------------------- /src/components/Discover/RecentlyAddedSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import Slider from '@app/components/Slider'; 2 | import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; 3 | import { Permission, useUser } from '@app/hooks/useUser'; 4 | import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces'; 5 | import { defineMessages, useIntl } from 'react-intl'; 6 | import useSWR from 'swr'; 7 | 8 | const messages = defineMessages({ 9 | recentlyAdded: 'Recently Added', 10 | }); 11 | 12 | const RecentlyAddedSlider = () => { 13 | const intl = useIntl(); 14 | const { hasPermission } = useUser(); 15 | const { data: media, error: mediaError } = useSWR( 16 | '/api/v1/media?filter=allavailable&take=20&sort=mediaAdded', 17 | { revalidateOnMount: true } 18 | ); 19 | 20 | if ( 21 | (media && !media.results.length && !mediaError) || 22 | !hasPermission([Permission.MANAGE_REQUESTS, Permission.RECENT_VIEW], { 23 | type: 'or', 24 | }) 25 | ) { 26 | return null; 27 | } 28 | 29 | return ( 30 | <> 31 |
32 |
33 | {intl.formatMessage(messages.recentlyAdded)} 34 |
35 |
36 | ( 40 | 47 | ))} 48 | /> 49 | 50 | ); 51 | }; 52 | 53 | export default RecentlyAddedSlider; 54 | -------------------------------------------------------------------------------- /src/components/Discover/Trending.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@app/components/Common/Header'; 2 | import ListView from '@app/components/Common/ListView'; 3 | import PageTitle from '@app/components/Common/PageTitle'; 4 | import useDiscover from '@app/hooks/useDiscover'; 5 | import Error from '@app/pages/_error'; 6 | import type { 7 | MovieResult, 8 | PersonResult, 9 | TvResult, 10 | } from '@server/models/Search'; 11 | import { defineMessages, useIntl } from 'react-intl'; 12 | 13 | const messages = defineMessages({ 14 | trending: 'Trending', 15 | }); 16 | 17 | const Trending = () => { 18 | const intl = useIntl(); 19 | const { 20 | isLoadingInitialData, 21 | isEmpty, 22 | isLoadingMore, 23 | isReachingEnd, 24 | titles, 25 | fetchMore, 26 | error, 27 | } = useDiscover( 28 | '/api/v1/discover/trending' 29 | ); 30 | 31 | if (error) { 32 | return ; 33 | } 34 | 35 | return ( 36 | <> 37 | 38 |
39 |
{intl.formatMessage(messages.trending)}
40 |
41 | 0) 46 | } 47 | isReachingEnd={isReachingEnd} 48 | onScrollBottom={fetchMore} 49 | /> 50 | 51 | ); 52 | }; 53 | 54 | export default Trending; 55 | -------------------------------------------------------------------------------- /src/components/Discover/TvGenreList/index.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@app/components/Common/Header'; 2 | import LoadingSpinner from '@app/components/Common/LoadingSpinner'; 3 | import PageTitle from '@app/components/Common/PageTitle'; 4 | import { genreColorMap } from '@app/components/Discover/constants'; 5 | import GenreCard from '@app/components/GenreCard'; 6 | import Error from '@app/pages/_error'; 7 | import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; 8 | import { defineMessages, useIntl } from 'react-intl'; 9 | import useSWR from 'swr'; 10 | 11 | const messages = defineMessages({ 12 | seriesgenres: 'Series Genres', 13 | }); 14 | 15 | const TvGenreList = () => { 16 | const intl = useIntl(); 17 | const { data, error } = useSWR( 18 | `/api/v1/discover/genreslider/tv` 19 | ); 20 | 21 | if (!data && !error) { 22 | return ; 23 | } 24 | 25 | if (!data) { 26 | return ; 27 | } 28 | 29 | return ( 30 | <> 31 | 32 |
33 |
{intl.formatMessage(messages.seriesgenres)}
34 |
35 |
    36 | {data.map((genre, index) => ( 37 |
  • 38 | 46 |
  • 47 | ))} 48 |
49 | 50 | ); 51 | }; 52 | 53 | export default TvGenreList; 54 | -------------------------------------------------------------------------------- /src/components/Discover/TvGenreSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import { genreColorMap } from '@app/components/Discover/constants'; 2 | import GenreCard from '@app/components/GenreCard'; 3 | import Slider from '@app/components/Slider'; 4 | import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; 5 | import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces'; 6 | import Link from 'next/link'; 7 | import React from 'react'; 8 | import { defineMessages, useIntl } from 'react-intl'; 9 | import useSWR from 'swr'; 10 | 11 | const messages = defineMessages({ 12 | tvgenres: 'Series Genres', 13 | }); 14 | 15 | const TvGenreSlider = () => { 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/Upcoming.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@app/components/Common/Header'; 2 | import ListView from '@app/components/Common/ListView'; 3 | import PageTitle from '@app/components/Common/PageTitle'; 4 | import useDiscover from '@app/hooks/useDiscover'; 5 | import Error from '@app/pages/_error'; 6 | import type { MovieResult } from '@server/models/Search'; 7 | import { defineMessages, useIntl } from 'react-intl'; 8 | 9 | const messages = defineMessages({ 10 | upcomingmovies: 'Upcoming Movies', 11 | }); 12 | 13 | const UpcomingMovies = () => { 14 | const intl = useIntl(); 15 | 16 | const { 17 | isLoadingInitialData, 18 | isEmpty, 19 | isLoadingMore, 20 | isReachingEnd, 21 | titles, 22 | fetchMore, 23 | error, 24 | } = useDiscover('/api/v1/discover/movies/upcoming'); 25 | 26 | if (error) { 27 | return ; 28 | } 29 | 30 | return ( 31 | <> 32 | 33 |
34 |
{intl.formatMessage(messages.upcomingmovies)}
35 |
36 | 0) 41 | } 42 | isReachingEnd={isReachingEnd} 43 | onScrollBottom={fetchMore} 44 | /> 45 | 46 | ); 47 | }; 48 | 49 | export default UpcomingMovies; 50 | -------------------------------------------------------------------------------- /src/components/GenreTag/index.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from '@app/assets/spinner.svg'; 2 | import Tag from '@app/components/Common/Tag'; 3 | import { RectangleStackIcon } from '@heroicons/react/24/outline'; 4 | import type { TmdbGenre } from '@server/api/themoviedb/interfaces'; 5 | import useSWR from 'swr'; 6 | 7 | type GenreTagProps = { 8 | type: 'tv' | 'movie'; 9 | genreId: number; 10 | }; 11 | 12 | const GenreTag = ({ genreId, type }: GenreTagProps) => { 13 | const { data, error } = useSWR(`/api/v1/genres/${type}`); 14 | 15 | if (!data && !error) { 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | const genre = data?.find((genre) => genre.id === genreId); 24 | 25 | return }>{genre?.name}; 26 | }; 27 | 28 | export default GenreTag; 29 | -------------------------------------------------------------------------------- /src/components/IssueModal/constants.ts: -------------------------------------------------------------------------------- 1 | import { IssueType } from '@server/constants/issue'; 2 | import type { MessageDescriptor } from 'react-intl'; 3 | import { defineMessages } from 'react-intl'; 4 | 5 | const messages = defineMessages({ 6 | issueAudio: 'Audio', 7 | issueVideo: 'Video', 8 | issueSubtitles: 'Subtitle', 9 | issueOther: 'Other', 10 | }); 11 | 12 | interface IssueOption { 13 | name: MessageDescriptor; 14 | issueType: IssueType; 15 | mediaType?: 'movie' | 'tv'; 16 | } 17 | 18 | export const issueOptions: IssueOption[] = [ 19 | { 20 | name: messages.issueVideo, 21 | issueType: IssueType.VIDEO, 22 | }, 23 | { 24 | name: messages.issueAudio, 25 | issueType: IssueType.AUDIO, 26 | }, 27 | { 28 | name: messages.issueSubtitles, 29 | issueType: IssueType.SUBTITLES, 30 | }, 31 | { 32 | name: messages.issueOther, 33 | issueType: IssueType.OTHER, 34 | }, 35 | ]; 36 | -------------------------------------------------------------------------------- /src/components/IssueModal/index.tsx: -------------------------------------------------------------------------------- 1 | import CreateIssueModal from '@app/components/IssueModal/CreateIssueModal'; 2 | import { Transition } from '@headlessui/react'; 3 | 4 | interface IssueModalProps { 5 | show?: boolean; 6 | onCancel: () => void; 7 | mediaType: 'movie' | 'tv'; 8 | tmdbId: number; 9 | issueId?: never; 10 | } 11 | 12 | const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => ( 13 | 23 | 28 | 29 | ); 30 | 31 | export default IssueModal; 32 | -------------------------------------------------------------------------------- /src/components/JSONEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import 'ace-builds/src-noconflict/ace'; 2 | import 'ace-builds/src-noconflict/mode-json'; 3 | import 'ace-builds/src-noconflict/theme-dracula'; 4 | import type { HTMLAttributes } from 'react'; 5 | import AceEditor from 'react-ace'; 6 | interface JSONEditorProps extends HTMLAttributes { 7 | name: string; 8 | value: string; 9 | onUpdate: (value: string) => void; 10 | } 11 | 12 | const JSONEditor = ({ name, value, onUpdate, onBlur }: JSONEditorProps) => { 13 | return ( 14 |
15 | 26 |
27 | ); 28 | }; 29 | 30 | export default JSONEditor; 31 | -------------------------------------------------------------------------------- /src/components/KeywordTag/index.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from '@app/assets/spinner.svg'; 2 | import Tag from '@app/components/Common/Tag'; 3 | import type { Keyword } from '@server/models/common'; 4 | import useSWR from 'swr'; 5 | 6 | type KeywordTagProps = { 7 | keywordId: number; 8 | }; 9 | 10 | const KeywordTag = ({ keywordId }: KeywordTagProps) => { 11 | const { data, error } = useSWR(`/api/v1/keyword/${keywordId}`); 12 | 13 | if (!data && !error) { 14 | return ( 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | return {data?.name}; 22 | }; 23 | 24 | export default KeywordTag; 25 | -------------------------------------------------------------------------------- /src/components/Layout/Notifications/index.tsx: -------------------------------------------------------------------------------- 1 | import { BellIcon } from '@heroicons/react/24/outline'; 2 | 3 | const Notifications = () => { 4 | return ( 5 | 11 | ); 12 | }; 13 | 14 | export default Notifications; 15 | -------------------------------------------------------------------------------- /src/components/PlexLoginButton/index.tsx: -------------------------------------------------------------------------------- 1 | import globalMessages from '@app/i18n/globalMessages'; 2 | import PlexOAuth from '@app/utils/plex'; 3 | import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; 4 | import { useState } from 'react'; 5 | import { defineMessages, useIntl } from 'react-intl'; 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 = ({ 21 | onAuthToken, 22 | onError, 23 | isProcessing, 24 | }: PlexLoginButtonProps) => { 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 | -------------------------------------------------------------------------------- /src/components/Search/index.tsx: -------------------------------------------------------------------------------- 1 | import Header from '@app/components/Common/Header'; 2 | import ListView from '@app/components/Common/ListView'; 3 | import PageTitle from '@app/components/Common/PageTitle'; 4 | import useDiscover from '@app/hooks/useDiscover'; 5 | import Error from '@app/pages/_error'; 6 | import type { 7 | MovieResult, 8 | PersonResult, 9 | TvResult, 10 | } from '@server/models/Search'; 11 | import { useRouter } from 'next/router'; 12 | import { defineMessages, useIntl } from 'react-intl'; 13 | 14 | const messages = defineMessages({ 15 | search: 'Search', 16 | searchresults: 'Search Results', 17 | }); 18 | 19 | const Search = () => { 20 | const intl = useIntl(); 21 | const router = useRouter(); 22 | 23 | const { 24 | isLoadingInitialData, 25 | isEmpty, 26 | isLoadingMore, 27 | isReachingEnd, 28 | titles, 29 | fetchMore, 30 | error, 31 | } = useDiscover( 32 | `/api/v1/search`, 33 | { 34 | query: router.query.query, 35 | }, 36 | { hideAvailable: false } 37 | ); 38 | 39 | if (error) { 40 | return ; 41 | } 42 | 43 | return ( 44 | <> 45 | 46 |
47 |
{intl.formatMessage(messages.searchresults)}
48 |
49 | 0) 54 | } 55 | isReachingEnd={isReachingEnd} 56 | onScrollBottom={fetchMore} 57 | /> 58 | 59 | ); 60 | }; 61 | 62 | export default Search; 63 | -------------------------------------------------------------------------------- /src/components/ServiceWorkerSetup/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { useUser } from '@app/hooks/useUser'; 4 | import { useEffect } from 'react'; 5 | 6 | const ServiceWorkerSetup = () => { 7 | const { user } = useUser(); 8 | useEffect(() => { 9 | if ('serviceWorker' in navigator && user?.id) { 10 | navigator.serviceWorker 11 | .register('/sw.js') 12 | .then(async (registration) => { 13 | console.log( 14 | '[SW] Registration successful, scope is:', 15 | registration.scope 16 | ); 17 | }) 18 | .catch(function (error) { 19 | console.log('[SW] Service worker registration failed, error:', error); 20 | }); 21 | } 22 | }, [user]); 23 | return null; 24 | }; 25 | 26 | export default ServiceWorkerSetup; 27 | -------------------------------------------------------------------------------- /src/components/Settings/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { ClipboardDocumentIcon } from '@heroicons/react/24/solid'; 2 | import { 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 = ({ textToCopy }: { textToCopy: string }) => { 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 | -------------------------------------------------------------------------------- /src/components/Settings/SettingsBadge.tsx: -------------------------------------------------------------------------------- 1 | import Badge from '@app/components/Common/Badge'; 2 | import Tooltip from '@app/components/Common/Tooltip'; 3 | import globalMessages from '@app/i18n/globalMessages'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | 6 | const messages = defineMessages({ 7 | advancedTooltip: 8 | 'Incorrectly configuring this setting may result in broken functionality', 9 | experimentalTooltip: 10 | 'Enabling this setting may result in unexpected application behavior', 11 | restartrequiredTooltip: 12 | 'Overseerr must be restarted for changes to this setting to take effect', 13 | }); 14 | 15 | const SettingsBadge = ({ 16 | badgeType, 17 | className, 18 | }: { 19 | badgeType: 'advanced' | 'experimental' | 'restartRequired'; 20 | className?: string; 21 | }) => { 22 | const intl = useIntl(); 23 | 24 | switch (badgeType) { 25 | case 'advanced': 26 | return ( 27 | 28 | 29 | {intl.formatMessage(globalMessages.advanced)} 30 | 31 | 32 | ); 33 | case 'experimental': 34 | return ( 35 | 36 | 37 | {intl.formatMessage(globalMessages.experimental)} 38 | 39 | 40 | ); 41 | case 'restartRequired': 42 | return ( 43 | 44 | 45 | {intl.formatMessage(globalMessages.restartRequired)} 46 | 47 | 48 | ); 49 | default: 50 | return null; 51 | } 52 | }; 53 | 54 | export default SettingsBadge; 55 | -------------------------------------------------------------------------------- /src/components/TitleCard/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | interface PlaceholderProps { 2 | canExpand?: boolean; 3 | } 4 | 5 | const Placeholder = ({ canExpand = false }: PlaceholderProps) => { 6 | return ( 7 |
12 |
13 |
14 | ); 15 | }; 16 | 17 | export default Placeholder; 18 | -------------------------------------------------------------------------------- /src/components/ToastContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ToastContainerProps } from 'react-toast-notifications'; 2 | 3 | const ToastContainer = ({ hasToasts, ...props }: ToastContainerProps) => { 4 | return ( 5 |
15 | ); 16 | }; 17 | 18 | export default ToastContainer; 19 | -------------------------------------------------------------------------------- /src/context/InteractionContext.tsx: -------------------------------------------------------------------------------- 1 | import useInteraction from '@app/hooks/useInteraction'; 2 | import React from 'react'; 3 | 4 | interface InteractionContextProps { 5 | isTouch?: boolean; 6 | children?: React.ReactNode; 7 | } 8 | 9 | export const InteractionContext = React.createContext({ 10 | isTouch: false, 11 | }); 12 | 13 | export const InteractionProvider = ({ children }: InteractionContextProps) => { 14 | const isTouch = useInteraction(); 15 | 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/context/SettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces'; 2 | import React from 'react'; 3 | import useSWR from 'swr'; 4 | 5 | export interface SettingsContextProps { 6 | currentSettings: PublicSettingsResponse; 7 | children?: React.ReactNode; 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 | partialRequestsEnabled: true, 21 | cacheImages: false, 22 | vapidPublic: '', 23 | enablePushRegistration: false, 24 | locale: 'en', 25 | emailEnabled: false, 26 | newPlexLogin: true, 27 | }; 28 | 29 | export const SettingsContext = React.createContext({ 30 | currentSettings: defaultSettings, 31 | }); 32 | 33 | export const SettingsProvider = ({ 34 | children, 35 | currentSettings, 36 | }: SettingsContextProps) => { 37 | const { data, error } = useSWR( 38 | '/api/v1/settings/public', 39 | { fallbackData: 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/context/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import type { User } from '@app/hooks/useUser'; 2 | import { useUser } from '@app/hooks/useUser'; 3 | import { useRouter } from 'next/dist/client/router'; 4 | import { useEffect, useRef } from 'react'; 5 | 6 | interface UserContextProps { 7 | initialUser: User; 8 | children?: React.ReactNode; 9 | } 10 | 11 | /** 12 | * This UserContext serves the purpose of just preparing the useUser hooks 13 | * cache on server side render. It also will handle redirecting the user to 14 | * the login page if their session ever becomes invalid. 15 | */ 16 | export const UserContext = ({ initialUser, children }: UserContextProps) => { 17 | const { user, error, revalidate } = useUser({ initialData: initialUser }); 18 | const router = useRouter(); 19 | const routing = useRef(false); 20 | 21 | useEffect(() => { 22 | revalidate(); 23 | }, [router.pathname, revalidate]); 24 | 25 | useEffect(() => { 26 | if ( 27 | !router.pathname.match(/(setup|login|resetpassword)/) && 28 | (!user || error) && 29 | !routing.current 30 | ) { 31 | routing.current = true; 32 | location.href = '/login'; 33 | } 34 | }, [router, user, error]); 35 | 36 | return <>{children}; 37 | }; 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/hooks/useDebouncedState.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch, SetStateAction } from 'react'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | /** 5 | * A hook to help with debouncing state 6 | * 7 | * This hook basically acts the same as useState except it is also 8 | * returning a deobuncedValue that can be used for things like 9 | * debouncing input into a search field 10 | * 11 | * @param initialValue Initial state value 12 | * @param debounceTime Debounce time in ms 13 | */ 14 | const useDebouncedState = ( 15 | initialValue: S, 16 | debounceTime = 300 17 | ): [S, S, Dispatch>] => { 18 | const [value, setValue] = useState(initialValue); 19 | const [finalValue, setFinalValue] = useState(initialValue); 20 | 21 | useEffect(() => { 22 | const timeout = setTimeout(() => { 23 | setFinalValue(value); 24 | }, debounceTime); 25 | 26 | return () => { 27 | clearTimeout(timeout); 28 | }; 29 | }, [value, debounceTime]); 30 | 31 | return [value, finalValue, setValue]; 32 | }; 33 | 34 | export default useDebouncedState; 35 | -------------------------------------------------------------------------------- /src/hooks/useDeepLinks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | interface useDeepLinksProps { 4 | plexUrl?: string; 5 | plexUrl4k?: string; 6 | iOSPlexUrl?: string; 7 | iOSPlexUrl4k?: string; 8 | } 9 | 10 | const useDeepLinks = ({ 11 | plexUrl, 12 | plexUrl4k, 13 | iOSPlexUrl, 14 | iOSPlexUrl4k, 15 | }: useDeepLinksProps) => { 16 | const [returnedPlexUrl, setReturnedPlexUrl] = useState(plexUrl); 17 | const [returnedPlexUrl4k, setReturnedPlexUrl4k] = useState(plexUrl4k); 18 | 19 | useEffect(() => { 20 | if ( 21 | /iPad|iPhone|iPod/.test(navigator.userAgent) || 22 | (navigator.userAgent.includes('Mac') && navigator.maxTouchPoints > 1) 23 | ) { 24 | setReturnedPlexUrl(iOSPlexUrl); 25 | setReturnedPlexUrl4k(iOSPlexUrl4k); 26 | } else { 27 | setReturnedPlexUrl(plexUrl); 28 | setReturnedPlexUrl4k(plexUrl4k); 29 | } 30 | }, [iOSPlexUrl, iOSPlexUrl4k, plexUrl, plexUrl4k]); 31 | 32 | return { plexUrl: returnedPlexUrl, plexUrl4k: returnedPlexUrl4k }; 33 | }; 34 | 35 | export default useDeepLinks; 36 | -------------------------------------------------------------------------------- /src/hooks/useIsTouch.ts: -------------------------------------------------------------------------------- 1 | import { InteractionContext } from '@app/context/InteractionContext'; 2 | import { useContext } from 'react'; 3 | 4 | export const useIsTouch = (): boolean => { 5 | const { isTouch } = useContext(InteractionContext); 6 | return isTouch ?? false; 7 | }; 8 | -------------------------------------------------------------------------------- /src/hooks/useLocale.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageContextProps } from '@app/context/LanguageContext'; 2 | import { LanguageContext } from '@app/context/LanguageContext'; 3 | import { useContext } from 'react'; 4 | 5 | const useLocale = (): Omit => { 6 | const languageContext = useContext(LanguageContext); 7 | 8 | return languageContext; 9 | }; 10 | 11 | export default useLocale; 12 | -------------------------------------------------------------------------------- /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 originalOverflowStyle = window.getComputedStyle( 19 | document.body 20 | ).overflow; 21 | const originalTouchActionStyle = window.getComputedStyle( 22 | document.body 23 | ).touchAction; 24 | if (isLocked && !disabled) { 25 | document.body.style.overflow = 'hidden'; 26 | document.body.style.touchAction = 'none'; 27 | } 28 | return () => { 29 | if (!disabled) { 30 | document.body.style.overflow = originalOverflowStyle; 31 | document.body.style.touchAction = originalTouchActionStyle; 32 | } 33 | }; 34 | }, [isLocked, disabled]); 35 | }; 36 | -------------------------------------------------------------------------------- /src/hooks/useRouteGuard.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { useEffect } from 'react'; 3 | import type { Permission, PermissionCheckOptions } from './useUser'; 4 | import { useUser } from './useUser'; 5 | 6 | const useRouteGuard = ( 7 | permission: Permission | Permission[], 8 | options?: PermissionCheckOptions 9 | ): void => { 10 | const router = useRouter(); 11 | const { user, hasPermission } = useUser(); 12 | 13 | useEffect(() => { 14 | if (user && !hasPermission(permission, options)) { 15 | router.push('/'); 16 | } 17 | }, [user, permission, router, hasPermission, options]); 18 | }; 19 | 20 | export default useRouteGuard; 21 | -------------------------------------------------------------------------------- /src/hooks/useSettings.ts: -------------------------------------------------------------------------------- 1 | import type { SettingsContextProps } from '@app/context/SettingsContext'; 2 | import { SettingsContext } from '@app/context/SettingsContext'; 3 | import { useContext } from 'react'; 4 | 5 | const useSettings = (): SettingsContextProps => { 6 | const settings = useContext(SettingsContext); 7 | 8 | return settings; 9 | }; 10 | 11 | export default useSettings; 12 | -------------------------------------------------------------------------------- /src/i18n/globalMessages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from 'react-intl'; 2 | 3 | const globalMessages = defineMessages({ 4 | available: 'Available', 5 | partiallyavailable: 'Partially Available', 6 | deleted: 'Deleted', 7 | processing: 'Processing', 8 | unavailable: 'Unavailable', 9 | notrequested: 'Not Requested', 10 | requested: 'Requested', 11 | requesting: 'Requesting…', 12 | request: 'Request', 13 | request4k: 'Request in 4K', 14 | failed: 'Failed', 15 | pending: 'Pending', 16 | declined: 'Declined', 17 | approved: 'Approved', 18 | completed: 'Completed', 19 | movie: 'Movie', 20 | movies: 'Movies', 21 | collection: 'Collection', 22 | tvshow: 'Series', 23 | tvshows: 'Series', 24 | cancel: 'Cancel', 25 | canceling: 'Canceling…', 26 | approve: 'Approve', 27 | decline: 'Decline', 28 | delete: 'Delete', 29 | retry: 'Retry', 30 | retrying: 'Retrying…', 31 | view: 'View', 32 | deleting: 'Deleting…', 33 | test: 'Test', 34 | testing: 'Testing…', 35 | save: 'Save Changes', 36 | saving: 'Saving…', 37 | import: 'Import', 38 | importing: 'Importing…', 39 | close: 'Close', 40 | edit: 'Edit', 41 | areyousure: 'Are you sure?', 42 | back: 'Back', 43 | next: 'Next', 44 | previous: 'Previous', 45 | status: 'Status', 46 | all: 'All', 47 | experimental: 'Experimental', 48 | advanced: 'Advanced', 49 | restartRequired: 'Restart Required', 50 | loading: 'Loading…', 51 | settings: 'Settings', 52 | usersettings: 'User Settings', 53 | delimitedlist: '{a}, {b}', 54 | showingresults: 55 | 'Showing {from} to {to} of {total} results', 56 | resultsperpage: 'Display {pageSize} results per page', 57 | noresults: 'No results.', 58 | open: 'Open', 59 | resolved: 'Resolved', 60 | specials: 'Specials', 61 | }); 62 | 63 | export default globalMessages; 64 | -------------------------------------------------------------------------------- /src/i18n/locale/sl.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import PageTitle from '@app/components/Common/PageTitle'; 2 | import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; 3 | import Link from 'next/link'; 4 | import { defineMessages, useIntl } from 'react-intl'; 5 | 6 | const messages = defineMessages({ 7 | errormessagewithcode: '{statusCode} - {error}', 8 | pagenotfound: 'Page Not Found', 9 | returnHome: 'Return Home', 10 | }); 11 | 12 | const Custom404 = () => { 13 | const intl = useIntl(); 14 | 15 | return ( 16 |
17 | 18 |
19 | {intl.formatMessage(messages.errormessagewithcode, { 20 | statusCode: 404, 21 | error: intl.formatMessage(messages.pagenotfound), 22 | })} 23 |
24 | 25 | 26 | {intl.formatMessage(messages.returnHome)} 27 | 28 | 29 | 30 |
31 | ); 32 | }; 33 | 34 | export default Custom404; 35 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import type { DocumentContext, DocumentInitialProps } from 'next/document'; 2 | import Document, { Head, Html, Main, NextScript } from 'next/document'; 3 | 4 | class MyDocument extends Document { 5 | static async getInitialProps( 6 | ctx: DocumentContext 7 | ): Promise { 8 | const initialProps = await Document.getInitialProps(ctx); 9 | 10 | return initialProps; 11 | } 12 | 13 | render(): JSX.Element { 14 | return ( 15 | 16 | 17 | 18 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | ); 29 | } 30 | } 31 | 32 | export default MyDocument; 33 | -------------------------------------------------------------------------------- /src/pages/collection/[collectionId]/index.tsx: -------------------------------------------------------------------------------- 1 | import CollectionDetails from '@app/components/CollectionDetails'; 2 | import type { Collection } from '@server/models/Collection'; 3 | import axios from 'axios'; 4 | import type { GetServerSideProps, NextPage } from 'next'; 5 | 6 | interface CollectionPageProps { 7 | collection?: Collection; 8 | } 9 | 10 | const CollectionPage: NextPage = ({ collection }) => { 11 | return ; 12 | }; 13 | 14 | export const getServerSideProps: GetServerSideProps< 15 | CollectionPageProps 16 | > = async (ctx) => { 17 | const response = await axios.get( 18 | `http://${process.env.HOST || 'localhost'}:${ 19 | process.env.PORT || 5055 20 | }/api/v1/collection/${ctx.query.collectionId}`, 21 | { 22 | headers: ctx.req?.headers?.cookie 23 | ? { cookie: ctx.req.headers.cookie } 24 | : undefined, 25 | } 26 | ); 27 | 28 | return { 29 | props: { 30 | collection: response.data, 31 | }, 32 | }; 33 | }; 34 | 35 | export default CollectionPage; 36 | -------------------------------------------------------------------------------- /src/pages/discover/movies/genre/[genreId]/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverMovieGenre from '@app/components/Discover/DiscoverMovieGenre'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverMoviesGenrePage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverMoviesGenrePage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/movies/genres.tsx: -------------------------------------------------------------------------------- 1 | import MovieGenreList from '@app/components/Discover/MovieGenreList'; 2 | import type { NextPage } from 'next'; 3 | 4 | const MovieGenresPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default MovieGenresPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/movies/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverMovies from '@app/components/Discover/DiscoverMovies'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverMoviesPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverMoviesPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/movies/keyword/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverMovieKeyword from '@app/components/Discover/DiscoverMovieKeyword'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverMoviesKeywordPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverMoviesKeywordPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/movies/language/[language]/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverMovieLanguage from '@app/components/Discover/DiscoverMovieLanguage'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverMovieLanguagePage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverMovieLanguagePage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/movies/studio/[studioId]/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverMovieStudio from '@app/components/Discover/DiscoverStudio'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverMoviesStudioPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverMoviesStudioPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/movies/upcoming.tsx: -------------------------------------------------------------------------------- 1 | import UpcomingMovies from '@app/components/Discover/Upcoming'; 2 | import type { NextPage } from 'next'; 3 | 4 | const UpcomingMoviesPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default UpcomingMoviesPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/trending.tsx: -------------------------------------------------------------------------------- 1 | import Trending from '@app/components/Discover/Trending'; 2 | import type { NextPage } from 'next'; 3 | 4 | const TrendingPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default TrendingPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/tv/genre/[genreId]/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverTvGenre from '@app/components/Discover/DiscoverTvGenre'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverTvGenrePage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverTvGenrePage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/tv/genres.tsx: -------------------------------------------------------------------------------- 1 | import TvGenreList from '@app/components/Discover/TvGenreList'; 2 | import type { NextPage } from 'next'; 3 | 4 | const TvGenresPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default TvGenresPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/tv/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverTv from '@app/components/Discover/DiscoverTv'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverTvPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverTvPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/tv/keyword/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverTvKeyword from '@app/components/Discover/DiscoverTvKeyword'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverTvKeywordPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverTvKeywordPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/tv/language/[language]/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverTvLanguage from '@app/components/Discover/DiscoverTvLanguage'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverTvLanguagePage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverTvLanguagePage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/tv/network/[networkId]/index.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverNetwork from '@app/components/Discover/DiscoverNetwork'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverTvNetworkPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverTvNetworkPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/tv/upcoming.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverTvUpcoming from '@app/components/Discover/DiscoverTvUpcoming'; 2 | import type { NextPage } from 'next'; 3 | 4 | const DiscoverTvPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default DiscoverTvPage; 9 | -------------------------------------------------------------------------------- /src/pages/discover/watchlist.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverWatchlist from '@app/components/Discover/DiscoverWatchlist'; 2 | import type { NextPage } from 'next'; 3 | 4 | const WatchlistPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default WatchlistPage; 9 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Discover from '@app/components/Discover'; 2 | import type { NextPage } from 'next'; 3 | 4 | const Index: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default Index; 9 | -------------------------------------------------------------------------------- /src/pages/issues/[issueId]/index.tsx: -------------------------------------------------------------------------------- 1 | import IssueDetails from '@app/components/IssueDetails'; 2 | import useRouteGuard from '@app/hooks/useRouteGuard'; 3 | import { Permission } from '@app/hooks/useUser'; 4 | import type { NextPage } from 'next'; 5 | 6 | const IssuePage: NextPage = () => { 7 | useRouteGuard( 8 | [ 9 | Permission.MANAGE_ISSUES, 10 | Permission.CREATE_ISSUES, 11 | Permission.VIEW_ISSUES, 12 | ], 13 | { 14 | type: 'or', 15 | } 16 | ); 17 | return ; 18 | }; 19 | 20 | export default IssuePage; 21 | -------------------------------------------------------------------------------- /src/pages/issues/index.tsx: -------------------------------------------------------------------------------- 1 | import IssueList from '@app/components/IssueList'; 2 | import useRouteGuard from '@app/hooks/useRouteGuard'; 3 | import { Permission } from '@app/hooks/useUser'; 4 | import type { NextPage } from 'next'; 5 | 6 | const IssuePage: NextPage = () => { 7 | useRouteGuard( 8 | [ 9 | Permission.MANAGE_ISSUES, 10 | Permission.CREATE_ISSUES, 11 | Permission.VIEW_ISSUES, 12 | ], 13 | { 14 | type: 'or', 15 | } 16 | ); 17 | return ; 18 | }; 19 | 20 | export default IssuePage; 21 | -------------------------------------------------------------------------------- /src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import Login from '@app/components/Login'; 2 | import type { NextPage } from 'next'; 3 | 4 | const LoginPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default LoginPage; 9 | -------------------------------------------------------------------------------- /src/pages/login/plex/loading.tsx: -------------------------------------------------------------------------------- 1 | import LoadingSpinner from '@app/components/Common/LoadingSpinner'; 2 | 3 | const PlexLoading = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default PlexLoading; 12 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/cast.tsx: -------------------------------------------------------------------------------- 1 | import MovieCast from '@app/components/MovieDetails/MovieCast'; 2 | import type { NextPage } from 'next'; 3 | 4 | const MovieCastPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default MovieCastPage; 9 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/crew.tsx: -------------------------------------------------------------------------------- 1 | import MovieCrew from '@app/components/MovieDetails/MovieCrew'; 2 | import type { NextPage } from 'next'; 3 | 4 | const MovieCrewPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default MovieCrewPage; 9 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/index.tsx: -------------------------------------------------------------------------------- 1 | import MovieDetails from '@app/components/MovieDetails'; 2 | import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; 3 | import axios from 'axios'; 4 | import type { GetServerSideProps, NextPage } from 'next'; 5 | 6 | interface MoviePageProps { 7 | movie?: MovieDetailsType; 8 | } 9 | 10 | const MoviePage: NextPage = ({ movie }) => { 11 | return ; 12 | }; 13 | 14 | export const getServerSideProps: GetServerSideProps = async ( 15 | ctx 16 | ) => { 17 | const response = await axios.get( 18 | `http://${process.env.HOST || 'localhost'}:${ 19 | process.env.PORT || 5055 20 | }/api/v1/movie/${ctx.query.movieId}`, 21 | { 22 | headers: ctx.req?.headers?.cookie 23 | ? { cookie: ctx.req.headers.cookie } 24 | : undefined, 25 | } 26 | ); 27 | 28 | return { 29 | props: { 30 | movie: response.data, 31 | }, 32 | }; 33 | }; 34 | 35 | export default MoviePage; 36 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/recommendations.tsx: -------------------------------------------------------------------------------- 1 | import MovieRecommendations from '@app/components/MovieDetails/MovieRecommendations'; 2 | import type { NextPage } from 'next'; 3 | 4 | const MovieRecommendationsPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default MovieRecommendationsPage; 9 | -------------------------------------------------------------------------------- /src/pages/movie/[movieId]/similar.tsx: -------------------------------------------------------------------------------- 1 | import MovieSimilar from '@app/components/MovieDetails/MovieSimilar'; 2 | import type { NextPage } from 'next'; 3 | 4 | const MovieSimilarPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default MovieSimilarPage; 9 | -------------------------------------------------------------------------------- /src/pages/person/[personId]/index.tsx: -------------------------------------------------------------------------------- 1 | import PersonDetails from '@app/components/PersonDetails'; 2 | import type { NextPage } from 'next'; 3 | 4 | const MoviePage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default MoviePage; 9 | -------------------------------------------------------------------------------- /src/pages/profile/index.tsx: -------------------------------------------------------------------------------- 1 | import UserProfile from '@app/components/UserProfile'; 2 | import type { NextPage } from 'next'; 3 | 4 | const UserPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default UserPage; 9 | -------------------------------------------------------------------------------- /src/pages/profile/requests.tsx: -------------------------------------------------------------------------------- 1 | import RequestList from '@app/components/RequestList'; 2 | import type { NextPage } from 'next'; 3 | 4 | const UserRequestsPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default UserRequestsPage; 9 | -------------------------------------------------------------------------------- /src/pages/profile/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserGeneralSettings from '@app/components/UserProfile/UserSettings/UserGeneralSettings'; 3 | import type { NextPage } from 'next'; 4 | 5 | const UserSettingsPage: NextPage = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UserSettingsPage; 14 | -------------------------------------------------------------------------------- /src/pages/profile/settings/main.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserGeneralSettings from '@app/components/UserProfile/UserSettings/UserGeneralSettings'; 3 | import type { NextPage } from 'next'; 4 | 5 | const UserSettingsMainPage: NextPage = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UserSettingsMainPage; 14 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/discord.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsDiscord from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord'; 4 | import type { NextPage } from 'next'; 5 | 6 | const NotificationsPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default NotificationsPage; 17 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/email.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsEmail from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail'; 4 | import type { NextPage } from 'next'; 5 | 6 | const NotificationsPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default NotificationsPage; 17 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/pushbullet.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsPushbullet from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet'; 4 | import type { NextPage } from 'next'; 5 | 6 | const NotificationsPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default NotificationsPage; 17 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/pushover.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsPushover from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover'; 4 | import type { NextPage } from 'next'; 5 | 6 | const NotificationsPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default NotificationsPage; 17 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/telegram.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsTelegram from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram'; 4 | import type { NextPage } from 'next'; 5 | 6 | const NotificationsPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default NotificationsPage; 17 | -------------------------------------------------------------------------------- /src/pages/profile/settings/notifications/webpush.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserWebPushSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush'; 4 | import type { NextPage } from 'next'; 5 | 6 | const WebPushProfileNotificationsPage: NextPage = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default WebPushProfileNotificationsPage; 17 | -------------------------------------------------------------------------------- /src/pages/profile/settings/password.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserPasswordChange from '@app/components/UserProfile/UserSettings/UserPasswordChange'; 3 | import type { NextPage } from 'next'; 4 | 5 | const UserPassswordPage: NextPage = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UserPassswordPage; 14 | -------------------------------------------------------------------------------- /src/pages/profile/settings/permissions.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserPermissions from '@app/components/UserProfile/UserSettings/UserPermissions'; 3 | import type { NextPage } from 'next'; 4 | 5 | const UserPermissionsPage: NextPage = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | export default UserPermissionsPage; 14 | -------------------------------------------------------------------------------- /src/pages/profile/watchlist.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverWatchlist from '@app/components/Discover/DiscoverWatchlist'; 2 | import type { NextPage } from 'next'; 3 | 4 | const UserWatchlistPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default UserWatchlistPage; 9 | -------------------------------------------------------------------------------- /src/pages/requests/index.tsx: -------------------------------------------------------------------------------- 1 | import RequestList from '@app/components/RequestList'; 2 | import type { NextPage } from 'next'; 3 | 4 | const RequestsPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default RequestsPage; 9 | -------------------------------------------------------------------------------- /src/pages/resetpassword/[guid]/index.tsx: -------------------------------------------------------------------------------- 1 | import ResetPassword from '@app/components/ResetPassword'; 2 | import type { NextPage } from 'next'; 3 | 4 | const ResetPasswordPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default ResetPasswordPage; 9 | -------------------------------------------------------------------------------- /src/pages/resetpassword/index.tsx: -------------------------------------------------------------------------------- 1 | import RequestResetLink from '@app/components/ResetPassword/RequestResetLink'; 2 | import type { NextPage } from 'next'; 3 | 4 | const RequestResetLinkPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default RequestResetLinkPage; 9 | -------------------------------------------------------------------------------- /src/pages/search.tsx: -------------------------------------------------------------------------------- 1 | import Search from '@app/components/Search'; 2 | 3 | const SearchPage = () => { 4 | return ; 5 | }; 6 | 7 | export default SearchPage; 8 | -------------------------------------------------------------------------------- /src/pages/settings/about.tsx: -------------------------------------------------------------------------------- 1 | import SettingsAbout from '@app/components/Settings/SettingsAbout'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const SettingsAboutPage: NextPage = () => { 8 | useRouteGuard(Permission.ADMIN); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default SettingsAboutPage; 17 | -------------------------------------------------------------------------------- /src/pages/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 2 | import SettingsMain from '@app/components/Settings/SettingsMain'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const SettingsPage: NextPage = () => { 8 | useRouteGuard(Permission.ADMIN); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default SettingsPage; 17 | -------------------------------------------------------------------------------- /src/pages/settings/jobs.tsx: -------------------------------------------------------------------------------- 1 | import SettingsJobs from '@app/components/Settings/SettingsJobsCache'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const SettingsMainPage: NextPage = () => { 8 | useRouteGuard(Permission.ADMIN); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default SettingsMainPage; 17 | -------------------------------------------------------------------------------- /src/pages/settings/logs.tsx: -------------------------------------------------------------------------------- 1 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 2 | import SettingsLogs from '@app/components/Settings/SettingsLogs'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const SettingsLogsPage: NextPage = () => { 8 | useRouteGuard(Permission.ADMIN); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default SettingsLogsPage; 17 | -------------------------------------------------------------------------------- /src/pages/settings/main.tsx: -------------------------------------------------------------------------------- 1 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 2 | import SettingsMain from '@app/components/Settings/SettingsMain'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const SettingsMainPage: NextPage = () => { 8 | useRouteGuard(Permission.ADMIN); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default SettingsMainPage; 17 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/discord.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsDiscord from '@app/components/Settings/Notifications/NotificationsDiscord'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/email.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsEmail from '@app/components/Settings/Notifications/NotificationsEmail'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/gotify.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsGotify from '@app/components/Settings/Notifications/NotificationsGotify'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/lunasea.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsLunaSea from '@app/components/Settings/Notifications/NotificationsLunaSea'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/pushbullet.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsPushbullet from '@app/components/Settings/Notifications/NotificationsPushbullet'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/pushover.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsPushover from '@app/components/Settings/Notifications/NotificationsPushover'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/slack.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsSlack from '@app/components/Settings/Notifications/NotificationsSlack'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsSlackPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsSlackPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/telegram.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsTelegram from '@app/components/Settings/Notifications/NotificationsTelegram'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/webhook.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsWebhook from '@app/components/Settings/Notifications/NotificationsWebhook'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/notifications/webpush.tsx: -------------------------------------------------------------------------------- 1 | import NotificationsWebPush from '@app/components/Settings/Notifications/NotificationsWebPush'; 2 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 3 | import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsWebPushPage: NextPage = () => { 9 | useRouteGuard(Permission.ADMIN); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsWebPushPage; 20 | -------------------------------------------------------------------------------- /src/pages/settings/plex.tsx: -------------------------------------------------------------------------------- 1 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 2 | import SettingsPlex from '@app/components/Settings/SettingsPlex'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const PlexSettingsPage: NextPage = () => { 8 | useRouteGuard(Permission.ADMIN); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default PlexSettingsPage; 17 | -------------------------------------------------------------------------------- /src/pages/settings/services.tsx: -------------------------------------------------------------------------------- 1 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 2 | import SettingsServices from '@app/components/Settings/SettingsServices'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const ServicesSettingsPage: NextPage = () => { 8 | useRouteGuard(Permission.ADMIN); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default ServicesSettingsPage; 17 | -------------------------------------------------------------------------------- /src/pages/settings/users.tsx: -------------------------------------------------------------------------------- 1 | import SettingsLayout from '@app/components/Settings/SettingsLayout'; 2 | import SettingsUsers from '@app/components/Settings/SettingsUsers'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const SettingsUsersPage: NextPage = () => { 8 | useRouteGuard(Permission.ADMIN); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default SettingsUsersPage; 17 | -------------------------------------------------------------------------------- /src/pages/setup.tsx: -------------------------------------------------------------------------------- 1 | import Setup from '@app/components/Setup'; 2 | import type { NextPage } from 'next'; 3 | 4 | const SetupPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default SetupPage; 9 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/cast.tsx: -------------------------------------------------------------------------------- 1 | import TvCast from '@app/components/TvDetails/TvCast'; 2 | import type { NextPage } from 'next'; 3 | 4 | const TvCastPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default TvCastPage; 9 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/crew.tsx: -------------------------------------------------------------------------------- 1 | import TvCrew from '@app/components/TvDetails/TvCrew'; 2 | import type { NextPage } from 'next'; 3 | 4 | const TvCrewPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default TvCrewPage; 9 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/index.tsx: -------------------------------------------------------------------------------- 1 | import TvDetails from '@app/components/TvDetails'; 2 | import type { TvDetails as TvDetailsType } from '@server/models/Tv'; 3 | import axios from 'axios'; 4 | import type { GetServerSideProps, NextPage } from 'next'; 5 | 6 | interface TvPageProps { 7 | tv?: TvDetailsType; 8 | } 9 | 10 | const TvPage: NextPage = ({ tv }) => { 11 | return ; 12 | }; 13 | 14 | export const getServerSideProps: GetServerSideProps = async ( 15 | ctx 16 | ) => { 17 | const response = await axios.get( 18 | `http://${process.env.HOST || 'localhost'}:${ 19 | process.env.PORT || 5055 20 | }/api/v1/tv/${ctx.query.tvId}`, 21 | { 22 | headers: ctx.req?.headers?.cookie 23 | ? { cookie: ctx.req.headers.cookie } 24 | : undefined, 25 | } 26 | ); 27 | 28 | return { 29 | props: { 30 | tv: response.data, 31 | }, 32 | }; 33 | }; 34 | 35 | export default TvPage; 36 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/recommendations.tsx: -------------------------------------------------------------------------------- 1 | import TvRecommendations from '@app/components/TvDetails/TvRecommendations'; 2 | import type { NextPage } from 'next'; 3 | 4 | const TvRecommendationsPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default TvRecommendationsPage; 9 | -------------------------------------------------------------------------------- /src/pages/tv/[tvId]/similar.tsx: -------------------------------------------------------------------------------- 1 | import TvSimilar from '@app/components/TvDetails/TvSimilar'; 2 | import type { NextPage } from 'next'; 3 | 4 | const TvSimilarPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default TvSimilarPage; 9 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/index.tsx: -------------------------------------------------------------------------------- 1 | import UserProfile from '@app/components/UserProfile'; 2 | import type { NextPage } from 'next'; 3 | 4 | const UserPage: NextPage = () => { 5 | return ; 6 | }; 7 | 8 | export default UserPage; 9 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/requests.tsx: -------------------------------------------------------------------------------- 1 | import RequestList from '@app/components/RequestList'; 2 | import useRouteGuard from '@app/hooks/useRouteGuard'; 3 | import { Permission } from '@app/hooks/useUser'; 4 | import type { NextPage } from 'next'; 5 | 6 | const UserRequestsPage: NextPage = () => { 7 | useRouteGuard([Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], { 8 | type: 'or', 9 | }); 10 | return ; 11 | }; 12 | 13 | export default UserRequestsPage; 14 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserGeneralSettings from '@app/components/UserProfile/UserSettings/UserGeneralSettings'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const UserSettingsPage: NextPage = () => { 8 | useRouteGuard(Permission.MANAGE_USERS); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default UserSettingsPage; 17 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/main.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserGeneralSettings from '@app/components/UserProfile/UserSettings/UserGeneralSettings'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const UserSettingsMainPage: NextPage = () => { 8 | useRouteGuard(Permission.MANAGE_USERS); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default UserSettingsMainPage; 17 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/discord.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsDiscord from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/email.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsEmail from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/pushbullet.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsPushbullet from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/pushover.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsPushover from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/telegram.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserNotificationsTelegram from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const NotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default NotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/notifications/webpush.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; 3 | import UserWebPushSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush'; 4 | import useRouteGuard from '@app/hooks/useRouteGuard'; 5 | import { Permission } from '@app/hooks/useUser'; 6 | import type { NextPage } from 'next'; 7 | 8 | const WebPushNotificationsPage: NextPage = () => { 9 | useRouteGuard(Permission.MANAGE_USERS); 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default WebPushNotificationsPage; 20 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/password.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserPasswordChange from '@app/components/UserProfile/UserSettings/UserPasswordChange'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const UserPassswordPage: NextPage = () => { 8 | useRouteGuard(Permission.MANAGE_USERS); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default UserPassswordPage; 17 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/settings/permissions.tsx: -------------------------------------------------------------------------------- 1 | import UserSettings from '@app/components/UserProfile/UserSettings'; 2 | import UserPermissions from '@app/components/UserProfile/UserSettings/UserPermissions'; 3 | import useRouteGuard from '@app/hooks/useRouteGuard'; 4 | import { Permission } from '@app/hooks/useUser'; 5 | import type { NextPage } from 'next'; 6 | 7 | const UserPermissionsPage: NextPage = () => { 8 | useRouteGuard(Permission.MANAGE_USERS); 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default UserPermissionsPage; 17 | -------------------------------------------------------------------------------- /src/pages/users/[userId]/watchlist.tsx: -------------------------------------------------------------------------------- 1 | import DiscoverWatchlist from '@app/components/Discover/DiscoverWatchlist'; 2 | import useRouteGuard from '@app/hooks/useRouteGuard'; 3 | import { Permission } from '@app/hooks/useUser'; 4 | import type { NextPage } from 'next'; 5 | 6 | const UserRequestsPage: NextPage = () => { 7 | useRouteGuard([Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW], { 8 | type: 'or', 9 | }); 10 | return ; 11 | }; 12 | 13 | export default UserRequestsPage; 14 | -------------------------------------------------------------------------------- /src/pages/users/index.tsx: -------------------------------------------------------------------------------- 1 | import UserList from '@app/components/UserList'; 2 | import useRouteGuard from '@app/hooks/useRouteGuard'; 3 | import { Permission } from '@app/hooks/useUser'; 4 | import type { NextPage } from 'next'; 5 | 6 | const UsersPage: NextPage = () => { 7 | useRouteGuard(Permission.MANAGE_USERS); 8 | return ; 9 | }; 10 | 11 | export default UsersPage; 12 | -------------------------------------------------------------------------------- /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/types/react-intl-auto.d.ts: -------------------------------------------------------------------------------- 1 | import type { 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/utils/creditHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/utils/polyfillIntl.ts: -------------------------------------------------------------------------------- 1 | import { shouldPolyfill as shouldPolyfillDisplayNames } from '@formatjs/intl-displaynames/should-polyfill'; 2 | import { shouldPolyfill as shouldPolyfillLocale } from '@formatjs/intl-locale/should-polyfill'; 3 | import { shouldPolyfill as shouldPolyfillPluralrules } from '@formatjs/intl-pluralrules/should-polyfill'; 4 | 5 | const polyfillLocale = async () => { 6 | if (shouldPolyfillLocale()) { 7 | await import('@formatjs/intl-locale/polyfill'); 8 | } 9 | }; 10 | 11 | const polyfillPluralRules = async (locale: string) => { 12 | const unsupportedLocale = shouldPolyfillPluralrules(locale); 13 | // This locale is supported 14 | if (!unsupportedLocale) { 15 | return; 16 | } 17 | // Load the polyfill 1st BEFORE loading data 18 | await import('@formatjs/intl-pluralrules/polyfill-force'); 19 | await import(`@formatjs/intl-pluralrules/locale-data/${unsupportedLocale}`); 20 | }; 21 | 22 | const polyfillDisplayNames = async (locale: string) => { 23 | const unsupportedLocale = shouldPolyfillDisplayNames(locale); 24 | // This locale is supported 25 | if (!unsupportedLocale) { 26 | return; 27 | } 28 | // Load the polyfill 1st BEFORE loading data 29 | await import('@formatjs/intl-displaynames/polyfill-force'); 30 | await import(`@formatjs/intl-displaynames/locale-data/${unsupportedLocale}`); 31 | }; 32 | 33 | export const polyfillIntl = async (locale: string) => { 34 | await polyfillLocale(); 35 | await polyfillPluralRules(locale); 36 | await polyfillDisplayNames(locale); 37 | }; 38 | -------------------------------------------------------------------------------- /src/utils/refreshIntervalHelper.ts: -------------------------------------------------------------------------------- 1 | import type { DownloadingItem } from '@server/lib/downloadtracker'; 2 | 3 | export const refreshIntervalHelper = ( 4 | downloadItem: { 5 | downloadStatus: DownloadingItem[] | undefined; 6 | downloadStatus4k: DownloadingItem[] | undefined; 7 | }, 8 | timer: number 9 | ) => { 10 | if ( 11 | (downloadItem.downloadStatus ?? []).length > 0 || 12 | (downloadItem.downloadStatus4k ?? []).length > 0 13 | ) { 14 | return timer; 15 | } else { 16 | return 0; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /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( 13 | component: A, 14 | properties: B 15 | ): A & B { 16 | (Object.keys(properties) as (keyof B)[]).forEach((key) => { 17 | Object.assign(component, { [key]: properties[key] }); 18 | }); 19 | return component as A & B; 20 | } 21 | -------------------------------------------------------------------------------- /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 | "incremental": true, 21 | "baseUrl": "src", 22 | "paths": { 23 | "@server/*": ["../server/*"], 24 | "@app/*": ["*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"], 28 | "exclude": ["node_modules"] 29 | } 30 | --------------------------------------------------------------------------------