Repository: seerr-team/seerr Branch: develop Commit: 25e376c74f7e Files: 787 Total size: 7.9 MB Directory structure: gitextract_udf17ict/ ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.yml │ │ ├── config.yml │ │ ├── documentation.yml │ │ ├── enhancement.yml │ │ └── maintenance.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── cliff.toml │ ├── renovate/ │ │ ├── actions.json5 │ │ ├── docker.json5 │ │ ├── groups.json5 │ │ ├── helm.json5 │ │ ├── labels.json5 │ │ ├── pnpm.json5 │ │ └── semanticCommits.json5 │ ├── renovate.json5 │ └── workflows/ │ ├── ci.yml │ ├── codeql.yml │ ├── conflict_labeler.yml │ ├── create-tag.yml │ ├── cypress.yml │ ├── detect-duplicate.yml │ ├── docs-deploy.yml │ ├── docs-link-check.yml │ ├── helm.yml │ ├── lint-helm-charts.yml │ ├── preview.yml │ ├── rebuild-issue-index.yml │ ├── release.yml │ ├── renovate-helm-custom-hooks.yml │ ├── seerr-labeller.yml │ ├── semantic-pr.yml │ ├── stale.yml │ ├── test-docs-deploy.yml │ └── trivy-scan.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ ├── pre-commit │ └── prepare-commit-msg ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.local ├── LICENSE ├── README.md ├── SECURITY.md ├── bin/ │ ├── check-i18n.js │ ├── duplicate-detector/ │ │ ├── .gitignore │ │ ├── build-index.mjs │ │ ├── detect.mjs │ │ ├── package.json │ │ └── utils.mjs │ └── prepare.js ├── charts/ │ └── seerr-chart/ │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── README.md.gotmpl │ ├── artifacthub-repo.yml │ ├── templates/ │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── ingress.yaml │ │ ├── persistentvolumeclaim.yaml │ │ ├── route.yaml │ │ ├── service.yaml │ │ ├── serviceaccount.yaml │ │ ├── statefulset.yaml │ │ └── tests/ │ │ └── test-connection.yaml │ └── values.yaml ├── compose.postgres.yaml ├── compose.yaml ├── config/ │ ├── .gitkeep │ ├── db/ │ │ └── .gitkeep │ └── logs/ │ └── .gitkeep ├── cypress/ │ ├── config/ │ │ └── settings.cypress.json │ ├── e2e/ │ │ ├── discover.cy.ts │ │ ├── login.cy.ts │ │ ├── movie-details.cy.ts │ │ ├── providers/ │ │ │ └── tvdb.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 ├── cypress.config.ts ├── docs/ │ ├── README.md │ ├── extending-seerr/ │ │ ├── _category_.json │ │ ├── database-config.mdx │ │ └── reverse-proxy.mdx │ ├── getting-started/ │ │ ├── _category_.json │ │ ├── buildfromsource.mdx │ │ ├── docker.mdx │ │ ├── index.mdx │ │ ├── kubernetes.mdx │ │ └── third-parties/ │ │ ├── aur.mdx │ │ ├── index.mdx │ │ ├── nixpkg.mdx │ │ ├── synology.mdx │ │ ├── truenas.mdx │ │ └── unraid.mdx │ ├── migration-guide.mdx │ ├── troubleshooting.mdx │ └── using-seerr/ │ ├── _category_.json │ ├── advanced/ │ │ ├── index.mdx │ │ └── verifying-signed-artifacts.mdx │ ├── backups.md │ ├── notifications/ │ │ ├── discord.md │ │ ├── email.md │ │ ├── gotify.md │ │ ├── index.mdx │ │ ├── ntfy.md │ │ ├── pushbullet.md │ │ ├── pushover.md │ │ ├── slack.md │ │ ├── telegram.md │ │ ├── webhook.md │ │ └── webpush.md │ ├── plex/ │ │ ├── _category_.json │ │ ├── index.md │ │ └── watchlist-auto-request.md │ ├── settings/ │ │ ├── _category_.json │ │ ├── dns-caching.md │ │ ├── general.md │ │ ├── jobs&cache.md │ │ ├── mediaserver.mdx │ │ ├── notifications.mdx │ │ ├── services.md │ │ └── users.md │ └── users/ │ ├── _category_.json │ ├── adding-users.mdx │ ├── deleting-users.md │ ├── editing-users.md │ └── owner.md ├── eslint.config.mts ├── gen-docs/ │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── blog/ │ │ ├── 2025-09-29-introducing-seerr-blog.md │ │ ├── 2026-02-10/ │ │ │ └── seerr-release.md │ │ ├── 2026-02-28-seerr-security-fix-release.md │ │ └── authors.yml │ ├── docusaurus.config.ts │ ├── package.json │ ├── sidebars.ts │ ├── src/ │ │ ├── components/ │ │ │ └── SeerrVersion/ │ │ │ └── index.tsx │ │ └── css/ │ │ └── custom.css │ ├── static/ │ │ ├── .nojekyll │ │ └── CNAME │ ├── tailwind.config.js │ └── tsconfig.json ├── next-env.d.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── public/ │ ├── offline.html │ ├── robots.txt │ ├── site.webmanifest │ └── sw.js ├── seerr-api.yml ├── server/ │ ├── api/ │ │ ├── animelist.ts │ │ ├── externalapi.ts │ │ ├── github.ts │ │ ├── jellyfin.ts │ │ ├── metadata.ts │ │ ├── plexapi.ts │ │ ├── plextv.ts │ │ ├── provider.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 │ │ └── tvdb/ │ │ ├── index.ts │ │ └── interfaces.ts │ ├── constants/ │ │ ├── discover.ts │ │ ├── error.ts │ │ ├── issue.ts │ │ ├── media.ts │ │ ├── server.ts │ │ └── user.ts │ ├── datasource.ts │ ├── entity/ │ │ ├── Blocklist.ts │ │ ├── DiscoverSlider.ts │ │ ├── Issue.ts │ │ ├── IssueComment.ts │ │ ├── Media.ts │ │ ├── MediaRequest.ts │ │ ├── OverrideRule.ts │ │ ├── Season.ts │ │ ├── SeasonRequest.ts │ │ ├── Session.ts │ │ ├── User.ts │ │ ├── UserPushSubscription.ts │ │ ├── UserSettings.ts │ │ └── Watchlist.ts │ ├── index.ts │ ├── interfaces/ │ │ └── api/ │ │ ├── blocklistInterfaces.ts │ │ ├── common.ts │ │ ├── discoverInterfaces.ts │ │ ├── issueInterfaces.ts │ │ ├── mediaInterfaces.ts │ │ ├── overrideRuleInterfaces.ts │ │ ├── personInterfaces.ts │ │ ├── plexInterfaces.ts │ │ ├── requestInterfaces.ts │ │ ├── serviceInterfaces.ts │ │ ├── settingsInterfaces.ts │ │ ├── userInterfaces.ts │ │ ├── userSettingsInterfaces.ts │ │ └── watchlistCreate.ts │ ├── job/ │ │ ├── blocklistedTagsProcessor.ts │ │ └── 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 │ │ │ │ ├── ntfy.ts │ │ │ │ ├── pushbullet.ts │ │ │ │ ├── pushover.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── telegram.ts │ │ │ │ ├── webhook.ts │ │ │ │ └── webpush.ts │ │ │ └── index.ts │ │ ├── overseerrMerge.ts │ │ ├── permissions.ts │ │ ├── refreshToken.ts │ │ ├── scanners/ │ │ │ ├── baseScanner.ts │ │ │ ├── jellyfin/ │ │ │ │ └── index.ts │ │ │ ├── plex/ │ │ │ │ └── index.ts │ │ │ ├── radarr/ │ │ │ │ └── index.ts │ │ │ └── sonarr/ │ │ │ └── index.ts │ │ ├── search.ts │ │ ├── settings/ │ │ │ ├── index.ts │ │ │ ├── migrations/ │ │ │ │ ├── 0001_migrate_hostname.ts │ │ │ │ ├── 0002_migrate_apitokens.ts │ │ │ │ ├── 0003_emby_media_server_type.ts │ │ │ │ ├── 0004_migrate_region_setting.ts │ │ │ │ ├── 0005_migrate_network_settings.ts │ │ │ │ ├── 0006_remove_lunasea.ts │ │ │ │ ├── 0007_migrate_arr_tags.ts │ │ │ │ └── 0008_migrate_blacklist_to_blocklist.ts │ │ │ └── migrator.ts │ │ └── watchlistsync.ts │ ├── logger.ts │ ├── middleware/ │ │ ├── auth.ts │ │ ├── clearcookies.ts │ │ └── deprecation.ts │ ├── migration/ │ │ ├── postgres/ │ │ │ ├── 1734786061496-InitialMigration.ts │ │ │ ├── 1734786596045-AddTelegramMessageThreadId.ts │ │ │ ├── 1734805738349-AddOverrideRules.ts │ │ │ ├── 1734809898562-FixNullFields.ts │ │ │ ├── 1737320080282-AddBlacklistTagsColumn.ts │ │ │ ├── 1743023615532-UpdateWebPush.ts │ │ │ ├── 1743107707465-AddUserAvatarCacheFields.ts │ │ │ ├── 1745492376568-UpdateWebPush.ts │ │ │ ├── 1746811308203-FixIssueTimestamps.ts │ │ │ ├── 1765233385034-AddUniqueConstraintToPushSubscription.ts │ │ │ ├── 1770627987304-AddPerformanceIndexes.ts │ │ │ ├── 1771080196816-RenameBlacklistToBlocklist.ts │ │ │ ├── 1771259406751-AddForeignKeyIndexes.ts │ │ │ ├── 1771337333450-RecoveryLinkExpirationDateTime.ts │ │ │ ├── 1772000000000-FixBlocklistIdDefault.ts │ │ │ └── 1772048000333-AddMediaTypeToUniqueConstraints.ts │ │ └── sqlite/ │ │ ├── 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 │ │ ├── 1613379909641-AddJellyfinUserParams.ts │ │ ├── 1613412948344-ServerTypeEnum.ts │ │ ├── 1613615266968-CreateUserSettings.ts │ │ ├── 1613670041760-AddJellyfinDeviceId.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 │ │ ├── 1682608634546-AddWatchlists.ts │ │ ├── 1697393491630-AddUserPushoverSound.ts │ │ ├── 1699901142442-AddBlacklist.ts │ │ ├── 1727907530757-AddUserSettingsStreamingRegion.ts │ │ ├── 1734287582736-AddTelegramMessageThreadId.ts │ │ ├── 1734805733535-AddOverrideRules.ts │ │ ├── 1737320080282-AddBlacklistTagsColumn.ts │ │ ├── 1743023610704-UpdateWebPush.ts │ │ ├── 1743107645301-AddUserAvatarCacheFields.ts │ │ ├── 1745492372230-UpdateWebPush.ts │ │ ├── 1765233385034-AddUniqueConstraintToPushSubscription.ts │ │ ├── 1770627968781-AddPerformanceIndexes.ts │ │ ├── 1771080196816-RenameBlacklistToBlocklist.ts │ │ ├── 1771259394105-AddForeignKeyIndexes.ts │ │ ├── 1771337037917-RecoveryLinkExpirationDateTime.ts │ │ └── 1772047972752-AddMediaTypeToUniqueConstraints.ts │ ├── models/ │ │ ├── Collection.ts │ │ ├── Movie.ts │ │ ├── Person.ts │ │ ├── Search.ts │ │ ├── Tv.ts │ │ └── common.ts │ ├── repositories/ │ │ └── watchlist.repository.ts │ ├── routes/ │ │ ├── auth.test.ts │ │ ├── auth.ts │ │ ├── avatarproxy.ts │ │ ├── blocklist.ts │ │ ├── collection.ts │ │ ├── discover.ts │ │ ├── imageproxy.ts │ │ ├── index.ts │ │ ├── issue.ts │ │ ├── issueComment.ts │ │ ├── media.ts │ │ ├── movie.ts │ │ ├── overrideRule.ts │ │ ├── person.ts │ │ ├── request.ts │ │ ├── search.ts │ │ ├── service.ts │ │ ├── settings/ │ │ │ ├── discover.ts │ │ │ ├── index.ts │ │ │ ├── metadata.ts │ │ │ ├── notifications.ts │ │ │ ├── radarr.ts │ │ │ └── sonarr.ts │ │ ├── tv.ts │ │ ├── user/ │ │ │ ├── index.ts │ │ │ └── usersettings.ts │ │ └── watchlist.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 │ ├── test/ │ │ ├── db.ts │ │ ├── index.mts │ │ └── setup.ts │ ├── tsconfig.json │ ├── types/ │ │ ├── custom.d.ts │ │ ├── error.ts │ │ ├── express-session.d.ts │ │ ├── express.d.ts │ │ └── languages.d.ts │ └── utils/ │ ├── DbColumnHelper.ts │ ├── appDataVolume.ts │ ├── appVersion.ts │ ├── asyncLock.ts │ ├── customProxyAgent.ts │ ├── dateHelpers.ts │ ├── dnsCache.ts │ ├── getHostname.ts │ ├── jellyfin.ts │ ├── profileMiddleware.ts │ ├── restartFlag.ts │ ├── seedTestDb.ts │ └── typeHelpers.ts ├── src/ │ ├── components/ │ │ ├── AirDateBadge/ │ │ │ └── index.tsx │ │ ├── AppDataWarning/ │ │ │ └── index.tsx │ │ ├── Blocklist/ │ │ │ └── index.tsx │ │ ├── BlocklistBlock/ │ │ │ └── index.tsx │ │ ├── BlocklistModal/ │ │ │ └── index.tsx │ │ ├── BlocklistedTagsBadge/ │ │ │ └── index.tsx │ │ ├── BlocklistedTagsSelector/ │ │ │ └── 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 │ │ │ ├── Dropdown/ │ │ │ │ └── index.tsx │ │ │ ├── Header/ │ │ │ │ └── index.tsx │ │ │ ├── ImageFader/ │ │ │ │ └── index.tsx │ │ │ ├── LabeledCheckbox/ │ │ │ │ └── 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 │ │ │ ├── UserWarnings/ │ │ │ │ └── index.tsx │ │ │ ├── VersionStatus/ │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── LoadingBar/ │ │ │ └── index.tsx │ │ ├── Login/ │ │ │ ├── AddEmailModal.tsx │ │ │ ├── JellyfinLogin.tsx │ │ │ ├── LocalLogin.tsx │ │ │ ├── PlexLoginButton.tsx │ │ │ └── index.tsx │ │ ├── ManageSlideOver/ │ │ │ └── index.tsx │ │ ├── MediaSlider/ │ │ │ ├── ShowMoreCard/ │ │ │ │ └── index.tsx │ │ │ └── index.tsx │ │ ├── MetadataSelector/ │ │ │ └── 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 │ │ ├── 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/ │ │ │ ├── CertificationSelector.tsx │ │ │ ├── USCertificationSelector.tsx │ │ │ └── index.tsx │ │ ├── ServiceWorkerSetup/ │ │ │ └── index.tsx │ │ ├── Settings/ │ │ │ ├── CopyButton.tsx │ │ │ ├── LibraryItem.tsx │ │ │ ├── Notifications/ │ │ │ │ ├── NotificationsDiscord.tsx │ │ │ │ ├── NotificationsEmail.tsx │ │ │ │ ├── NotificationsGotify/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── NotificationsNtfy/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── NotificationsPushbullet/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── NotificationsPushover/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── NotificationsSlack/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── NotificationsTelegram.tsx │ │ │ │ ├── NotificationsWebPush/ │ │ │ │ │ └── index.tsx │ │ │ │ └── NotificationsWebhook/ │ │ │ │ └── index.tsx │ │ │ ├── OverrideRule/ │ │ │ │ ├── OverrideRuleModal.tsx │ │ │ │ └── OverrideRuleTiles.tsx │ │ │ ├── RadarrModal/ │ │ │ │ └── index.tsx │ │ │ ├── SettingsAbout/ │ │ │ │ ├── Releases/ │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ ├── SettingsBadge.tsx │ │ │ ├── SettingsJellyfin.tsx │ │ │ ├── SettingsJobsCache/ │ │ │ │ └── index.tsx │ │ │ ├── SettingsLayout.tsx │ │ │ ├── SettingsLogs/ │ │ │ │ └── index.tsx │ │ │ ├── SettingsMain/ │ │ │ │ └── index.tsx │ │ │ ├── SettingsMetadata.tsx │ │ │ ├── SettingsNetwork/ │ │ │ │ └── index.tsx │ │ │ ├── SettingsNotifications.tsx │ │ │ ├── SettingsPlex.tsx │ │ │ ├── SettingsServices.tsx │ │ │ ├── SettingsUsers/ │ │ │ │ └── index.tsx │ │ │ └── SonarrModal/ │ │ │ └── index.tsx │ │ ├── Setup/ │ │ │ ├── JellyfinSetup.tsx │ │ │ ├── LoginWithPlex.tsx │ │ │ ├── SetupLogin.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 │ │ │ ├── JellyfinImportModal.tsx │ │ │ ├── PlexImportModal.tsx │ │ │ └── index.tsx │ │ └── UserProfile/ │ │ ├── ProfileHeader/ │ │ │ └── index.tsx │ │ ├── UserSettings/ │ │ │ ├── UserGeneralSettings/ │ │ │ │ └── index.tsx │ │ │ ├── UserLinkedAccountsSettings/ │ │ │ │ ├── LinkJellyfinModal.tsx │ │ │ │ └── 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 │ │ ├── usePlexLogin.ts │ │ ├── useRequestOverride.ts │ │ ├── useRouteGuard.ts │ │ ├── useSearchInput.ts │ │ ├── useSettings.ts │ │ ├── useUpdateQueryParams.ts │ │ ├── useUser.ts │ │ └── useVerticalScroll.ts │ ├── i18n/ │ │ ├── extractMessages.ts │ │ ├── globalMessages.ts │ │ └── locale/ │ │ ├── ar.json │ │ ├── bg.json │ │ ├── ca.json │ │ ├── cs.json │ │ ├── da.json │ │ ├── de.json │ │ ├── el.json │ │ ├── en.json │ │ ├── es.json │ │ ├── es_MX.json │ │ ├── et.json │ │ ├── eu.json │ │ ├── fi.json │ │ ├── fr.json │ │ ├── he.json │ │ ├── hi.json │ │ ├── hr.json │ │ ├── hu.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── lb.json │ │ ├── lt.json │ │ ├── nb_NO.json │ │ ├── nl.json │ │ ├── pl.json │ │ ├── pt_BR.json │ │ ├── pt_PT.json │ │ ├── ro.json │ │ ├── ru.json │ │ ├── sk.json │ │ ├── sl.json │ │ ├── sq.json │ │ ├── sr.json │ │ ├── sv.json │ │ ├── tr.json │ │ ├── uk.json │ │ ├── vi.json │ │ ├── zh_Hans.json │ │ └── zh_Hant.json │ ├── pages/ │ │ ├── 404.tsx │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── _error.tsx │ │ ├── blocklist/ │ │ │ └── index.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 │ │ │ ├── settings/ │ │ │ │ ├── index.tsx │ │ │ │ ├── linked-accounts.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 │ │ │ ├── jellyfin.tsx │ │ │ ├── jobs.tsx │ │ │ ├── logs.tsx │ │ │ ├── main.tsx │ │ │ ├── metadata.tsx │ │ │ ├── network.tsx │ │ │ ├── notifications/ │ │ │ │ ├── discord.tsx │ │ │ │ ├── email.tsx │ │ │ │ ├── gotify.tsx │ │ │ │ ├── ntfy.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 │ │ │ │ ├── linked-accounts.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 │ ├── defineMessages.ts │ ├── jellyfin.ts │ ├── numberHelpers.ts │ ├── plex.ts │ ├── polyfillIntl.ts │ ├── pushSubscriptionHelpers.ts │ ├── refreshIntervalHelper.ts │ ├── typeHelpers.ts │ └── urlValidationHelper.ts ├── stylelint.config.js ├── tailwind.config.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ **/*.md **/.gitkeep **/.vscode .dockerignore .editorconfig .eslintrc.js .git .gitconfig .github .gitignore .husky .next .prettierignore .vscode charts config/db/* config/logs/* config/*.json cypress dist Dockerfile* compose.yaml gen-docs docs LICENSE node_modules public/os_logo_filled.png public/preview.jpg stylelint.config.js ================================================ FILE: .editorconfig ================================================ # editorconfig.org root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true indent_style = space indent_size = 2 trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false ================================================ FILE: .gitattributes ================================================ * text eol=lf # ## These files are binary and should be left untouched # # (binary is a macro for -text -diff) *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.mov binary *.mp4 binary *.mp3 binary *.flv binary *.fla binary *.swf binary *.gz binary *.zip binary *.7z binary *.ttf binary *.eot binary *.woff binary *.pyc binary *.pdf binary # ## Theses files/directories should be excluded from git archives # .husky export-ignore .vscode export-ignore docs export-ignore .git* export-ignore *ignore export-ignore *.md export-ignore .editorconfig export-ignore Dockerfile.local export-ignore compose.yaml export-ignore stylelint.config.js export-ignore public/os_logo_filled.png export-ignore public/preview.jpg export-ignore ================================================ FILE: .github/CODEOWNERS ================================================ # Global code ownership * @seerr-team/seerr-core ================================================ FILE: .github/FUNDING.yml ================================================ open_collective: seerr ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yml ================================================ name: 🐛 Bug Report description: Report a problem labels: ['awaiting triage'] type: bug body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! Please note that we use GitHub issues exclusively for bug reports and feature requests. For support requests, please use our other support channels to get help. - type: textarea id: description attributes: label: Description description: Please provide a clear and concise description of the bug or issue. validations: required: true - type: input id: version attributes: label: Version description: What version of Seerr are you running? (You can find this in Settings → About → Version.) validations: required: true - type: textarea id: repro-steps attributes: label: Steps to Reproduce description: Please tell us how we can reproduce the undesired behavior. placeholder: | 1. Go to [...] 2. Click on [...] 3. Scroll down to [...] 4. See error in [...] validations: required: true - type: textarea id: screenshots attributes: label: Screenshots description: If applicable, please provide screenshots depicting the problem. - type: textarea id: logs attributes: label: Logs description: Please copy and paste any relevant log output. (This will be automatically formatted into code, so no need for backticks.) render: shell - type: dropdown id: platform attributes: label: Platform options: - desktop - smartphone - tablet validations: required: true - type: dropdown id: database attributes: options: - SQLite (default) - PostgreSQL label: Database description: Which database backend are you using? validations: required: true - type: input id: device attributes: label: Device description: e.g., iPhone X, Surface Pro, Samsung Galaxy Tab validations: required: true - type: input id: os attributes: label: Operating System description: e.g., iOS 8.1, Windows 10, Android 11 validations: required: true - type: input id: browser attributes: label: Browser description: e.g., Chrome, Safari, Edge, Firefox validations: required: true - type: textarea id: additional-context attributes: label: Additional Context description: Please provide any additional information that may be relevant or helpful. - type: checkboxes id: search-existing attributes: label: Search Existing Issues description: Have you searched existing issues to see if this bug has already been reported? options: - label: Yes, I have searched existing issues. required: true - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/seerr-team/seerr/blob/develop/CODE_OF_CONDUCT.md) options: - label: I agree to follow Seerr's Code of Conduct required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 💬 Support via Discord url: https://discord.gg/seerr about: Chat with other users and the Seerr dev team - name: 💬 Support via GitHub Discussions url: https://github.com/seerr-team/seerr/discussions about: Ask questions and discuss with other community members ================================================ FILE: .github/ISSUE_TEMPLATE/documentation.yml ================================================ name: 📚 Documentation description: Report a docs problem or suggest a docs improvement title: "[Docs]: " labels: ["documentation", "awaiting triage"] type: task body: - type: markdown attributes: value: | Thanks for helping improve the docs! Use this template for documentation issues (typos, unclear steps, missing info, outdated screenshots). For app bugs or feature ideas, please use the other templates. - type: input id: doc-location attributes: label: Page / Location description: Link to the docs page or the file/path (e.g. https://docs.seerr.dev/... or README.md) placeholder: "https://docs.seerr.dev/..." validations: required: true - type: dropdown id: doc-area attributes: label: Docs Area options: - docs site - migration guide - README / repo docs - API / integrations - other validations: required: true - type: textarea id: problem attributes: label: What’s wrong / missing? description: Describe the issue in the docs. validations: required: true - type: textarea id: suggested-fix attributes: label: Suggested change description: If you know what should be changed, describe it (or paste proposed wording). validations: required: false - type: checkboxes id: search-existing attributes: label: Search Existing Issues description: Have you searched existing issues to see if this has already been reported? options: - label: Yes, I have searched existing issues. required: true - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our Code of Conduct. options: - label: I agree to follow Seerr's [Code of Conduct](https://github.com/seerr-team/seerr/blob/develop/CODE_OF_CONDUCT.md). required: true ================================================ FILE: .github/ISSUE_TEMPLATE/enhancement.yml ================================================ name: ✨ Feature Request description: Suggest an idea labels: ['awaiting triage'] type: feature body: - type: markdown attributes: value: | Thanks for taking the time to fill out this feature request! Please note that we use GitHub issues exclusively for bug reports and feature requests. For support requests, please use our other support channels to get help. - type: textarea id: description attributes: label: Description description: Is your feature request related to a problem? If so, please provide a clear and concise description of the problem; e.g., "I'm always frustrated when [...]." validations: required: true - type: textarea id: desired-behavior attributes: label: Desired Behavior description: Provide a clear and concise description of what you want to happen. validations: required: true - type: textarea id: additional-context attributes: label: Additional Context description: Provide any additional information or screenshots that may be relevant or helpful. - type: checkboxes id: search-existing attributes: label: Search Existing Issues description: Have you searched existing issues to see if this feature has already been requested? options: - label: Yes, I have searched existing issues. required: true - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/seerr-team/seerr/blob/develop/CODE_OF_CONDUCT.md) options: - label: I agree to follow Seerr's Code of Conduct required: true ================================================ FILE: .github/ISSUE_TEMPLATE/maintenance.yml ================================================ name: 🧰 Maintenance / Chore description: CI, GitHub Actions, build, dependencies, refactors (non-feature work) title: "[Chore]: " labels: ["maintenance", "awaiting triage"] type: task body: - type: markdown attributes: value: | Maintainers / contributors: use this for internal tasks (CI, workflows, tooling, refactors). If you're reporting a user-facing bug or requesting a feature, use the other templates. - type: dropdown id: area attributes: label: Area options: - CI / GitHub Actions - build / packaging - dependencies - release process - refactor / tech debt - tooling / scripts - other validations: required: true - type: textarea id: summary attributes: label: Summary description: What needs doing and why? validations: required: true - type: textarea id: acceptance attributes: label: Acceptance criteria description: What does "done" look like? placeholder: | - [ ] ... - [ ] ... validations: required: false - type: input id: related attributes: label: Related links description: PRs, failing workflow runs, logs, or relevant issues. validations: required: false - type: checkboxes id: search-existing attributes: label: Search Existing Issues description: Have you searched existing issues to see if this has already been reported? options: - label: Yes, I have searched existing issues. required: true - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow our Code of Conduct. options: - label: I agree to follow Seerr's [Code of Conduct](https://github.com/seerr-team/seerr/blob/develop/CODE_OF_CONDUCT.md). required: true ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description - Fixes #XXXX ## How Has This Been Tested? ## Screenshots / Logs (if applicable) ## Checklist: - [ ] I have read and followed the contribution [guidelines](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md). - [ ] Disclosed any use of AI (see our [policy](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md#ai-assistance-notice)) - [ ] I have updated the documentation accordingly. - [ ] All new and existing tests passed. - [ ] Successful build `pnpm build` - [ ] Translation keys `pnpm i18n:extract` - [ ] Database migration (if required) ================================================ FILE: .github/cliff.toml ================================================ # git-cliff ~ configuration # https://git-cliff.org/docs/configuration [changelog] header = "" body = """ {%- macro remote_url() -%} https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} {%- endmacro -%} {%- set excluded_users = ["github-actions[bot]", "dependabot[bot]", "renovate[bot]"] -%} {% macro print_commit(commit) -%} - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ {% if commit.breaking %}[**breaking**] {% endif %}\ {{ commit.message | upper_first }} - \ ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}))\ {% endmacro -%} {% if version %}\ {% if previous.version %}\ ## [{{ version | trim_start_matches(pat="v") }}]({{ self::remote_url() }}/compare/{{ previous.version }}..{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} {% else %}\ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} {% endif %}\ {% else %}\ ## [unreleased] {% endif %}\ {%- for group, commits in commits | group_by(attribute="group") %} ### {{ group | striptags | trim | upper_first }} {%- for commit in commits | filter(attribute="scope") | sort(attribute="scope") %} {{ self::print_commit(commit=commit) }} {%- endfor %} {%- for commit in commits %} {%- if not commit.scope %} {{ self::print_commit(commit=commit) }} {%- endif %} {%- endfor -%} {%- endfor -%} {%- set valid_contributors = [] -%} {%- for c in github.contributors | filter(attribute="is_first_time", value=true) %} {%- if c.username and c.username not in excluded_users and c.username not in valid_contributors %} {%- set_global valid_contributors = valid_contributors | concat(with=c.username) %} {%- endif %} {%- endfor %} {%- if valid_contributors | length > 0 %} ## New Contributors ❤️ {%- for username in valid_contributors %} * @{{ username }} made their first contribution {%- endfor %} {%- endif %} """ footer = """ """ trim = true postprocessors = [] [git] conventional_commits = true filter_unconventional = true split_commits = false filter_commits = true commit_preprocessors = [ { pattern = '.*\[skip ci\].*', replace = "" }, { pattern = '.*\[ci skip\].*', replace = "" }, ] commit_parsers = [ { message = '.*\(helm\).*', skip = true }, { message = '^chore\(release\): prepare for', skip = true }, { message = '^chore\(deps.*\)', skip = true }, { body = ".*security", group = "🛡️ Security" }, { message = "^feat", group = "🚀 Features" }, { message = "^fix", group = "🐛 Bug Fixes" }, { message = "^doc", group = "📖 Documentation" }, { message = "^perf", group = "⚡ Performance" }, { message = "^refactor", group = "🚜 Refactor" }, { message = "^style", group = "🎨 Styling" }, { message = "^test", group = "🧪 Testing" }, { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, { message = "^revert", group = "◀️ Revert" }, ] protect_breaking_commits = false tag_pattern = "v?[0-9]+\\.[0-9]+\\.[0-9]+.*" skip_tags = "beta|alpha|rc" topo_order = false sort_commits = "newest" ================================================ FILE: .github/renovate/actions.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', extends: ['helpers:pinGitHubActionDigests'], packageRules: [ // All GitHub Actions need manual review { matchManagers: ['github-actions'], groupName: 'GitHub Actions', }, ], } ================================================ FILE: .github/renovate/docker.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', extends: [ 'docker:enableMajor', 'docker:pinDigests' ], packageRules: [ { matchManagers: ['docker-compose'], pinDigests: false, }, ], } ================================================ FILE: .github/renovate/groups.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', packageRules: [ // Node.js { matchPackageNames: ['node'], matchManagers: ['dockerfile', 'npm'], groupName: 'Node.js', commitMessageTopic: 'Node.js', }, // Database packages { matchPackageNames: ['pg', 'sqlite3', 'typeorm'], groupName: 'Database', }, ], } ================================================ FILE: .github/renovate/helm.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', packageRules: [ { matchManagers: ['helm-values'], matchFileNames: ['charts/*/values.yaml'], minimumReleaseAge: '0', pinDigests: false, }, ], customManagers: [ { customType: 'regex', description: 'Update appVersion in Chart.yaml to match Docker image', fileMatch: ['(^|/)Chart\\.yaml$'], matchStrings: [ "#\\s+renovate:\\s+image=(?\\S*)\nappVersion:\\s+'(?\\S*)'", ], datasourceTemplate: 'docker', }, ], } ================================================ FILE: .github/renovate/labels.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', packageRules: [ // JavaScript/npm packages { matchManagers: ['npm'], addLabels: ['javascript'], }, // GitHub Actions { matchManagers: ['github-actions'], addLabels: ['github_actions'], }, // Docker images { matchManagers: ['dockerfile', 'docker-compose'], addLabels: ['docker'], }, // Helm charts { matchManagers: ['helm-values'], addLabels: ['helm'], }, ], } ================================================ FILE: .github/renovate/pnpm.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', // Run pnpm dedupe after dependency updates postUpdateOptions: ['pnpmDedupe'], lockFileMaintenance: { enabled: true, }, } ================================================ FILE: .github/renovate/semanticCommits.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', packageRules: [ // Default for all dependencies { matchPackagePatterns: ['*'], semanticCommitType: 'chore', semanticCommitScope: 'deps', }, // Node.js runtime { matchPackageNames: ['node'], semanticCommitType: 'build', semanticCommitScope: 'node', }, // GitHub Actions { matchManagers: ['github-actions'], semanticCommitType: 'ci', semanticCommitScope: 'actions', }, // Docker { matchManagers: ['dockerfile'], semanticCommitType: 'build', semanticCommitScope: 'docker', }, ], } ================================================ FILE: .github/renovate.json5 ================================================ { $schema: 'https://docs.renovatebot.com/renovate-schema.json', extends: [ 'config:recommended', ':dependencyDashboard', ':timezone(UTC)', 'group:allNonMajor', 'group:nextjsMonorepo', 'group:reactMonorepo', 'group:typescript-eslintMonorepo', 'group:tailwindcssMonorepo', 'github>seerr-team/seerr//.github/renovate/actions.json5', 'github>seerr-team/seerr//.github/renovate/docker.json5', 'github>seerr-team/seerr//.github/renovate/groups.json5', 'github>seerr-team/seerr//.github/renovate/helm.json5', 'github>seerr-team/seerr//.github/renovate/labels.json5', 'github>seerr-team/seerr//.github/renovate/pnpm.json5', 'github>seerr-team/seerr//.github/renovate/semanticCommits.json5', ], dependencyDashboardTitle: 'Renovate Dashboard 🤖', suppressNotifications: ['prEditedNotification', 'prIgnoreNotification'], rebaseWhen: 'conflicted', labels: ['dependencies'], minimumReleaseAge: '7 days', } ================================================ FILE: .github/workflows/ci.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Seerr CI on: pull_request: branches: - '*' push: branches: - develop workflow_dispatch: permissions: contents: read env: DOCKER_HUB: seerr/seerr concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: i18n: name: i18n Check if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} NUMBER: ${{ github.event.pull_request.number }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: 'package.json' - name: Get pnpm store directory shell: bash run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies env: CI: true run: pnpm install - name: i18n Check shell: bash env: I18N_LABEL: i18n-out-of-sync BODY: | The i18n check failed because translation messages are out of sync. This usually happens when you've added or modified translation strings in your code but haven't updated the translation file. Please run `pnpm i18n:extract` and commit the changes. run: | retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; } check_failed=0; node bin/check-i18n.js || check_failed=$? pr_labels=$(gh pr view "$NUMBER" -R "$GH_REPO" --json labels -q '.labels[].name' 2>/dev/null) || true has_label=0 while IFS= read -r name; do [ -n "$name" ] && [ "$name" = "$I18N_LABEL" ] && has_label=1 && break; done <<< "$pr_labels" if [ "$check_failed" -eq 1 ]; then [ "$has_label" -eq 0 ] && { retry gh pr edit "$NUMBER" -R "$GH_REPO" --add-label "$I18N_LABEL" || true; retry gh pr comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true; } else [ "$has_label" -eq 1 ] && retry gh pr edit "$NUMBER" -R "$GH_REPO" --remove-label "$I18N_LABEL" || true fi exit $check_failed test: name: Lint & Test Build if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 container: node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Get pnpm store directory shell: sh run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies env: CI: true run: pnpm install - name: Lint run: pnpm lint - name: Formatting run: pnpm format:check - name: Build run: pnpm build unit-test: name: Unit Tests if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 container: node:22.22.1-alpine3.22@sha256:9f96f09f127f06feaff1e7faa4a34a3020cf5c1138c988782e59959641facabe permissions: checks: write steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Get pnpm store directory shell: sh run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies env: CI: true run: pnpm install - name: Run tests env: CI: true run: pnpm test - name: Publish test report uses: mikepenz/action-junit-report@49b2ca06f62aa7ef83ae6769a2179271e160d8e4 # v6.3.1 if: success() || failure() # always run even if the previous step fails with: report_paths: 'report.xml' build: name: Build (per-arch, native runners) if: github.ref == 'refs/heads/develop' strategy: matrix: include: - runner: ubuntu-24.04 platform: linux/amd64 arch: amd64 - runner: ubuntu-24.04-arm platform: linux/arm64 arch: arm64 runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Commit timestamp id: ts run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Warm cache (no push) — ${{ matrix.platform }} uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ./Dockerfile platforms: ${{ matrix.platform }} push: false build-args: | COMMIT_TAG=${{ github.sha }} BUILD_VERSION=develop SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }} cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} provenance: false publish: name: Publish multi-arch image needs: build runs-on: ubuntu-24.04 permissions: contents: read packages: write id-token: write steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Commit timestamp id: ts run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | ${{ env.DOCKER_HUB }} ghcr.io/${{ github.repository }} tags: | type=raw,value=develop type=sha labels: | org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }} - name: Build & Push (multi-arch, single tag) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true build-args: | COMMIT_TAG=${{ github.sha }} BUILD_VERSION=develop SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }} labels: ${{ steps.meta.outputs.labels }} tags: ${{ steps.meta.outputs.tags }} cache-from: | type=gha,scope=linux/amd64 type=gha,scope=linux/arm64 cache-to: type=gha,mode=max provenance: false discord: name: Send Discord Notification needs: publish if: always() && github.event_name != 'pull_request' runs-on: ubuntu-24.04 steps: - name: Determine Workflow Status id: status run: | case "${{ needs.publish.result }}" in success) echo "status=Success" >> $GITHUB_OUTPUT; echo "colour=3066993" >> $GITHUB_OUTPUT ;; failure) echo "status=Failure" >> $GITHUB_OUTPUT; echo "colour=15158332" >> $GITHUB_OUTPUT ;; cancelled) echo "status=Cancelled" >> $GITHUB_OUTPUT; echo "colour=10181046" >> $GITHUB_OUTPUT ;; *) echo "status=Skipped" >> $GITHUB_OUTPUT; echo "colour=9807270" >> $GITHUB_OUTPUT ;; esac - name: Send Discord notification shell: bash run: | WEBHOOK="${{ secrets.DISCORD_WEBHOOK }}" PAYLOAD=$(cat <> "$GITHUB_OUTPUT" create-tag: name: Create tag if: github.ref == 'refs/heads/main' runs-on: ubuntu-24.04 permissions: contents: write needs: determine-tag-version env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_VERSION: ${{ needs.determine-tag-version.outputs.tag_version }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: ssh-key: '${{ secrets.COMMIT_KEY }}' - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: 'package.json' # For workflows with elevated privileges we recommend disabling automatic caching. # https://github.com/actions/setup-node package-manager-cache: false - name: Configure git run: | git config --global user.name "${{ github.actor }}" git config --global user.email "${{ github.actor }}@users.noreply.github.com" - name: Bump package.json run: npm version ${TAG_VERSION} --no-commit-hooks --no-git-tag-version - name: Commit updated files run: | git add package.json git commit -m "chore(release): prepare ${TAG_VERSION}" git push - name: Create git tag run: | git tag ${TAG_VERSION} git push origin ${TAG_VERSION} ================================================ FILE: .github/workflows/cypress.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Cypress Tests on: pull_request: branches: ['*'] paths: - '{src,server,config,cypress}/**' - 'cypress.config.ts' - 'package.json' - 'pnpm-lock.yaml' - 'next.config.js' - 'tsconfig.json' - '.github/workflows/cypress.yml' push: branches: [develop] paths: - '{src,server,config,cypress}/**' - 'cypress.config.ts' - 'package.json' - 'pnpm-lock.yaml' - 'next.config.js' - 'tsconfig.json' - '.github/workflows/cypress.yml' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: cypress-run: name: Cypress Run runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: package.json package-manager-cache: false - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Setup cypress cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.cache/Cypress key: ${{ runner.os }}-cypress-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-cypress-store- - name: Install Cypress binary env: CYPRESS_CACHE_FOLDER: ~/.cache/Cypress run: pnpm exec cypress install - name: Cypress run uses: cypress-io/github-action@f790eee7a50d9505912f50c2095510be7de06aa7 # v6.10.9 with: install: false build: pnpm cypress:build start: pnpm start wait-on: 'http://localhost:5055' record: true env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} WITH_MIGRATIONS: true # Fix test titles in cypress dashboard COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}} COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}} ================================================ FILE: .github/workflows/detect-duplicate.yml ================================================ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Duplicate Issue Detector on: issues: types: [opened] permissions: {} env: EMBEDDING_MODEL: ${{ vars.EMBEDDING_MODEL }} GROQ_MODEL: ${{ vars.GROQ_MODEL }} GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} jobs: detect-duplicate: runs-on: ubuntu-24.04 if: ${{ !github.event.issue.pull_request }} permissions: issues: write actions: read contents: read steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: 'package.json' - name: Get pnpm store directory shell: bash run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies working-directory: bin/duplicate-detector env: CI: true run: pnpm install --frozen-lockfile - name: Download issue index uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14 with: name: issue-index workflow: rebuild-issue-index.yml path: bin/duplicate-detector search_artifacts: true if_no_artifact_found: warn - name: Build index if missing working-directory: bin/duplicate-detector env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} INDEX_PATH: issue_index.json run: | if [ ! -f issue_index.json ]; then echo "No index found — building from scratch..." node build-index.mjs fi - name: Detect duplicates working-directory: bin/duplicate-detector continue-on-error: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} ISSUE_NUMBER: ${{ github.event.issue.number }} INDEX_PATH: issue_index.json run: node detect.mjs ================================================ FILE: .github/workflows/docs-deploy.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Deploy to GitHub Pages on: workflow_dispatch: push: branches: - develop paths: - 'docs/**' - 'gen-docs/**' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: name: Build Docusaurus runs-on: ubuntu-24.04 steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: package.json package-manager-cache: false - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Get pnpm store directory shell: sh run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies run: | cd gen-docs pnpm install --frozen-lockfile - name: Build website working-directory: gen-docs run: pnpm build - name: Upload Build Artifact uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b6 # v4.0.0 with: path: gen-docs/build deploy: name: Deploy to GitHub Pages needs: build runs-on: ubuntu-24.04 permissions: contents: read pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 ================================================ FILE: .github/workflows/docs-link-check.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Check Docs Links on: pull_request: branches: - '*' paths: - 'docs/**' - 'gen-docs/**' - '.github/workflows/docs-link-check.yml' push: branches: - develop paths: - 'docs/**' - 'gen-docs/**' - '.github/workflows/docs-link-check.yml' schedule: - cron: '50 7 * * 5' workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: link-check: name: Verify external links in Markdown and MDX runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Run Lychee link checker uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0 with: fail: false args: >- --verbose --no-progress --accept 200..204,300..304,307,308,404,429,999 --exclude '^file://' --exclude '^https?://(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|\[::\])' --exclude '^https?://support\.discord\.com' './docs/**/*.md' './docs/**/*.mdx' './gen-docs/**/*.md' './gen-docs/**/*.mdx' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Lychee report uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: lychee-report path: | lychee/out.md lychee/results.json if-no-files-found: ignore ================================================ FILE: .github/workflows/helm.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Release Charts on: push: branches: - develop paths: - 'charts/**' - '.github/workflows/release-charts.yml' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: package-helm-chart: name: Package helm chart runs-on: ubuntu-24.04 permissions: contents: read packages: read outputs: has_artifacts: ${{ steps.check-artifacts.outputs.has_artifacts }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Install helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 - name: Install Oras uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1.2.4 - name: Login to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Package helm charts run: | mkdir -p ./.cr-release-packages for chart_path in ./charts/*; do if [ -d "$chart_path" ] && [ -f "$chart_path/Chart.yaml" ]; then chart_name=$(grep '^name:' "$chart_path/Chart.yaml" | awk '{print $2}') # get current version current_version=$(grep '^version:' "$chart_path/Chart.yaml" | awk '{print $2}') # try to get current release version if oras manifest fetch "ghcr.io/${{ github.repository }}/${chart_name}:${current_version}" >/dev/null 2>&1; then echo "No version change for $chart_name. Skipping." else helm dependency build "$chart_path" helm package "$chart_path" --destination ./.cr-release-packages fi else echo "Skipping $chart_name: Not a valid Helm chart" fi done - name: Check if artifacts exist id: check-artifacts run: | if ls .cr-release-packages/*.tgz >/dev/null 2>&1; then echo "has_artifacts=true" >> $GITHUB_OUTPUT else echo "has_artifacts=false" >> $GITHUB_OUTPUT fi - name: Upload artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: steps.check-artifacts.outputs.has_artifacts == 'true' with: name: artifacts include-hidden-files: true path: .cr-release-packages/ publish: name: Publish to ghcr.io runs-on: ubuntu-24.04 permissions: packages: write id-token: write needs: [package-helm-chart] if: needs.package-helm-chart.outputs.has_artifacts == 'true' steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Install helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 - name: Install Oras uses: oras-project/setup-oras@22ce207df3b08e061f537244349aac6ae1d214f6 # v1.2.4 - name: Install Cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Downloads artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: artifacts path: .cr-release-packages/ - name: Login to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Push charts to GHCR env: COSIGN_YES: true run: | for chart_path in `find .cr-release-packages -name '*.tgz' -print`; do # push chart to OCI chart_release_file=$(basename "$chart_path") chart_name=${chart_release_file%-*} helm push ${chart_path} oci://ghcr.io/${{ github.repository }} |& tee helm-push-output.log chart_digest=$(awk -F "[, ]+" '/Digest/{print $NF}' < helm-push-output.log) # sign chart cosign sign "ghcr.io/${{ github.repository }}/${chart_name}@${chart_digest}" # push artifacthub-repo.yml to OCI oras push \ ghcr.io/${{ github.repository }}/${chart_name}:artifacthub.io \ --config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \ charts/$chart_name/artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml \ |& tee oras-push-output.log artifacthub_digest=$(grep "Digest:" oras-push-output.log | awk '{print $2}') # sign artifacthub-repo.yml cosign sign "ghcr.io/${{ github.repository }}/${chart_name}:artifacthub.io@${artifacthub_digest}" done verify: name: Verify signatures for each chart tag needs: [publish] runs-on: ubuntu-24.04 permissions: contents: read steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Install Cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Downloads artifacts uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: artifacts path: .cr-release-packages/ - name: Login to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Verify signatures for each chart tag run: | for chart_path in $(find .cr-release-packages -name '*.tgz' -print); do chart_release_file=$(basename "$chart_path") chart_name=${chart_release_file%-*} version=${chart_release_file#$chart_name-} version=${version%.tgz} cosign verify "ghcr.io/${{ github.repository }}/${chart_name}:${version}" \ --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" done ================================================ FILE: .github/workflows/lint-helm-charts.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Lint and Test Charts on: pull_request: branches: - develop paths: - '.github/workflows/lint-helm-charts.yml' - 'charts/**' push: branches: [develop] paths: - 'charts/**' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: lint-test: runs-on: ubuntu-24.04 permissions: contents: read steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Set up Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 - name: Set up chart-testing uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 - name: Ensure documentation is updated uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c - name: Run chart-testing (list-changed) id: list-changed run: | changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }}) if [[ -n "$changed" ]]; then echo "changed=true" >> "$GITHUB_OUTPUT" echo "$changed" fi - name: Run chart-testing if: steps.list-changed.outputs.changed == 'true' run: ct lint --target-branch ${{ github.event.repository.default_branch }} --validate-maintainers=false ================================================ FILE: .github/workflows/preview.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Seerr Preview on: push: tags: - 'preview-*' workflow_dispatch: permissions: contents: read env: DOCKER_HUB: seerr/seerr concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: name: Build (per-arch, native runners) strategy: matrix: include: - runner: ubuntu-24.04 platform: linux/amd64 arch: amd64 - runner: ubuntu-24.04-arm platform: linux/arm64 arch: arm64 runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Commit timestamp id: ts run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Derive preview version from tag id: ver shell: bash run: | TAG="${GITHUB_REF_NAME}" VER="${TAG#preview-}" VER="${VER#v}" echo "version=${VER}" >> "$GITHUB_OUTPUT" echo "Building preview version: ${VER}" - name: Warm cache (no push) — ${{ matrix.platform }} uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ./Dockerfile platforms: ${{ matrix.platform }} push: false build-args: | COMMIT_TAG=${{ github.sha }} BUILD_VERSION=${{ steps.ver.outputs.version }} SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }} cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} provenance: false publish: name: Publish multi-arch image needs: build runs-on: ubuntu-24.04 permissions: contents: read packages: write id-token: write steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Commit timestamp id: ts run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Derive preview version from tag id: ver shell: bash run: | TAG="${GITHUB_REF_NAME}" VER="${TAG#preview-}" VER="${VER#v}" echo "version=${VER}" >> "$GITHUB_OUTPUT" echo "Publishing preview version: ${VER}" - name: Extract metadata id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | ${{ env.DOCKER_HUB }} ghcr.io/${{ github.repository }} tags: | type=raw,value=preview-${{ steps.ver.outputs.version }} labels: | org.opencontainers.image.version=preview-${{ steps.ver.outputs.version }} org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }} - name: Build & Push (multi-arch, single tag) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true build-args: | COMMIT_TAG=${{ github.sha }} BUILD_VERSION=${{ steps.ver.outputs.version }} SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }} labels: ${{ steps.meta.outputs.labels }} tags: ${{ steps.meta.outputs.tags }} cache-from: | type=gha,scope=linux/amd64 type=gha,scope=linux/arm64 cache-to: type=gha,mode=max provenance: false ================================================ FILE: .github/workflows/rebuild-issue-index.yml ================================================ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Rebuild Issue Index on: schedule: - cron: "0 3 * * *" workflow_dispatch: permissions: {} env: EMBEDDING_MODEL: ${{ vars.EMBEDDING_MODEL }} jobs: build-index: runs-on: ubuntu-24.04 permissions: issues: read actions: write contents: read steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: 'package.json' - name: Get pnpm store directory shell: bash run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies working-directory: bin/duplicate-detector env: CI: true run: pnpm install --frozen-lockfile - name: Build issue index working-directory: bin/duplicate-detector env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} INDEX_PATH: issue_index.json run: node build-index.mjs - name: Upload index artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: issue-index path: bin/duplicate-detector/issue_index.json retention-days: 7 ================================================ FILE: .github/workflows/release.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Seerr Release on: push: tags: - 'v*' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: DOCKER_HUB: seerr/seerr jobs: changelog: name: Generate changelog runs-on: ubuntu-24.04 permissions: contents: read outputs: release_body: ${{ steps.git-cliff.outputs.content }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Generate changelog id: git-cliff uses: orhun/git-cliff-action@c93ef52f3d0ddcdcc9bd5447d98d458a11cd4f72 # v4.7.1 with: config: .github/cliff.toml args: -vv --current env: OUTPUT: CHANGELOG.md GITHUB_REPO: ${{ github.repository }} create-draft-release: name: Create draft release runs-on: ubuntu-24.04 permissions: contents: write needs: changelog steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Draft Release run: gh release create ${GITHUB_REF_NAME} -t "Release ${GITHUB_REF_NAME}" -n "${RELEASE_BODY}" --draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_BODY: ${{ needs.changelog.outputs.release_body }} build: name: Build (${{ matrix.arch }}) strategy: matrix: include: - runner: ubuntu-24.04 platform: linux/amd64 arch: amd64 - runner: ubuntu-24.04-arm platform: linux/arm64 arch: arm64 runs-on: ${{ matrix.runner }} env: VERSION: ${{ github.ref_name }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Commit timestamp id: ts run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Warm cache [${{ matrix.platform }}] uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ./Dockerfile platforms: ${{ matrix.platform }} push: false build-args: | COMMIT_TAG=${{ github.sha }} BUILD_VERSION=${{ env.VERSION }} SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }} cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} provenance: false publish: name: Publish multi-arch manifests needs: build runs-on: ubuntu-24.04 permissions: contents: read packages: write outputs: image_digest: ${{ steps.digests.outputs.IMAGE_DIGEST }} env: VERSION: ${{ github.ref_name }} steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Commit timestamp id: ts run: echo "TIMESTAMP=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: | ${{ env.DOCKER_HUB }} ghcr.io/${{ github.repository }} tags: | type=raw,value=${{ env.VERSION }} labels: | org.opencontainers.image.created=${{ steps.ts.outputs.TIMESTAMP }} - name: Build & Push (multi-arch) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: true build-args: | COMMIT_TAG=${{ github.sha }} BUILD_VERSION=${{ env.VERSION }} SOURCE_DATE_EPOCH=${{ steps.ts.outputs.TIMESTAMP }} labels: ${{ steps.meta.outputs.labels }} tags: ${{ steps.meta.outputs.tags }} cache-from: | type=gha,scope=linux/amd64 type=gha,scope=linux/arm64 cache-to: type=gha,mode=max provenance: false - name: Resolve manifest digest id: digests run: | DIGEST=$(docker buildx imagetools inspect "${{ env.DOCKER_HUB }}:${{ env.VERSION }}" --format '{{json .Manifest.Digest}}' | tr -d '"') echo "IMAGE_DIGEST=$DIGEST" >> $GITHUB_OUTPUT - name: Also tag :latest (non-pre-release only) shell: bash if: ${{ !contains(env.VERSION, '-') }} run: | docker buildx imagetools create \ -t ${{ env.DOCKER_HUB }}:latest \ ${{ env.DOCKER_HUB }}:${{ env.VERSION }} docker buildx imagetools create \ -t ghcr.io/${{ github.repository }}:latest \ ghcr.io/${{ github.repository }}:${{ env.VERSION }} sign: name: Sign images and create SBOM attestations needs: publish runs-on: ubuntu-24.04 permissions: contents: read id-token: write packages: write env: VERSION: ${{ github.ref_name }} COSIGN_YES: 'true' steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: persist-credentials: false - name: Install Cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Install Trivy uses: aquasecurity/setup-trivy@3fb12ec12f41e471780db15c232d5dd185dcb514 # v0.2.5 - name: Log in to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_TOKEN }} - name: Log in to GitHub Container Registry uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Sign images run: | cosign sign --recursive "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" cosign sign --recursive "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" - name: Generate SBOMs run: | trivy image --format cyclonedx --output seerr-ghcr-image-${{ env.VERSION }}.sbom \ "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" trivy image --format cyclonedx --output seerr-dockerhub-image-${{ env.VERSION }}.sbom \ "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" - name: Attest SBOMs run: | cosign attest \ --type cyclonedx \ --predicate seerr-ghcr-image-${{ env.VERSION }}.sbom \ "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" cosign attest \ --type cyclonedx \ --predicate seerr-dockerhub-image-${{ env.VERSION }}.sbom \ "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" - name: Upload SBOMs uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sboms-${{ env.VERSION }} path: '*.sbom' if-no-files-found: error retention-days: 1 verify: name: Verify signatures and attestations needs: [publish, sign] runs-on: ubuntu-24.04 permissions: contents: read env: VERSION: ${{ github.ref_name }} steps: - name: Install Cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Verify signatures run: | cosign verify "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" \ --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" cosign verify "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" \ --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" # - name: Verify attestations # run: | # cosign verify-attestation "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" \ # --type cyclonedx \ # --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ # --certificate-oidc-issuer "https://token.actions.githubusercontent.com" > /dev/null # cosign verify-attestation "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" \ # --type cyclonedx \ # --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ # --certificate-oidc-issuer "https://token.actions.githubusercontent.com" > /dev/null publish-release: name: Publish release needs: [create-draft-release, verify] runs-on: ubuntu-24.04 permissions: contents: write env: VERSION: ${{ github.ref_name }} steps: - name: Publish release run: gh release edit "${{ env.VERSION }}" --draft=false --repo "${{ github.repository }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/renovate-helm-custom-hooks.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Renovate Helm Hooks on: pull_request: branches: - develop paths: - 'charts/**' permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: renovate-post-run: name: Renovate Bump Chart Version runs-on: ubuntu-latest permissions: contents: read pull-requests: write if: github.actor == 'renovate[bot]' steps: - name: Checkout code uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 id: app-token with: app-id: 2138788 private-key: ${{ secrets.APP_SEERR_HELM_PRIVATE_KEY }} - name: Set up chart-testing uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 - name: Run chart-testing (list-changed) id: list-changed run: | changed="$(ct list-changed --target-branch ${TARGET_BRANCH})" if [[ -n "$changed" ]]; then echo "changed=true" >> "$GITHUB_OUTPUT" echo "changed_list=${changed//$'\n'/ }" >> "$GITHUB_OUTPUT" fi env: TARGET_BRANCH: ${{ github.event.repository.default_branch }} - name: Bump chart version if: steps.list-changed.outputs.changed == 'true' env: CHART: ${{ steps.list-changed.outputs.changed_list }} run: | if [[ ! -d "${CHART}" ]]; then echo "${CHART} directory not found" exit 0 fi # Extract current appVersion and chart version from Chart.yaml APP_VERSION=$(grep -e "^appVersion:" "$CHART/Chart.yaml" | cut -d ":" -f 2 | tr -d '[:space:]' | tr -d '"') CHART_VERSION=$(grep -e "^version:" "$CHART/Chart.yaml" | cut -d ":" -f 2 | tr -d '[:space:]' | tr -d '"') # Extract major, minor and patch versions of appVersion APP_MAJOR_VERSION=$(printf '%s' "$APP_VERSION" | cut -d "." -f 1) APP_MINOR_VERSION=$(printf '%s' "$APP_VERSION" | cut -d "." -f 2) APP_PATCH_VERSION=$(printf '%s' "$APP_VERSION" | cut -d "." -f 3) # Extract major, minor and patch versions of chart version CHART_MAJOR_VERSION=$(printf '%s' "$CHART_VERSION" | cut -d "." -f 1) CHART_MINOR_VERSION=$(printf '%s' "$CHART_VERSION" | cut -d "." -f 2) CHART_PATCH_VERSION=$(printf '%s' "$CHART_VERSION" | cut -d "." -f 3) # Get previous appVersion from the base commit of the pull request BASE_COMMIT=$(git merge-base origin/main HEAD) PREV_APP_VERSION=$(git show "$BASE_COMMIT":"$CHART/Chart.yaml" | grep -e "^appVersion:" | cut -d ":" -f 2 | tr -d '[:space:]' | tr -d '"') # Extract major, minor and patch versions of previous appVersion PREV_APP_MAJOR_VERSION=$(printf '%s' "$PREV_APP_VERSION" | cut -d "." -f 1) PREV_APP_MINOR_VERSION=$(printf '%s' "$PREV_APP_VERSION" | cut -d "." -f 2) PREV_APP_PATCH_VERSION=$(printf '%s' "$PREV_APP_VERSION" | cut -d "." -f 3) # Check if the major, minor, or patch version of appVersion has changed if [[ "$APP_MAJOR_VERSION" != "$PREV_APP_MAJOR_VERSION" ]]; then # Bump major version of the chart and reset minor and patch versions to 0 CHART_MAJOR_VERSION=$((CHART_MAJOR_VERSION+1)) CHART_MINOR_VERSION=0 CHART_PATCH_VERSION=0 elif [[ "$APP_MINOR_VERSION" != "$PREV_APP_MINOR_VERSION" ]]; then # Bump minor version of the chart and reset patch version to 0 CHART_MINOR_VERSION=$((CHART_MINOR_VERSION+1)) CHART_PATCH_VERSION=0 elif [[ "$APP_PATCH_VERSION" != "$PREV_APP_PATCH_VERSION" ]]; then # Bump patch version of the chart CHART_PATCH_VERSION=$((CHART_PATCH_VERSION+1)) fi # Update the chart version in Chart.yaml CHART_NEW_VERSION="${CHART_MAJOR_VERSION}.${CHART_MINOR_VERSION}.${CHART_PATCH_VERSION}" sed -i "s/^version:.*/version: ${CHART_NEW_VERSION}/" "$CHART/Chart.yaml" - name: Ensure documentation is updated if: steps.list-changed.outputs.changed == 'true' uses: docker://jnorwood/helm-docs:v1.14.2@sha256:7e562b49ab6b1dbc50c3da8f2dd6ffa8a5c6bba327b1c6335cc15ce29267979c - name: Commit changes if: steps.list-changed.outputs.changed == 'true' env: CHART: ${{ steps.list-changed.outputs.changed_list }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_HEAD_REF: ${{ github.head_ref }} run: | # Define the target directory TARGET_DIR="$CHART" # Fetch deleted files in the target directory DELETED_FILES=$(git diff --diff-filter=D --name-only HEAD -- "$TARGET_DIR") # Fetch added/modified files in the target directory MODIFIED_FILES=$(git diff --diff-filter=ACM --name-only HEAD -- "$TARGET_DIR") # Create a temporary file for JSON output FILE_CHANGES_JSON_FILE=$(mktemp) # Initialize JSON structure in the file echo '{ "deletions": [], "additions": [] }' > "$FILE_CHANGES_JSON_FILE" # Add deletions for file in $DELETED_FILES; do jq --arg path "$file" '.deletions += [{"path": $path}]' "$FILE_CHANGES_JSON_FILE" > "$FILE_CHANGES_JSON_FILE.tmp" mv "$FILE_CHANGES_JSON_FILE.tmp" "$FILE_CHANGES_JSON_FILE" done # Add additions (new or modified files) for file in $MODIFIED_FILES; do BASE64_CONTENT=$(base64 -w 0 <"$file") # Encode file content jq --arg path "$file" --arg content "$BASE64_CONTENT" \ '.additions += [{"path": $path, "contents": $content}]' "$FILE_CHANGES_JSON_FILE" > "$FILE_CHANGES_JSON_FILE.tmp" mv "$FILE_CHANGES_JSON_FILE.tmp" "$FILE_CHANGES_JSON_FILE" done # Create a temporary file for the final JSON payload JSON_PAYLOAD_FILE=$(mktemp) # Construct the final JSON using jq and store it in a file jq -n --arg repo "$GITHUB_REPOSITORY" \ --arg branch "$GITHUB_HEAD_REF" \ --arg message "fix: post upgrade changes from renovate" \ --arg expectedOid "$GITHUB_SHA" \ --slurpfile fileChanges "$FILE_CHANGES_JSON_FILE" \ '{ query: "mutation ($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { url } } }", variables: { input: { branch: { repositoryNameWithOwner: $repo, branchName: $branch }, message: { headline: $message }, fileChanges: $fileChanges[0], expectedHeadOid: $expectedOid } } }' > "$JSON_PAYLOAD_FILE" # Call GitHub API curl https://api.github.com/graphql -f \ -sSf -H "Authorization: Bearer $GITHUB_TOKEN" \ --data "@$JSON_PAYLOAD_FILE" # Clean up temporary files rm "$FILE_CHANGES_JSON_FILE" "$JSON_PAYLOAD_FILE" ================================================ FILE: .github/workflows/seerr-labeller.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: 'Seerr Labeller' on: pull_request_target: types: [labeled, unlabeled, reopened] issues: types: [labeled, unlabeled, reopened] permissions: {} jobs: ai-generated-support: if: > github.event_name == 'pull_request_target' && (github.event.label.name == 'ai-generated' || (github.event.action == 'reopened' && contains(github.event.pull_request.labels.*.name, 'ai-generated'))) runs-on: ubuntu-24.04 concurrency: group: ai-generated-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: pull-requests: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} NUMBER: ${{ github.event.pull_request.number }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} steps: - name: Label added, comment and close pull request if: github.event.action == 'labeled' && github.event.label.name == 'ai-generated' shell: bash env: BODY: > :wave: @${{ env.PR_AUTHOR }}, thank you for your contribution! However, this pull request has been closed because it appears to contain a significant amount of AI-generated code without sufficient human review or supervision. AI-generated code can often introduce subtle bugs, poor design patterns, or inconsistent styles that make long-term maintenance difficult and reduce overall code quality. For the sake of the project's future stability and readability, we require that all contributions meet our established coding standards and demonstrate clear developer oversight. This pull request is also too large for effective human review. Please discuss with us on how to break down these changes into smaller, more focused PRs to ensure a thorough and efficient review process. If you'd like to revise and resubmit your changes with careful review and cleanup, we'd be happy to take another look. run: | retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; } retry gh pr comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true retry gh pr close "$NUMBER" -R "$GH_REPO" || true gh pr lock "$NUMBER" -R "$GH_REPO" -r "spam" || true - name: Label removed, reopen and unlock pull request if: github.event.action == 'unlabeled' && github.event.label.name == 'ai-generated' shell: bash run: | retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; } retry gh pr reopen "$NUMBER" -R "$GH_REPO" || true gh pr unlock "$NUMBER" -R "$GH_REPO" || true - name: Remove AI-generated label on manual reopen if: github.event.action == 'reopened' shell: bash run: | gh pr edit "$NUMBER" -R "$GH_REPO" --remove-label "ai-generated" || true gh pr unlock "$NUMBER" -R "$GH_REPO" || true support: if: > github.event_name == 'issues' && (github.event.label.name == 'support' || (github.event.action == 'reopened' && contains(github.event.issue.labels.*.name, 'support'))) runs-on: ubuntu-24.04 concurrency: group: support-${{ github.event.issue.number }} cancel-in-progress: true permissions: issues: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} NUMBER: ${{ github.event.issue.number }} ISSUE_AUTHOR: ${{ github.event.issue.user.login }} steps: - name: Label added, comment and close issue if: github.event.action == 'labeled' && github.event.label.name == 'support' shell: bash env: BODY: > :wave: @${{ env.ISSUE_AUTHOR }}, we use the issue tracker exclusively for bug reports and feature requests. However, this issue appears to be a support request. Please use our support channels to get help with Seerr. - [Discord](https://discord.gg/seerr) run: | retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; } retry gh issue comment "$NUMBER" -R "$GH_REPO" -b "$BODY" || true retry gh issue close "$NUMBER" -R "$GH_REPO" || true gh issue lock "$NUMBER" -R "$GH_REPO" -r "off_topic" || true - name: Label removed, reopen and unlock issue if: github.event.action == 'unlabeled' && github.event.label.name == 'support' shell: bash run: | retry() { n=0; until "$@"; do n=$((n+1)); [ $n -ge 3 ] && break; echo "retry $n: $*" >&2; sleep 2; done; } retry gh issue reopen "$NUMBER" -R "$GH_REPO" || true gh issue unlock "$NUMBER" -R "$GH_REPO" || true - name: Remove support label on manual reopen if: github.event.action == 'reopened' shell: bash run: | gh issue edit "$NUMBER" -R "$GH_REPO" --remove-label "support" || true gh issue unlock "$NUMBER" -R "$GH_REPO" || true ================================================ FILE: .github/workflows/semantic-pr.yml ================================================ name: "Semantic PR" on: pull_request_target: types: - opened - reopened - edited - synchronize permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: main: name: Validate PR Title runs-on: ubuntu-slim permissions: contents: read pull-requests: read checks: write steps: - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/stale.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Close Stale Issues and PRs on: schedule: - cron: '0 7 * * *' permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: stale: name: Close stale issues and PRs runs-on: ubuntu-24.04 permissions: actions: write issues: write pull-requests: write steps: - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: any-of-labels: "pending author's response" exempt-issue-labels: 'confirmed' days-before-stale: 30 days-before-close: 30 stale-issue-label: 'stale' stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Please provide an update or the requested information to keep it open.' close-issue-message: 'This issue was closed because it has been stalled for 30 days with no activity. Feel free to reopen it once you provide the required update or information.' stale-pr-label: 'stale' stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Please address the feedback or provide an update to keep it open.' close-pr-message: 'This PR was closed because it has been stalled for 30 days with no activity. You can reopen it once you address the feedback or provide the requested changes.' ================================================ FILE: .github/workflows/test-docs-deploy.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Test Docs deployment on: pull_request: branches: - develop paths: - 'docs/**' - 'gen-docs/**' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test-deploy: name: Test deployment runs-on: ubuntu-24.04 permissions: contents: read steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Set up Node.js uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: package.json package-manager-cache: false - name: Pnpm Setup uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Get pnpm store directory shell: sh run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies run: | cd gen-docs pnpm install --frozen-lockfile - name: Build website run: | cd gen-docs pnpm build ================================================ FILE: .github/workflows/trivy-scan.yml ================================================ --- # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json name: Trivy Container Vulnerability Scan on: workflow_run: workflows: - Seerr Release types: - completed schedule: - cron: '50 7 * * 5' workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: trivy: if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} name: Scan latest container image runs-on: ubuntu-24.04 permissions: contents: read security-events: write env: TRIVY_CACHE_DIR: .trivycache steps: - name: Checkout uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 persist-credentials: false - name: Cache Trivy DB uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .trivycache key: trivy-${{ runner.os }}-${{ hashFiles('**/Dockerfile') }} restore-keys: | trivy-${{ runner.os }}- - name: Run Trivy image scan uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 with: image-ref: ghcr.io/${{ github.repository }}:latest format: sarif output: trivy.sarif ignore-unfixed: true - name: Upload SARIF to code scanning uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 with: sarif_file: trivy.sarif ================================================ FILE: .gitignore ================================================ # dependencies /node_modules /.pnp .pnp.js # testing /coverage lcov.info # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env.local .env.development.local .env.test.local .env.production.local # vercel .vercel # database config/db/*.sqlite3* config/settings.json config/settings.old.json # logs config/logs/*.log* config/logs/*.json config/logs/*.log.gz config/logs/*.json.gz config/logs/*-audit.json # anidb mapping file config/anime-list.xml # dist files dist # sqlite journal config/db/db.sqlite3-journal # VS Code .vscode/launch.json # Cypress cypress.env.json cypress/videos cypress/screenshots # ESLint .eslintcache # TS Build Info tsconfig.tsbuildinfo # Webstorm .idea # Config Cache Directory config/cache # Docker compose compose.override.yaml ================================================ FILE: .husky/commit-msg ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" [ -n "$HUSKY_BYPASS" ] || npx commitlint --edit $1 ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npx lint-staged ================================================ FILE: .husky/prepare-commit-msg ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" exec < /dev/tty && npx cz --hook || true ================================================ FILE: .npmrc ================================================ engine-strict=true ================================================ FILE: .prettierignore ================================================ # Generated files which we would not like to format .next/ dist/ config/ cache/config.json pnpm-lock.yaml cypress/config/settings.cypress.json .github .vscode # assets src/assets/ docs/ public/* !public/sw.js # helm charts **/charts # Prettier breaks GitHub alert syntax in markdown *.md ================================================ FILE: .prettierrc.js ================================================ module.exports = { plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-tailwindcss'], singleQuote: true, trailingComma: 'es5', overrides: [ { files: 'pnpm-lock.yaml', options: { rangeEnd: 0, // default: Infinity }, }, { files: 'gen-docs/pnpm-lock.yaml', options: { rangeEnd: 0, // default: Infinity }, }, { files: 'charts/**', options: { rangeEnd: 0, // default: Infinity }, }, { files: 'cypress/config/settings.cypress.json', options: { rangeEnd: 0, }, }, { files: 'public/offline.html', options: { rangeEnd: 0, }, }, { files: 'cache/config.json', options: { rangeEnd: 0, // default: Infinity }, }, ], }; ================================================ FILE: .vscode/extensions.json ================================================ { // see // - https://code.visualstudio.com/docs/editor/extension-gallery#_workspace-recommended-extensions "recommendations": [ // https://marketplace.visualstudio.com/items?itemName=EditorConfig.editorconfig "EditorConfig.editorconfig", // https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint "dbaeumer.vscode-eslint", // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode "esbenp.prettier-vscode", // https://marketplace.visualstudio.com/items?itemName=Orta.vscode-jest "Orta.vscode-jest", "stylelint.vscode-stylelint", "bradlc.vscode-tailwindcss", "firsttris.vscode-jest-runner" ] } ================================================ FILE: .vscode/settings.json ================================================ { "eslint.enable": true, "eslint.validate": [ "javascript", "javascriptreact", "typescript", "typescriptreact" ], "typescript.tsdk": "node_modules/typescript/lib", "sqltools.connections": [ { "previewLimit": 50, "driver": "SQLite", "name": "Local SQLite", "database": "./config/db/db.sqlite3" } ], "editor.formatOnSave": true, "typescript.preferences.importModuleSpecifier": "non-relative", "files.associations": { "globals.css": "tailwindcss" }, "i18n-ally.localesPaths": [ "src/i18n/locale" ], "yaml.format.singleQuote": true, "jestrunner.enableTestExplorer": true, "jestrunner.defaultTestPatterns": [ "server/**/*.{test,spec}.?(c|m)[jt]s?(x)", ], "jestrunner.nodeTestCommand": "pnpm test", "jestrunner.changeDirectoryToWorkspaceRoot": true, "jestrunner.projectPath": "." } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [ryan@sct.dev](mailto:ryan@sct.dev). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][mozilla coc]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][faq]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html [mozilla coc]: https://github.com/mozilla/diversity [faq]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Seerr All help is welcome and greatly appreciated! If you would like to contribute to the project, the following instructions should get you started... ## AI Assistance Notice > [!IMPORTANT] > > Automated AI-generated contributions without human review are not allowed and will be rejected. > This is an open-source project maintained by volunteers. > We do not have the resources to review pull requests that could have been avoided with proper human oversight. > While we have no issue with contributors using AI tools as an aid, it is your responsibility as a contributor to ensure that all submissions are carefully reviewed and meet our quality standards. > Submissions that appear to be unreviewed AI output will be considered low-effort and may result in a ban. > > If you are using **any kind of AI assistance** to contribute to Seerr, > it must be disclosed in the pull request. If you are using any kind of AI assistance while contributing to Seerr, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). If PR responses are being generated by an AI, disclose that as well. As a small exception, trivial tab-completion doesn't need to be disclosed, so long as it is limited to single keywords or short phrases. An example disclosure: > This PR was written primarily by Claude Code. Or a more detailed disclosure: > I consulted ChatGPT to understand the codebase but the solution > was fully authored manually by myself. Failure to disclose this is first and foremost rude to the human operators on the other end of the pull request, but it also makes it difficult to determine how much scrutiny to apply to the contribution. In a perfect world, AI assistance would produce equal or higher quality work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! When using AI assistance, we expect contributors to understand the code that is produced and be able to answer critical questions about it. It isn't a maintainers job to review a PR so broken that it requires significant rework to be acceptable. Please be respectful to maintainers and disclose AI assistance. ## Development ### Tools Required - HTML/Typescript/Javascript editor - [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install. - [NodeJS](https://nodejs.org/en/download/) (Node 22.x) - [Pnpm](https://pnpm.io/cli/install) - [Git](https://git-scm.com/downloads) ### Getting Started 1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device: ```bash git clone https://github.com/YOUR_USERNAME/seerr.git cd seerr/ ``` 2. Add the remote `upstream`: ```bash git remote add upstream https://github.com/seerr-team/seerr.git ``` 3. Create a new branch: ```bash git switch -c BRANCH_NAME develop ``` - It is recommended to give your branch a meaningful name, relevant to the feature or fix you are working on. - Good examples: - `docs-docker` - `feature-new-system` - `fix-title-cards` - Bad examples: - `bug` - `docs` - `feature` - `fix` - `patch` 4. Run the development environment: ```bash pnpm install pnpm dev ``` - Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. 5. Create your patch and test your changes. - Be sure to follow both the [code](#contributing-code) and [UI text](#ui-text-style) guidelines. - Should you need to update your fork, you can do so by rebasing from `upstream`: ```bash git fetch upstream git rebase upstream/develop git push origin BRANCH_NAME -f ``` ### Helm Chart Tools Required: - [Helm](https://helm.sh/docs/intro/install/) - [helm-docs](https://github.com/norwoodj/helm-docs) Steps: 1. Make the necessary changes. 2. Test your changes. 3. Update the `version` in `charts/seerr-chart/Chart.yaml` following [Semantic Versioning (SemVer)](https://semver.org/). 4. Run the `helm-docs` command to regenerate the chart's README. ### Contributing Code - If you are taking on an existing bug or feature ticket, please comment on the [issue](/../../issues) to avoid multiple people working on the same thing. - Pull requests with titles not following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) will **not** be merged. PR titles are automatically checked for compliance. - Please make meaningful commits, or squash them prior to opening a pull request. - Do not squash commits once people have begun reviewing your changes. - Always rebase your branch to the latest `develop` branch. - It is your responsibility to keep your branch up-to-date. Your work will **not** be merged unless it is rebased off the latest `develop` branch. - You can create a "draft" pull request early to get feedback on your work. - Your code **must** be formatted correctly, or the tests will fail. - We use Prettier to format our code base. It should automatically run with a Git hook, but it is recommended to have the Prettier extension installed in your editor and format on save. - If you have questions or need help, you can reach out via [Discussions](/../../discussions) or our [Discord server](https://discord.gg/seerr). - Only open pull requests to `develop`, never `master`! Any pull requests opened to `master` will be closed. ### UI Text Style When adding new UI text, please try to adhere to the following guidelines: 1. Be concise and clear, and use as few words as possible to make your point. 2. Use the Oxford comma where appropriate. 3. Use the appropriate Unicode characters for ellipses, arrows, and other special characters/symbols. 4. Capitalize proper nouns, such as Plex, Radarr, Sonarr, Telegram, Slack, Pushover, etc. Be sure to also use the official capitalization for any abbreviations; e.g., IMDb has a lowercase 'b', whereas TMDB and TheTVDB have a capital 'B'. 5. Title case headings, button text, and form labels. Note that verbs such as "is" should be capitalized, whereas prepositions like "from" should be lowercase (unless as the first or last word of the string, in which case they are also capitalized). 6. Capitalize the first word in validation error messages, dropdowns, and form "tips." These strings should not end in punctuation. 7. Ensure that toast notification strings are complete sentences ending in punctuation. 8. If an additional description or "tip" is required for a form field, it should be styled using the global CSS class `label-tip`. 9. In full sentences, abbreviations like "info" or "auto" should not be used in place of full words, unless referencing the name/label of a specific setting or option which has an abbreviation in its name. 10. Do your best to check for spelling errors and grammatical mistakes. 11. Do not misspell "Seerr." ## Translation We use [Weblate](https://translate.seerr.dev/projects/seerr/seerr-frontend/) for our translations, and your help with localizing Seerr would be greatly appreciated! If your language is not listed below, please [open a feature request](/../../issues/new/choose). Translation status ## Migrations If you are adding a new feature that requires a database migration, you will need to create 2 migrations: one for SQLite and one for PostgreSQL. Here is how you could do it: 1. Create a PostgreSQL database or use an existing one: ```bash sudo docker run --name postgres-seerr -e POSTGRES_PASSWORD=postgres -d -p 127.0.0.1:5432:5432/tcp postgres:latest ``` 2. Reset the SQLite database and the PostgreSQL database: ```bash rm config/db/db.* rm config/settings.* PGPASSWORD=postgres sudo docker exec -it postgres-seerr /usr/bin/psql -h 127.0.0.1 -U postgres -c "DROP DATABASE IF EXISTS seerr;" PGPASSWORD=postgres sudo docker exec -it postgres-seerr /usr/bin/psql -h 127.0.0.1 -U postgres -c "CREATE DATABASE seerr;" ``` 3. Switch to the `develop` branch and create the original database for SQLite and PostgreSQL so that TypeORM can automatically generate the migrations: ```bash git switch develop pnpm i rm -r .next dist; pnpm build pnpm start DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm start ``` (You can shutdown the server once the message "Server ready on 5055" appears) 4. Let TypeORM generate the migrations: ```bash git switch -c your-feature-branch pnpm i pnpm migration:generate server/migration/sqlite/YourMigrationName DB_TYPE="postgres" DB_USER=postgres DB_PASS=postgres pnpm migration:generate server/migration/postgres/YourMigrationName ``` ## Attribution This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Ghostty](https://github.com/ghostty-org/ghostty) contribution guides. ================================================ FILE: Dockerfile ================================================ FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384 AS base ARG SOURCE_DATE_EPOCH ARG TARGETPLATFORM ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable COPY . ./app WORKDIR /app FROM base AS prod-deps RUN --mount=type=cache,id=pnpm,target=/pnpm/store CI=true pnpm install --prod --frozen-lockfile FROM base AS build ARG COMMIT_TAG ENV COMMIT_TAG=${COMMIT_TAG} RUN \ case "${TARGETPLATFORM}" in \ 'linux/arm64' | 'linux/arm/v7') \ apk update && \ apk add --no-cache python3 make g++ gcc libc6-compat bash && \ npm install --global node-gyp \ ;; \ esac RUN --mount=type=cache,id=pnpm,target=/pnpm/store CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile RUN pnpm build RUN rm -rf .next/cache FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384 ARG SOURCE_DATE_EPOCH ARG COMMIT_TAG ENV NODE_ENV=production ENV COMMIT_TAG=${COMMIT_TAG} RUN apk add --no-cache tzdata USER node:node WORKDIR /app COPY --chown=node:node . . COPY --chown=node:node --from=prod-deps /app/node_modules ./node_modules COPY --chown=node:node --from=build /app/.next ./.next COPY --chown=node:node --from=build /app/dist ./dist RUN touch config/DOCKER && \ echo "{\"commitTag\": \"${COMMIT_TAG}\"}" > committag.json EXPOSE 5055 CMD [ "npm", "start" ] ================================================ FILE: Dockerfile.local ================================================ FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384 ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable COPY . /app WORKDIR /app RUN pnpm install CMD pnpm dev ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 sct Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Seerr

Seerr Release Seerr CI

Discord Docker pulls Translation status GitHub **Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**. ## Current Features - Full Jellyfin/Emby/Plex integration including authentication with user import & management. - Support for **PostgreSQL** and **SQLite** databases. - Supports Movies, Shows and Mixed Libraries. - Ability to change email addresses for SMTP purposes. - Easy integration with your existing services. Currently, Seerr supports Sonarr and Radarr. More to come! - Jellyfin/Emby/Plex library scan, to keep track of the titles which are already available. - Customizable request system, which allows users to request individual seasons or movies in a friendly, easy-to-use interface. - Incredibly simple request management UI. Don't dig through the app to simply approve recent requests! - Granular permission system. - Support for various notification agents. - Mobile-friendly design, for when you need to approve requests on the go! - Support for watchlisting & blocklisting media. With more features on the way! Check out our [issue tracker](/../../issues) to see the features which have already been requested. ## Getting Started Check out our documentation for instructions on how to install and run Seerr: https://docs.seerr.dev/getting-started/ ## Preview Seerr application preview ## Migrating from Overseerr/Jellyseerr to Seerr Read our [release announcement](https://docs.seerr.dev/blog/seerr-release) to learn what Seerr means for Jellyseerr and Overseerr users. Please follow our [migration guide](https://docs.seerr.dev/migration-guide) for detailed instructions on migrating from Overseerr or Jellyseerr. ## Support - Check out the [Seerr Documentation](https://docs.seerr.dev) before asking for help. Your question might already be in the docs! - You can get support on [Discord](https://discord.gg/seerr). - You can ask questions in the Help category of our [GitHub Discussions](/../../discussions). - Bug reports and feature requests can be submitted via [GitHub Issues](/../../issues). ## API Documentation You can access the API documentation from your local Seerr install at http://localhost:5055/api-docs ## Community You can ask questions, share ideas, and more in [GitHub Discussions](/../../discussions). If you would like to chat with other members of our growing community, [join the Seerr Discord server](https://discord.gg/seerr)! Our [Code of Conduct](./CODE_OF_CONDUCT.md) applies to all Seerr community channels. ## Contributing You can help improve Seerr too! Check out our [Contribution Guide](./CONTRIBUTING.md) to get started. ## Contributors ✨ [![Contributors](https://opencollective.com/seerr/contributors.svg?width=890)](https://opencollective.com/seerr/#backers) [![Become a Backer](https://opencollective.com/seerr/backers.svg)](https://opencollective.com/seerr/#backers) [![Become a Sponsor](https://opencollective.com/seerr/sponsors.svg)](https://opencollective.com/seerr/#sponsors) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting Security Issues Maintainers and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions. To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](../../security/advisories/new) tab. **Please do not report security vulnerabilities through public GitHub issues, discussions, or Discord.** ## AI Assistance Notice > [!IMPORTANT] > > Automated AI-generated contributions without human review are not allowed and will be rejected. > This is an open-source project maintained by volunteers. > We do not have the resources to review pull requests that could have been avoided with proper human oversight. > While we have no issue with contributors using AI tools as an aid, it is your responsibility as a contributor to ensure that all submissions are carefully reviewed and meet our quality standards. > Submissions that appear to be unreviewed AI output will be considered low-effort and may result in a ban. > > If you are using **any kind of AI assistance** to contribute to Seerr, > it must be disclosed in the pull request. If you are using any kind of AI assistance while contributing to Seerr, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used (e.g. docs only vs. code generation). If security advisory responses are being generated by an AI, disclose that as well. As a small exception, trivial tab-completion doesn't need to be disclosed, so long as it is limited to single keywords or short phrases. An example disclosure: > This security advisory was written primarily by Claude Code. Or a more detailed disclosure: > I consulted ChatGPT to understand the codebase but the solution > was fully authored manually by myself. Failure to disclose this is first and foremost rude to the human operators on the other end of the pull request, but it also makes it difficult to determine how much scrutiny to apply to the contribution. In a perfect world, AI assistance would produce equal or higher quality work than any human. That isn't the world we live in today, and in most cases it's generating slop. I say this despite being a fan of and using them successfully myself (with heavy supervision)! When using AI assistance, we expect contributors to understand the code that is produced and be able to answer critical questions about it. It isn't a maintainers job to review a PR so broken that it requires significant rework to be acceptable. Please be respectful to maintainers and disclose AI assistance. ## What to Include in Your Report To help us better understand and resolve the issue, please include as much of the following information as possible: - Full paths of source file(s) related to the manifestation of the issue - The location of the affected source code (tag/branch/commit or direct URL) - Any special configuration required to reproduce the issue - Step-by-step instructions to reproduce the issue - Proof-of-concept or exploit code (if possible) - Impact of the issue ## Response Timeline We will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. ## Disclosure Policy - Security issues will be disclosed in a coordinated manner - We will credit reporters in the security advisory unless anonymity is requested - We request that you do not publicly disclose the issue until we have released a fix ## Third-Party Dependencies If you discover a security vulnerability in a third-party dependency used by Seerr, please report it directly to the maintainers of that module. You can also notify us through our security advisory process so we can: - Track the issue and monitor for updates - Apply patches or workarounds if available - Coordinate with upstream maintainers when necessary - Communicate the impact to our users We regularly monitor and update our dependencies to address known security vulnerabilities. ## Security Updates Security updates and advisories will be published on our [GitHub Security Advisories page](../../security/advisories). ## Community For general questions and support (non-security related): - [GitHub Discussions](../../discussions) - [Discord](https://discord.gg/seerr) ================================================ FILE: bin/check-i18n.js ================================================ #!/usr/bin/env node /** * Check that i18n locale files are in sync with extracted messages. * Runs `pnpm i18n:extract` and compares en.json; exits 1 if they differ. */ const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const localePath = path.join( __dirname, '..', 'src', 'i18n', 'locale', 'en.json' ); const backupPath = `${localePath}.bak`; try { fs.copyFileSync(localePath, backupPath); execSync('pnpm i18n:extract', { stdio: 'inherit' }); const original = fs.readFileSync(backupPath, 'utf8'); const extracted = fs.readFileSync(localePath, 'utf8'); fs.unlinkSync(backupPath); if (original !== extracted) { console.error( "i18n messages are out of sync. Please run 'pnpm i18n:extract' and commit the changes." ); process.exit(1); } } catch (err) { if (fs.existsSync(backupPath)) { fs.unlinkSync(backupPath); } throw err; } ================================================ FILE: bin/duplicate-detector/.gitignore ================================================ node_modules/ ================================================ FILE: bin/duplicate-detector/build-index.mjs ================================================ #!/usr/bin/env node /** * Build Issue Embedding Index * * Fetches all open issues and recently closed ones, * generates embeddings using a local ONNX transformer model, * and saves them as a JSON artifact for the duplicate detector. */ import { pipeline } from '@huggingface/transformers'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { fetchIssues, issueText } from './utils.mjs'; const MODEL_NAME = process.env.EMBEDDING_MODEL || 'Xenova/all-MiniLM-L6-v2'; const OUTPUT_PATH = 'issue_index.json'; const INCLUDE_CLOSED_DAYS = 90; const MAX_ISSUES = 5000; const BATCH_SIZE = 64; async function main() { console.log('Fetching open issues...'); const openIssues = await fetchIssues({ state: 'open', maxIssues: MAX_ISSUES, }); console.log(`Fetched ${openIssues.length} open issues`); const since = new Date( Date.now() - INCLUDE_CLOSED_DAYS * 24 * 60 * 60 * 1000 ).toISOString(); console.log( `Fetching closed issues from last ${INCLUDE_CLOSED_DAYS} days...` ); const closedIssues = await fetchIssues({ state: 'closed', since, maxIssues: MAX_ISSUES, }); console.log(`Fetched ${closedIssues.length} closed issues`); let allIssues = [...openIssues, ...closedIssues]; const seen = new Set(); allIssues = allIssues.filter((issue) => { if (seen.has(issue.number)) return false; seen.add(issue.number); return true; }); console.log(`Total unique issues to index: ${allIssues.length}`); if (allIssues.length === 0) { console.warn('No issues found - writing empty index'); writeFileSync(OUTPUT_PATH, JSON.stringify({ issues: [], embeddings: [] })); return; } console.log(`Loading model: ${MODEL_NAME}`); const extractor = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'fp32', }); const texts = allIssues.map((issue) => issueText(issue.title, issue.body)); const allEmbeddings = []; console.log(`Generating embeddings for ${texts.length} issues...`); for (let i = 0; i < texts.length; i += BATCH_SIZE) { const batch = texts.slice(i, i + BATCH_SIZE); const output = await extractor(batch, { pooling: 'mean', normalize: true, }); const vectors = output.tolist(); allEmbeddings.push(...vectors); const progress = Math.min(i + BATCH_SIZE, texts.length); console.log(` ${progress}/${texts.length}`); } const issueMetadata = allIssues.map((issue) => { const body = (issue.body || '').trim(); return { number: issue.number, title: issue.title, state: issue.state, url: issue.html_url, body_preview: body.slice(0, 500) || '', labels: (issue.labels || []).map((l) => l.name), created_at: issue.created_at, updated_at: issue.updated_at, }; }); const indexData = { issues: issueMetadata, embeddings: allEmbeddings, model: MODEL_NAME, issue_count: issueMetadata.length, built_at: new Date().toISOString(), }; const dir = dirname(OUTPUT_PATH); if (dir && dir !== '.') mkdirSync(dir, { recursive: true }); writeFileSync(OUTPUT_PATH, JSON.stringify(indexData)); const sizeMb = ( Buffer.byteLength(JSON.stringify(indexData)) / (1024 * 1024) ).toFixed(1); console.log( `Index saved to ${OUTPUT_PATH} (${sizeMb} MB, ${issueMetadata.length} issues)` ); } main().catch((err) => { console.error(err); process.exit(1); }); ================================================ FILE: bin/duplicate-detector/detect.mjs ================================================ #!/usr/bin/env node /** * Duplicate Issue Detector * * Triggered on new issue creation. Compares the new issue against an * existing embedding index, then uses an LLM to * confirm duplicates before posting a comment for maintainer review. */ import { pipeline } from '@huggingface/transformers'; import { existsSync, readFileSync } from 'node:fs'; import { addLabel, dotProduct, fetchIssues, getIssue, issueText, postComment, } from './utils.mjs'; const SIMILARITY_THRESHOLD = 0.55; const TOP_K = 5; const MAX_COMMENT_CANDIDATES = 3; const MODEL_NAME = process.env.EMBEDDING_MODEL || 'Xenova/all-MiniLM-L6-v2'; const GROQ_MODEL = process.env.GROQ_MODEL || 'llama-3.3-70b-versatile'; const INDEX_PATH = 'issue_index.json'; const LABEL_NAME = 'possible-duplicate'; const GROQ_API_KEY = process.env.GROQ_API_KEY || ''; const ISSUE_NUMBER = parseInt(process.env.ISSUE_NUMBER, 10); function loadIndex(path) { if (!existsSync(path)) { console.error( `Index file not found at ${path}. Run build-index.mjs first.` ); process.exit(1); } const data = JSON.parse(readFileSync(path, 'utf-8')); console.log(`Loaded index with ${data.issues.length} issues`); return data; } function findSimilar( queryEmbedding, index, { topK = TOP_K, threshold = SIMILARITY_THRESHOLD, excludeNumber } = {} ) { const { issues, embeddings } = index; if (!issues.length) return []; const scored = issues.map((issue, i) => ({ ...issue, score: dotProduct(queryEmbedding, embeddings[i]), })); return scored .sort((a, b) => b.score - a.score) .filter( (c) => c.score >= threshold && (!excludeNumber || c.number !== excludeNumber) ) .slice(0, topK); } const CONFIRM_SYSTEM_PROMPT = `You are a GitHub issue triage assistant. You will be given a NEW issue and one \ or more CANDIDATE issues that may be duplicates. For each candidate, determine if the new issue is truly a duplicate (same root \ problem/request) or merely related (similar area but different issue). Respond ONLY with a JSON array of objects, each with: - "number": the candidate issue number - "duplicate": true or false - "reason": one-sentence explanation Example: [{"number": 123, "duplicate": true, "reason": "Both report the same crash when ..."}]`; async function confirmWithLlm(newIssue, candidates) { if (!GROQ_API_KEY) { console.warn('GROQ_API_KEY not set — skipping LLM confirmation'); return candidates; } const candidateText = candidates .map( (c) => `### Candidate #${c.number} (similarity: ${c.score.toFixed(2)})\n` + `**Title:** ${c.title}\n` + `**State:** ${c.state}\n` + `**Body preview:** ${(c.body_preview || 'N/A').slice(0, 500)}` ) .join('\n\n'); const userPrompt = `## NEW ISSUE #${newIssue.number}\n` + `**Title:** ${newIssue.title}\n` + `**Body:**\n${(newIssue.body || 'No body').slice(0, 1500)}\n\n` + `---\n\n` + `## CANDIDATES\n${candidateText}`; try { const resp = await fetch( 'https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${GROQ_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: GROQ_MODEL, messages: [ { role: 'system', content: CONFIRM_SYSTEM_PROMPT }, { role: 'user', content: userPrompt }, ], temperature: 0.1, max_tokens: 1024, }), signal: AbortSignal.timeout(30_000), } ); if (!resp.ok) { const text = await resp.text(); throw new Error(`Groq API error ${resp.status}: ${text}`); } let content = (await resp.json()).choices[0].message.content.trim(); if (content.startsWith('```')) { content = content .split('\n') .slice(1) .join('\n') .replace(/```\s*$/, '') .trim(); } const verdicts = JSON.parse(content); if (!Array.isArray(verdicts)) { throw new Error('Invalid LLM response format - expected array'); } const verdictMap = new Map(verdicts.map((v) => [v.number, v])); const confirmed = []; for (const c of candidates) { const verdict = verdictMap.get(c.number); if (verdict?.duplicate) { c.llm_reason = verdict.reason || ''; confirmed.push(c); } else { const reason = verdict?.reason || 'not evaluated'; console.log(` #${c.number} ruled out by LLM: ${reason}`); } } return confirmed; } catch (err) { console.warn( `LLM confirmation failed: ${err.message} - falling back to all candidates` ); return candidates; } } function formatComment(candidates) { const lines = [ '**Possible duplicate detected**', '', 'This issue may be a duplicate of the following (detected via semantic similarity + LLM review):', '', ]; for (const c of candidates.slice(0, MAX_COMMENT_CANDIDATES)) { const confidence = `${(c.score * 100).toFixed(0)}%`; let line = `- #${c.number} (${confidence} match) — ${c.title}`; if (c.llm_reason) { line += `\n > *${c.llm_reason}*`; } lines.push(line); } lines.push( '', 'A maintainer will review this. If this is **not** a duplicate, no action is needed.', '', `` ); return lines.join('\n'); } async function main() { if (!ISSUE_NUMBER) { console.error('ISSUE_NUMBER not set'); process.exit(1); } console.log(`Processing issue #${ISSUE_NUMBER}`); const issue = await getIssue(ISSUE_NUMBER); const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); const recentIssues = await fetchIssues({ creator: issue.user.login, since: oneHourAgo, state: 'all', }); if (recentIssues.length > 10) { console.log( `User ${issue.user.login} created ${recentIssues.length} issues in the last hour - skipping to prevent spam` ); return; } if (issue.pull_request) { console.log('Skipping - this is a pull request'); return; } if (issue.user.type === 'Bot') { console.log('Skipping - issue created by bot'); return; } console.log(`Loading model: ${MODEL_NAME}`); const extractor = await pipeline('feature-extraction', MODEL_NAME, { dtype: 'fp32', }); const index = loadIndex(INDEX_PATH); const text = issueText(issue.title, issue.body); const output = await extractor(text, { pooling: 'mean', normalize: true }); const queryEmbedding = output.tolist()[0]; let candidates = findSimilar(queryEmbedding, index, { topK: TOP_K, threshold: SIMILARITY_THRESHOLD, excludeNumber: issue.number, }); if (!candidates.length) { console.log('No similar issues found above threshold - done'); return; } console.log(`Found ${candidates.length} candidates above threshold:`); for (const c of candidates) { console.log(` #${c.number} (${c.score.toFixed(3)}) - ${c.title}`); } console.log('Running LLM confirmation via Groq...'); candidates = await confirmWithLlm(issue, candidates); if (!candidates.length) { console.log('LLM ruled out all candidates - done'); return; } const comment = formatComment(candidates); await postComment(ISSUE_NUMBER, comment); await addLabel(ISSUE_NUMBER, LABEL_NAME); console.log('Done!'); } main().catch((err) => { console.error(err); process.exit(1); }); ================================================ FILE: bin/duplicate-detector/package.json ================================================ { "name": "duplicate-detector", "version": "1.0.0", "private": true, "type": "module", "packageManager": "pnpm@10.17.1", "scripts": { "build-index": "node build-index.mjs", "detect": "node detect.mjs" }, "dependencies": { "@huggingface/transformers": "^3.8.1" }, "engines": { "node": ">=22.0" } } ================================================ FILE: bin/duplicate-detector/utils.mjs ================================================ const GITHUB_API = 'https://api.github.com'; const GITHUB_TOKEN = process.env.GITHUB_TOKEN; const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY; function ghHeaders() { return { Authorization: `token ${GITHUB_TOKEN}`, Accept: 'application/vnd.github+json', }; } export async function fetchIssues({ state = 'open', since, maxIssues = 5000, } = {}) { const issues = []; let page = 1; const perPage = 100; while (issues.length < maxIssues) { const params = new URLSearchParams({ state, per_page: String(perPage), page: String(page), sort: 'updated', direction: 'desc', }); if (since) params.set('since', since); const url = `${GITHUB_API}/repos/${GITHUB_REPOSITORY}/issues?${params}`; const resp = await fetch(url, { headers: ghHeaders() }); if (!resp.ok) { throw new Error(`GitHub API error: ${resp.status} ${resp.statusText}`); } const batch = await resp.json(); if (!batch.length) break; for (const item of batch) { if (!item.pull_request) { issues.push(item); } } page++; if (batch.length < perPage) break; } return issues.slice(0, maxIssues); } export async function getIssue(issueNumber) { const url = `${GITHUB_API}/repos/${GITHUB_REPOSITORY}/issues/${issueNumber}`; const resp = await fetch(url, { headers: ghHeaders() }); if (!resp.ok) { throw new Error(`GitHub API error: ${resp.status} ${resp.statusText}`); } return resp.json(); } export async function postComment(issueNumber, body) { const url = `${GITHUB_API}/repos/${GITHUB_REPOSITORY}/issues/${issueNumber}/comments`; const resp = await fetch(url, { method: 'POST', headers: { ...ghHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ body }), }); if (!resp.ok) { throw new Error( `Failed to post comment: ${resp.status} ${resp.statusText}` ); } console.log(`Posted comment on #${issueNumber}`); } export async function addLabel(issueNumber, label) { const url = `${GITHUB_API}/repos/${GITHUB_REPOSITORY}/issues/${issueNumber}/labels`; const resp = await fetch(url, { method: 'POST', headers: { ...ghHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ labels: [label] }), }); if (resp.status === 404) { console.warn( `Label '${label}' does not exist - skipping. Create it manually.` ); return; } if (!resp.ok) { throw new Error(`Failed to add label: ${resp.status} ${resp.statusText}`); } console.log(`Added label '${label}' to #${issueNumber}`); } export function issueText(title, body) { body = (body || '').trim(); if (body.length > 2000) body = body.slice(0, 2000) + '...'; return body ? `${title}\n\n${body}` : title; } export function dotProduct(a, b) { let sum = 0; for (let i = 0; i < a.length; i++) { sum += a[i] * b[i]; } return sum; } ================================================ FILE: bin/prepare.js ================================================ #!/usr/bin/env node /** * Do not run husky in CI environments */ const isCi = process.env.CI !== undefined; if (!isCi) { require('husky').install(); } ================================================ FILE: charts/seerr-chart/.helmignore ================================================ # Patterns to ignore when building packages. # This supports shell glob matching, relative path matching, and # negation (prefixed with !). Only one pattern per line. .DS_Store # Common VCS dirs .git/ .gitignore .bzr/ .bzrignore .hg/ .hgignore .svn/ # Common backup files *.swp *.bak *.tmp *.orig *~ # Various IDEs .project .idea/ *.tmproj .vscode/ # go template *.gotmpl ================================================ FILE: charts/seerr-chart/Chart.yaml ================================================ apiVersion: v2 kubeVersion: '>=1.23.0-0' name: seerr-chart description: Seerr helm chart for Kubernetes type: application version: 3.3.0 # renovate: image=ghcr.io/seerr-team/seerr appVersion: 'v3.1.0' maintainers: - name: Seerr Team url: https://github.com/orgs/seerr-team/people sources: - https://github.com/seerr-team/seerr/tree/main/charts/seerr home: https://github.com/seerr-team/seerr ================================================ FILE: charts/seerr-chart/README.md ================================================ # seerr-chart ![Version: 3.3.0](https://img.shields.io/badge/Version-3.3.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v3.1.0](https://img.shields.io/badge/AppVersion-v3.1.0-informational?style=flat-square) Seerr helm chart for Kubernetes **Homepage:** ## Maintainers | Name | Email | Url | | ---- | ------ | --- | | Seerr Team | | | ## Source Code * ## Requirements Kubernetes: `>=1.23.0-0` ## Installation Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes) ## Update Notes ### Updating to 3.0.0 Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳 refer to our [Migration guide](https://docs.seerr.dev/migration-guide). ### Updating to 2.7.0 Seerr is a stateful application and it is not designed to have multiple replicas. In version 2.7.0 we address this by: - replacing `Deployment` with `StatefulSet` - removing `replicaCount` value If `replicaCount` value was used - remove it. Helm update should work fine after that. ## Values | Key | Type | Default | Description | |-----|------|---------|-------------| | affinity | object | `{}` | | | config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"existingClaim":"","name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration | | config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk | | config.persistence.annotations | object | `{}` | Annotations for PVCs | | config.persistence.existingClaim | string | `""` | Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created | | config.persistence.name | string | `""` | Config name | | config.persistence.size | string | `"5Gi"` | Size of persistent disk | | config.persistence.volumeName | string | `""` | Name of the permanent volume to reference in the claim. Can be used to bind to existing volumes. | | extraEnv | list | `[]` | Environment variables to add to the seerr pods | | extraEnvFrom | list | `[]` | Environment variables from secrets or configmaps to add to the seerr pods | | fullnameOverride | string | `""` | | | image.pullPolicy | string | `"IfNotPresent"` | | | image.registry | string | `"ghcr.io"` | | | image.repository | string | `"seerr-team/seerr"` | | | image.sha | string | `""` | | | image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | | imagePullSecrets | list | `[]` | | | ingress.annotations | object | `{}` | | | ingress.enabled | bool | `false` | | | ingress.hosts[0].host | string | `"chart-example.local"` | | | ingress.hosts[0].paths[0].path | string | `"/"` | | | ingress.hosts[0].paths[0].pathType | string | `"ImplementationSpecific"` | | | ingress.ingressClassName | string | `""` | | | ingress.tls | list | `[]` | | | nameOverride | string | `""` | | | nodeSelector | object | `{}` | | | podAnnotations | object | `{}` | | | podLabels | object | `{}` | | | podSecurityContext.fsGroup | int | `1000` | | | podSecurityContext.fsGroupChangePolicy | string | `"OnRootMismatch"` | | | probes.livenessProbe | object | `{}` | Configure liveness probe | | probes.readinessProbe | object | `{}` | Configure readiness probe | | probes.startupProbe | string | `nil` | Configure startup probe | | resources | object | `{}` | | | route.main.additionalRules | list | `[]` | | | route.main.annotations | object | `{}` | | | route.main.apiVersion | string | `"gateway.networking.k8s.io/v1"` | Set the route apiVersion, e.g. gateway.networking.k8s.io/v1 or gateway.networking.k8s.io/v1alpha2 | | route.main.enabled | bool | `false` | Enables or disables the Gateway API route | | route.main.filters | list | `[]` | | | route.main.hostnames | list | `[]` | | | route.main.httpsRedirect | bool | `false` | To redirect to HTTPS, create a new route object under the main route and enable this option. This should only be used with HTTP-like routes, such as HTTPRoute or GRPCRoute. [Reference]( https://gateway-api.sigs.k8s.io/guides/http-redirect-rewrite/ ) | | route.main.kind | string | `"HTTPRoute"` | Set the route kind. Note that experimental kinds require changing `apiVersion` | | route.main.labels | object | `{}` | | | route.main.matches[0].path.type | string | `"PathPrefix"` | | | route.main.matches[0].path.value | string | `"/"` | | | route.main.parentRefs | list | `[]` | | | securityContext.allowPrivilegeEscalation | bool | `false` | | | securityContext.capabilities.drop[0] | string | `"ALL"` | | | securityContext.privileged | bool | `false` | | | securityContext.readOnlyRootFilesystem | bool | `false` | | | securityContext.runAsGroup | int | `1000` | | | securityContext.runAsNonRoot | bool | `true` | | | securityContext.runAsUser | int | `1000` | | | securityContext.seccompProfile.type | string | `"RuntimeDefault"` | | | service.port | int | `80` | | | service.type | string | `"ClusterIP"` | | | serviceAccount.annotations | object | `{}` | Annotations to add to the service account | | serviceAccount.automount | bool | `true` | Automatically mount a ServiceAccount's API credentials? | | serviceAccount.create | bool | `true` | Specifies whether a service account should be created | | serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template | | tolerations | list | `[]` | | | volumeMounts | list | `[]` | Additional volumeMounts on the output StatefulSet definition. | | volumes | list | `[]` | Additional volumes on the output StatefulSet definition. | ================================================ FILE: charts/seerr-chart/README.md.gotmpl ================================================ {{ template "chart.header" . }} {{ template "chart.deprecationWarning" . }} {{ template "chart.badgesSection" . }} {{ template "chart.description" . }} {{ template "chart.homepageLine" . }} {{ template "chart.maintainersSection" . }} {{ template "chart.sourcesSection" . }} {{ template "chart.requirementsSection" . }} ## Installation Refer to [Seerr kubernetes documentation](https://docs.seerr.dev/getting-started/kubernetes) ## Update Notes ### Updating to 3.0.0 Nothing has changed; we just rebranded the `jellyseerr` Helm chart to `seerr` 🥳 refer to our [Migration guide](https://docs.seerr.dev/migration-guide). ### Updating to 2.7.0 Seerr is a stateful application and it is not designed to have multiple replicas. In version 2.7.0 we address this by: - replacing `Deployment` with `StatefulSet` - removing `replicaCount` value If `replicaCount` value was used - remove it. Helm update should work fine after that. {{ template "chart.valuesSection" . }} ================================================ FILE: charts/seerr-chart/artifacthub-repo.yml ================================================ repositoryID: 249547ec-2a30-48de-a5bc-07bfd5aa2e8f ================================================ FILE: charts/seerr-chart/templates/NOTES.txt ================================================ *********************************************************************** Welcome to {{ .Chart.Name }} Chart version: {{ .Chart.Version }} App version: {{ .Chart.AppVersion }} *********************************************************************** ================================================ FILE: charts/seerr-chart/templates/_helpers.tpl ================================================ {{/* Expand the name of the chart. */}} {{- define "seerr.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Create a default fully qualified app name. We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). If release name contains chart name it will be used as a full name. */}} {{- define "seerr.fullname" -}} {{- if .Values.fullnameOverride }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- else }} {{- $name := default .Chart.Name .Values.nameOverride }} {{- if contains $name .Release.Name }} {{- .Release.Name | trunc 63 | trimSuffix "-" }} {{- else }} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} {{- end }} {{- end }} {{- end }} {{/* Create chart name and version as used by the chart label. */}} {{- define "seerr.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Common labels */}} {{- define "seerr.labels" -}} helm.sh/chart: {{ include "seerr.chart" . }} {{ include "seerr.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/part-of: {{ .Chart.Name }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} {{/* Selector labels */}} {{- define "seerr.selectorLabels" -}} app.kubernetes.io/name: {{ include "seerr.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* Create the name of the service account to use */}} {{- define "seerr.serviceAccountName" -}} {{- if .Values.serviceAccount.create }} {{- default (include "seerr.fullname" .) .Values.serviceAccount.name }} {{- else }} {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} {{/* Create the name of the pvc config to use */}} {{- define "seerr.configPersistenceName" -}} {{- default (printf "%s-config" (include "seerr.fullname" .)) .Values.config.persistence.name }} {{- end }} ================================================ FILE: charts/seerr-chart/templates/ingress.yaml ================================================ {{- if .Values.ingress.enabled -}} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "seerr.fullname" . }} labels: {{- include "seerr.labels" . | nindent 4 }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: {{- if .Values.ingress.ingressClassName }} ingressClassName: {{ .Values.ingress.ingressClassName }} {{- end }} {{- if .Values.ingress.tls }} tls: {{- range .Values.ingress.tls }} - hosts: {{- range .hosts }} - {{ . | quote }} {{- end }} secretName: {{ .secretName }} {{- end }} {{- end }} rules: {{- range .Values.ingress.hosts }} - host: {{ .host | quote }} http: paths: {{- range .paths }} - path: {{ .path }} pathType: {{ .pathType }} backend: service: name: {{ include "seerr.fullname" $ }} port: number: {{ $.Values.service.port }} {{- end }} {{- end }} {{- end }} ================================================ FILE: charts/seerr-chart/templates/persistentvolumeclaim.yaml ================================================ {{- if not .Values.config.persistence.existingClaim -}} apiVersion: v1 kind: PersistentVolumeClaim metadata: name: {{ include "seerr.configPersistenceName" . }} labels: {{- include "seerr.labels" . | nindent 4 }} {{- with .Values.config.persistence.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: {{- with .Values.config.persistence.accessModes }} accessModes: {{- toYaml . | nindent 4 }} {{- end }} {{- if .Values.config.persistence.volumeName }} volumeName: {{ .Values.config.persistence.volumeName }} {{- end }} {{- with .Values.config.persistence.storageClass }} storageClassName: {{ if (eq "-" .) }}""{{ else }}{{ . }}{{ end }} {{- end }} resources: requests: storage: "{{ .Values.config.persistence.size }}" {{- end -}} ================================================ FILE: charts/seerr-chart/templates/route.yaml ================================================ {{- range $name, $route := .Values.route }} {{- if $route.enabled }} --- apiVersion: {{ $route.apiVersion | default "gateway.networking.k8s.io/v1" }} kind: {{ $route.kind | default "HTTPRoute" }} metadata: name: {{ template "seerr.fullname" $ }}{{ if ne $name "main" }}-{{ $name }}{{ end }} labels: {{- include "seerr.labels" $ | nindent 4 }} {{- with $route.labels }} {{- toYaml . | nindent 4 }} {{- end }} {{- with $route.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: {{- with $route.parentRefs }} parentRefs: {{- toYaml . | nindent 4 }} {{- end }} {{- with $route.hostnames }} hostnames: {{- tpl (toYaml .) $ | nindent 4 }} {{- end }} rules: {{- with $route.additionalRules }} {{- tpl (toYaml .) $ | nindent 4 }} {{- end }} {{- if $route.httpsRedirect }} - filters: - type: RequestRedirect requestRedirect: scheme: https statusCode: 301 {{- else }} - backendRefs: - name: {{ include "seerr.fullname" $ }} port: {{ $.Values.service.port }} {{- with $route.filters }} filters: {{- toYaml . | nindent 8 }} {{- end }} {{- with $route.matches }} matches: {{- toYaml . | nindent 8 }} {{- end }} {{- end }} {{- end }} {{- end }} ================================================ FILE: charts/seerr-chart/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: {{ include "seerr.fullname" . }} labels: {{- include "seerr.labels" . | nindent 4 }} spec: type: {{ .Values.service.type }} ports: - port: {{ .Values.service.port }} targetPort: http protocol: TCP name: http selector: {{- include "seerr.selectorLabels" . | nindent 4 }} ipFamilyPolicy: PreferDualStack ================================================ FILE: charts/seerr-chart/templates/serviceaccount.yaml ================================================ {{- if .Values.serviceAccount.create -}} apiVersion: v1 kind: ServiceAccount metadata: name: {{ include "seerr.serviceAccountName" . }} labels: {{- include "seerr.labels" . | nindent 4 }} {{- with .Values.serviceAccount.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} automountServiceAccountToken: {{ .Values.serviceAccount.automount }} {{- end }} ================================================ FILE: charts/seerr-chart/templates/statefulset.yaml ================================================ apiVersion: apps/v1 kind: StatefulSet metadata: name: {{ include "seerr.fullname" . }} labels: {{- include "seerr.labels" . | nindent 4 }} spec: serviceName: {{ include "seerr.fullname" . }} selector: matchLabels: {{- include "seerr.selectorLabels" . | nindent 6 }} template: metadata: {{- with .Values.podAnnotations }} annotations: {{- toYaml . | nindent 8 }} {{- end }} labels: {{- include "seerr.labels" . | nindent 8 }} {{- with .Values.podLabels }} {{- toYaml . | nindent 8 }} {{- end }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} serviceAccountName: {{ include "seerr.serviceAccountName" . }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: - name: {{ .Chart.Name }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} {{- if .Values.image.sha }} image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}@sha256:{{ .Values.image.sha }}" {{- else }} image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" {{- end }} imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - name: http containerPort: 5055 protocol: TCP livenessProbe: httpGet: path: / port: http {{- if .Values.probes.livenessProbe.initialDelaySeconds }} initialDelaySeconds: {{ .Values.probes.livenessProbe.initialDelaySeconds }} {{- end }} {{- if .Values.probes.livenessProbe.periodSeconds }} periodSeconds: {{ .Values.probes.livenessProbe.periodSeconds }} {{- end }} {{- if .Values.probes.livenessProbe.timeoutSeconds }} timeoutSeconds: {{ .Values.probes.livenessProbe.timeoutSeconds }} {{- end }} {{- if .Values.probes.livenessProbe.successThreshold }} successThreshold: {{ .Values.probes.livenessProbe.successThreshold }} {{- end }} {{- if .Values.probes.livenessProbe.failureThreshold }} failureThreshold: {{ .Values.probes.livenessProbe.failureThreshold }} {{- end }} readinessProbe: httpGet: path: / port: http {{- if .Values.probes.readinessProbe.initialDelaySeconds }} initialDelaySeconds: {{ .Values.probes.readinessProbe.initialDelaySeconds }} {{- end }} {{- if .Values.probes.readinessProbe.periodSeconds }} periodSeconds: {{ .Values.probes.readinessProbe.periodSeconds }} {{- end }} {{- if .Values.probes.readinessProbe.timeoutSeconds }} timeoutSeconds: {{ .Values.probes.readinessProbe.timeoutSeconds }} {{- end }} {{- if .Values.probes.readinessProbe.successThreshold }} successThreshold: {{ .Values.probes.readinessProbe.successThreshold }} {{- end }} {{- if .Values.probes.readinessProbe.failureThreshold }} failureThreshold: {{ .Values.probes.readinessProbe.failureThreshold }} {{- end }} {{- if .Values.probes.startupProbe }} startupProbe: {{- toYaml .Values.probes.startupProbe | nindent 12 }} {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} {{- with .Values.extraEnv }} env: {{- toYaml . | nindent 12 }} {{- end }} {{- with .Values.extraEnvFrom }} envFrom: {{- toYaml . | nindent 12 }} {{- end }} volumeMounts: - name: config mountPath: /app/config {{- with .Values.volumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} volumes: - name: config persistentVolumeClaim: claimName: {{ if .Values.config.persistence.existingClaim }}{{ .Values.config.persistence.existingClaim }}{{- else }}{{ include "seerr.configPersistenceName" . }}{{- end }} {{- with .Values.volumes }} {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.affinity }} affinity: {{- toYaml . | nindent 8 }} {{- end }} {{- with .Values.tolerations }} tolerations: {{- toYaml . | nindent 8 }} {{- end }} ================================================ FILE: charts/seerr-chart/templates/tests/test-connection.yaml ================================================ apiVersion: v1 kind: Pod metadata: name: "{{ include "seerr.fullname" . }}-test-connection" labels: {{- include "seerr.labels" . | nindent 4 }} annotations: "helm.sh/hook": test spec: containers: - name: wget image: busybox command: ['wget'] args: ['{{ include "seerr.fullname" . }}:{{ .Values.service.port }}'] restartPolicy: Never ================================================ FILE: charts/seerr-chart/values.yaml ================================================ image: registry: ghcr.io repository: seerr-team/seerr pullPolicy: IfNotPresent # -- Overrides the image tag whose default is the chart appVersion. tag: '' sha: '' imagePullSecrets: [] nameOverride: '' fullnameOverride: '' # Liveness / Readiness / Startup Probes probes: # -- Configure liveness probe livenessProbe: {} # initialDelaySeconds: 60 # periodSeconds: 30 # timeoutSeconds: 5 # successThreshold: 1 # failureThreshold: 5 # -- Configure readiness probe readinessProbe: {} # initialDelaySeconds: 60 # periodSeconds: 30 # timeoutSeconds: 5 # successThreshold: 1 # failureThreshold: 5 # -- Configure startup probe startupProbe: null # tcpSocket: # port: http # -- Environment variables to add to the seerr pods extraEnv: [] # -- Environment variables from secrets or configmaps to add to the seerr pods extraEnvFrom: [] serviceAccount: # -- Specifies whether a service account should be created create: true # -- Automatically mount a ServiceAccount's API credentials? automount: true # -- Annotations to add to the service account annotations: {} # -- The name of the service account to use. # -- If not set and create is true, a name is generated using the fullname template name: '' podAnnotations: {} podLabels: {} podSecurityContext: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL readOnlyRootFilesystem: false runAsNonRoot: true privileged: false runAsUser: 1000 runAsGroup: 1000 seccompProfile: type: RuntimeDefault service: type: ClusterIP port: 80 # -- Creating PVC to store configuration config: persistence: # -- Size of persistent disk size: 5Gi # -- Annotations for PVCs annotations: {} # -- Access modes of persistent disk accessModes: - ReadWriteOnce # -- Config name name: '' # -- Name of the permanent volume to reference in the claim. # Can be used to bind to existing volumes. volumeName: '' # -- Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created existingClaim: '' ingress: enabled: false ingressClassName: '' annotations: {} # kubernetes.io/ingress.class: nginx # kubernetes.io/tls-acme: "true" hosts: - host: chart-example.local paths: - path: / pathType: ImplementationSpecific tls: [] # - secretName: chart-example-tls # hosts: # - chart-example.local route: main: # -- Enables or disables the Gateway API route enabled: false # -- Set the route apiVersion, e.g. gateway.networking.k8s.io/v1 or gateway.networking.k8s.io/v1alpha2 apiVersion: gateway.networking.k8s.io/v1 # -- Set the route kind. # Note that experimental kinds require changing `apiVersion` kind: HTTPRoute annotations: {} labels: {} parentRefs: [] # - name: my-gateway # namespace: gateway # sectionName: https hostnames: [] # - seerr.example.com additionalRules: [] filters: [] matches: - path: type: PathPrefix value: / # -- To redirect to HTTPS, create a new route object under the main route and enable this option. # This should only be used with HTTP-like routes, such as HTTPRoute or GRPCRoute. # [Reference]( https://gateway-api.sigs.k8s.io/guides/http-redirect-rewrite/ ) httpsRedirect: false resources: {} # We usually recommend not to specify default resources and to leave this as a conscious # choice for the user. This also increases chances charts run on environments with little # resources, such as Minikube. If you do want to specify resources, uncomment the following # lines, adjust them as necessary, and remove the curly braces after 'resources:'. # limits: # cpu: 100m # memory: 128Mi # requests: # cpu: 100m # memory: 128Mi # -- Additional volumes on the output StatefulSet definition. volumes: [] # - name: foo # secret: # secretName: mysecret # optional: false # -- Additional volumeMounts on the output StatefulSet definition. volumeMounts: [] # - name: foo # mountPath: "/etc/foo" # readOnly: true nodeSelector: {} tolerations: [] affinity: {} ================================================ FILE: compose.postgres.yaml ================================================ services: seerr: build: context: . dockerfile: Dockerfile.local ports: - '5055:5055' environment: DB_TYPE: 'postgres' # Which DB engine to use. The default is "sqlite". To use postgres, this needs to be set to "postgres" DB_HOST: 'postgres' # The host (url) of the database DB_PORT: '5432' # The port to connect to DB_USER: 'seerr' # Username used to connect to the database DB_PASS: 'seerr' # Password of the user used to connect to the database DB_NAME: 'seerr' # The name of the database to connect to DB_LOG_QUERIES: 'false' # Whether to log the DB queries for debugging DB_USE_SSL: 'false' # Whether to enable ssl for database connection volumes: - .:/app:rw,cached - /app/node_modules - /app/.next depends_on: - postgres links: - postgres postgres: image: postgres:18 environment: POSTGRES_USER: seerr POSTGRES_PASSWORD: seerr POSTGRES_DB: seerr ports: - '5432:5432' volumes: - postgres:/var/lib/postgresql volumes: postgres: ================================================ FILE: compose.yaml ================================================ services: seerr: build: context: . dockerfile: Dockerfile.local ports: - 5055:5055 volumes: - .:/app:rw,cached - /app/node_modules - /app/.next ================================================ FILE: config/.gitkeep ================================================ ================================================ FILE: config/db/.gitkeep ================================================ ================================================ FILE: config/logs/.gitkeep ================================================ ================================================ FILE: cypress/config/settings.cypress.json ================================================ { "clientId": "6919275e-142a-48d8-be6b-93594cbd4626", "vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M", "vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs", "main": { "apiKey": "testkey", "applicationTitle": "Seerr", "applicationUrl": "", "cacheImages": false, "defaultPermissions": 32, "defaultQuotas": { "movie": {}, "tv": {} }, "hideAvailable": false, "localLogin": true, "newPlexLogin": true, "discoverRegion": "", "streamingRegion": "", "originalLanguage": "", "blocklistedTags": "", "blocklistedTagsLimit": 50, "trustProxy": false, "mediaServerType": 1, "partialRequestsEnabled": true, "enableSpecialEpisodes": false, "locale": "en" }, "plex": { "name": "Seerr", "ip": "192.168.1.1", "port": 32400, "useSsl": false, "libraries": [ { "id": "1", "name": "Movies", "enabled": true, "type": "movie" } ], "machineId": "test" }, "jellyfin": { "name": "", "ip": "", "port": 8096, "useSsl": false, "urlBase": "", "externalHostname": "", "jellyfinForgotPasswordUrl": "", "libraries": [], "serverId": "" }, "tautulli": {}, "radarr": [], "sonarr": [], "public": { "initialized": true }, "notifications": { "agents": { "email": { "enabled": false, "options": { "emailFrom": "", "smtpHost": "", "smtpPort": 587, "secure": false, "ignoreTls": false, "requireTls": false, "allowSelfSigned": false, "senderName": "Seerr" } }, "discord": { "enabled": false, "types": 0, "options": { "webhookUrl": "", "webhookRoleId": "", "enableMentions": true } }, "slack": { "enabled": false, "types": 0, "options": { "webhookUrl": "" } }, "telegram": { "enabled": false, "types": 0, "options": { "botAPI": "", "chatId": "", "messageThreadId": "", "sendSilently": false } }, "pushbullet": { "enabled": false, "types": 0, "options": { "accessToken": "" } }, "pushover": { "enabled": false, "types": 0, "options": { "accessToken": "", "userToken": "" } }, "webhook": { "enabled": false, "types": 0, "options": { "webhookUrl": "", "jsonPayload": "IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i" } }, "webpush": { "enabled": false, "options": {} }, "gotify": { "enabled": false, "types": 0, "options": { "url": "", "token": "", "priority": 0 } }, "ntfy": { "enabled": false, "types": 0, "options": { "url": "", "topic": "" } } } }, "jobs": { "plex-recently-added-scan": { "schedule": "0 */5 * * * *" }, "plex-full-scan": { "schedule": "0 0 3 * * *" }, "radarr-scan": { "schedule": "0 0 4 * * *" }, "sonarr-scan": { "schedule": "0 30 4 * * *" }, "plex-watchlist-sync": { "schedule": "0 */10 * * * *" }, "availability-sync": { "schedule": "0 0 5 * * *" }, "download-sync": { "schedule": "0 * * * * *" }, "download-sync-reset": { "schedule": "0 0 1 * * *" }, "jellyfin-recently-added-scan": { "schedule": "0 */5 * * * *" }, "jellyfin-full-scan": { "schedule": "0 0 3 * * *" }, "image-cache-cleanup": { "schedule": "0 0 5 * * *" } }, "network": { "csrfProtection": false, "trustProxy": false, "forceIpv4First": false, "dnsServers": "", "proxy": { "enabled": false, "hostname": "", "port": 8080, "useSsl": false, "user": "", "password": "", "bypassFilter": "", "bypassLocalAddresses": true }, "dnsCache": { "enabled": false, "forceMinTtl": 0, "forceMaxTtl": -1 } } } ================================================ FILE: cypress/e2e/discover.cy.ts ================================================ const clickFirstTitleCardInSlider = (sliderTitle: string): void => { cy.contains('.slider-header', sliderTitle) .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .trigger('mouseover') .find('[data-testid=title-card-title]') .invoke('text') .then((text) => { cy.contains('.slider-header', sliderTitle) .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .click(); cy.get('[data-testid=media-title]').should('contain', text); }); }; describe('Discover', () => { beforeEach(() => { cy.loginAsAdmin(); }); it('loads a trending item', () => { cy.intercept('/api/v1/discover/trending*').as('getTrending'); cy.visit('/'); cy.wait('@getTrending'); clickFirstTitleCardInSlider('Trending'); }); it('loads popular movies', () => { cy.intercept('/api/v1/discover/movies*').as('getPopularMovies'); cy.visit('/'); cy.wait('@getPopularMovies'); clickFirstTitleCardInSlider('Popular Movies'); }); it('loads upcoming movies', () => { cy.intercept('/api/v1/discover/movies?page=1&primaryReleaseDateGte*').as( 'getUpcomingMovies' ); cy.visit('/'); cy.wait('@getUpcomingMovies'); clickFirstTitleCardInSlider('Upcoming Movies'); }); it('loads popular series', () => { cy.intercept('/api/v1/discover/tv*').as('getPopularTv'); cy.visit('/'); cy.wait('@getPopularTv'); clickFirstTitleCardInSlider('Popular Series'); }); it('loads upcoming series', () => { cy.intercept('/api/v1/discover/tv?page=1&firstAirDateGte=*').as( 'getUpcomingSeries' ); cy.visit('/'); cy.wait('@getUpcomingSeries'); clickFirstTitleCardInSlider('Upcoming Series'); }); it('displays error for media with invalid TMDB ID', () => { cy.intercept('GET', '/api/v1/media?*', { pageInfo: { pages: 1, pageSize: 20, results: 1, page: 1 }, results: [ { downloadStatus: [], downloadStatus4k: [], id: 1922, mediaType: 'movie', tmdbId: 998814, tvdbId: null, imdbId: null, status: 5, status4k: 1, createdAt: '2022-08-18T18:11:13.000Z', updatedAt: '2022-08-18T19:56:41.000Z', lastSeasonChange: '2022-08-18T19:56:41.000Z', mediaAddedAt: '2022-08-18T19:56:41.000Z', serviceId: null, serviceId4k: null, externalServiceId: null, externalServiceId4k: null, externalServiceSlug: null, externalServiceSlug4k: null, ratingKey: null, ratingKey4k: null, seasons: [], }, ], }).as('getMedia'); cy.visit('/'); cy.wait('@getMedia'); cy.contains('.slider-header', 'Recently Added') .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .find('[data-testid=title-card-title]') .contains('Movie Not Found'); }); it('displays error for request with invalid TMDB ID', () => { cy.intercept('GET', '/api/v1/request?*', { pageInfo: { pages: 1, pageSize: 10, results: 1, page: 1 }, results: [ { id: 582, status: 1, createdAt: '2022-08-18T18:11:13.000Z', updatedAt: '2022-08-18T18:11:13.000Z', type: 'movie', is4k: false, serverId: null, profileId: null, rootFolder: null, languageProfileId: null, tags: null, media: { downloadStatus: [], downloadStatus4k: [], id: 1922, mediaType: 'movie', tmdbId: 998814, tvdbId: null, imdbId: null, status: 2, status4k: 1, createdAt: '2022-08-18T18:11:13.000Z', updatedAt: '2022-08-18T18:11:13.000Z', lastSeasonChange: '2022-08-18T18:11:13.000Z', mediaAddedAt: null, serviceId: null, serviceId4k: null, externalServiceId: null, externalServiceId4k: null, externalServiceSlug: null, externalServiceSlug4k: null, ratingKey: null, ratingKey4k: null, }, seasons: [], modifiedBy: null, requestedBy: { permissions: 4194336, id: 18, email: 'friend@seerr.dev', plexUsername: null, username: '', recoveryLinkExpirationDate: null, userType: 2, avatar: 'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200', movieQuotaLimit: null, movieQuotaDays: null, tvQuotaLimit: null, tvQuotaDays: null, createdAt: '2022-08-17T04:55:28.000Z', updatedAt: '2022-08-17T04:55:28.000Z', requestCount: 1, displayName: 'friend@seerr.dev', }, seasonCount: 0, }, ], }).as('getRequests'); cy.visit('/'); cy.wait('@getRequests'); cy.contains('.slider-header', 'Recent Requests') .next('[data-testid=media-slider]') .find('[data-testid=request-card]') .first() .find('[data-testid=request-card-title]') .contains('Movie Not Found'); }); it('loads plex watchlist', () => { cy.intercept('/api/v1/discover/watchlist', { fixture: 'watchlist.json', }).as('getWatchlist'); // Wait for one of the watchlist movies to resolve cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); cy.visit('/'); cy.wait('@getWatchlist'); const sliderHeader = cy.contains('.slider-header', 'Watchlist'); sliderHeader.scrollIntoView(); cy.wait('@getTmdbMovie'); // Wait a little longer to make sure the movie component reloaded cy.wait(500); sliderHeader .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .trigger('mouseover') .find('[data-testid=title-card-title]') .invoke('text') .then((text) => { cy.contains('.slider-header', 'Watchlist') .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .click(); cy.get('[data-testid=media-title]').should('contain', text); }); }); }); ================================================ FILE: cypress/e2e/login.cy.ts ================================================ describe('Login Page', () => { it('succesfully logs in as an admin', () => { cy.loginAsAdmin(); cy.visit('/'); cy.contains('Trending'); }); it('succesfully logs in as a local user', () => { cy.loginAsUser(); cy.visit('/'); cy.contains('Trending'); }); }); ================================================ FILE: cypress/e2e/movie-details.cy.ts ================================================ describe('Movie Details', () => { it('loads a movie page', () => { cy.loginAsAdmin(); // Try to load minions: rise of gru cy.visit('/movie/438148'); cy.get('[data-testid=media-title]').should( 'contain', 'Minions: The Rise of Gru (2022)' ); }); }); ================================================ FILE: cypress/e2e/providers/tvdb.cy.ts ================================================ describe('TVDB Integration', () => { // Constants for routes and selectors const ROUTES = { home: '/', metadataSettings: '/settings/metadata', tomorrowIsOursTvShow: '/tv/72879', monsterTvShow: '/tv/225634', dragonnBallZKaiAnime: '/tv/61709', }; const SELECTORS = { sidebarToggle: '[data-testid=sidebar-toggle]', sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]', settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]', metadataTestButton: 'button[type="button"]:contains("Test")', metadataSaveButton: '[data-testid="metadata-save-button"]', tmdbStatus: '[data-testid="tmdb-status"]', tvdbStatus: '[data-testid="tvdb-status"]', tvMetadataProviderSelector: '[data-testid="tv-metadata-provider-selector"]', animeMetadataProviderSelector: '[data-testid="anime-metadata-provider-selector"]', seasonSelector: '[data-testid="season-selector"]', season1: 'Season 1', season2: 'Season 2', season3: 'Season 3', episodeList: '[data-testid="episode-list"]', episode9: '9 - Hang Men', }; // Reusable commands const navigateToMetadataSettings = () => { cy.visit(ROUTES.home); cy.get(SELECTORS.sidebarToggle).click(); cy.get(SELECTORS.sidebarSettingsMobile).click(); cy.get( `${SELECTORS.settingsNavDesktop} a[href="${ROUTES.metadataSettings}"]` ).click(); }; const testAndVerifyMetadataConnection = () => { cy.intercept('POST', '/api/v1/settings/metadatas/test').as( 'testConnection' ); cy.get(SELECTORS.metadataTestButton).click(); return cy.wait('@testConnection'); }; const saveMetadataSettings = (customBody = null) => { if (customBody) { cy.intercept('PUT', '/api/v1/settings/metadatas', (req) => { req.body = customBody; }).as('saveMetadata'); } else { // Else just intercept without modifying body cy.intercept('PUT', '/api/v1/settings/metadatas').as('saveMetadata'); } cy.get(SELECTORS.metadataSaveButton).click(); return cy.wait('@saveMetadata'); }; beforeEach(() => { // Perform login cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); // Navigate to Metadata settings navigateToMetadataSettings(); // Verify we're on the correct settings page cy.contains('h3', 'Metadata Providers').should('be.visible'); // Configure TVDB as TV provider and test connection cy.get(SELECTORS.tvMetadataProviderSelector).click(); // get id react-select-4-option-1 cy.get('[class*="react-select__option"]').contains('TheTVDB').click(); // Test the connection testAndVerifyMetadataConnection().then(({ response }) => { expect(response.statusCode).to.equal(200); // Check TVDB connection status cy.get(SELECTORS.tvdbStatus).should('contain', 'Operational'); }); // Save settings saveMetadataSettings({ anime: 'tvdb', tv: 'tvdb', }).then(({ response }) => { expect(response.statusCode).to.equal(200); expect(response.body.tv).to.equal('tvdb'); }); }); it('should display "Tomorrow is Ours" show information with multiple seasons from TVDB', () => { // Navigate to the TV show cy.visit(ROUTES.tomorrowIsOursTvShow); // Verify that multiple seasons are displayed (TMDB has only 1 season, TVDB has multiple) // cy.get(SELECTORS.seasonSelector).should('exist'); cy.intercept('/api/v1/tv/225634/season/1').as('season1'); // Select Season 2 and verify it loads cy.contains(SELECTORS.season2) .should('be.visible') .scrollIntoView() .click(); // Verify that episodes are displayed for Season 2 cy.contains('260 - Episode 506').should('be.visible'); }); it('Should display "Monster" show information correctly when not existing on TVDB', () => { // Navigate to the TV show cy.visit(ROUTES.monsterTvShow); // Intercept season 1 request cy.intercept('/api/v1/tv/225634/season/1').as('season1'); // Select Season 1 cy.contains(SELECTORS.season1) .should('be.visible') .scrollIntoView() .click(); // Wait for the season data to load cy.wait('@season1'); // Verify specific episode exists cy.contains(SELECTORS.episode9).should('be.visible'); }); it('should display "Dragon Ball Z Kai" show information with multiple only 2 seasons from TVDB', () => { // Navigate to the TV show cy.visit(ROUTES.dragonnBallZKaiAnime); // Intercept season 1 request cy.intercept('/api/v1/tv/61709/season/1').as('season1'); // Select Season 2 and verify it visible cy.contains(SELECTORS.season2) .should('be.visible') .scrollIntoView() .click(); // select season 3 and verify it not visible cy.contains(SELECTORS.season3).should('not.exist'); }); }); ================================================ FILE: cypress/e2e/pull-to-refresh.cy.ts ================================================ describe('Pull To Refresh', () => { beforeEach(() => { cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); cy.viewport(390, 844); cy.visitMobile('/'); }); it('reloads the current page', () => { cy.wait(500); cy.intercept({ method: 'GET', url: '/api/v1/*', }).as('apiCall'); cy.get('.searchbar').swipe('bottom', [190, 500]); cy.wait('@apiCall').then((interception) => { assert.isNotNull( interception.response.body, 'API was called and received data' ); }); }); }); ================================================ FILE: cypress/e2e/settings/discover-customization.cy.ts ================================================ describe('Discover Customization', () => { beforeEach(() => { cy.loginAsAdmin(); cy.intercept('/api/v1/settings/discover').as('getDiscoverSliders'); }); it('show the discover customization settings', () => { cy.visit('/'); cy.get('[data-testid=discover-start-editing]').click(); cy.get('[data-testid=create-slider-header') .should('contain', 'Create New Slider') .scrollIntoView(); // There should be some built in options cy.get('[data-testid=discover-slider-edit-mode]').should( 'contain', 'Recently Added' ); cy.get('[data-testid=discover-slider-edit-mode]').should( 'contain', 'Recent Requests' ); }); it('can drag to re-order elements and save to persist the changes', () => { let dataTransfer = new DataTransfer(); cy.visit('/'); cy.get('[data-testid=discover-start-editing]').click(); cy.get('[data-testid=discover-slider-edit-mode]') .first() .trigger('dragstart', { dataTransfer }); cy.get('[data-testid=discover-slider-edit-mode]') .eq(1) .trigger('drop', { dataTransfer }); cy.get('[data-testid=discover-slider-edit-mode]') .eq(1) .trigger('dragend', { dataTransfer }); cy.get('[data-testid=discover-slider-edit-mode]') .eq(1) .should('contain', 'Recently Added'); cy.get('[data-testid=discover-customize-submit').click(); cy.wait('@getDiscoverSliders'); cy.reload(); cy.get('[data-testid=discover-start-editing]').click(); dataTransfer = new DataTransfer(); cy.get('[data-testid=discover-slider-edit-mode]') .eq(1) .should('contain', 'Recently Added'); cy.get('[data-testid=discover-slider-edit-mode]') .first() .trigger('dragstart', { dataTransfer }); cy.get('[data-testid=discover-slider-edit-mode]') .eq(1) .trigger('drop', { dataTransfer }); cy.get('[data-testid=discover-slider-edit-mode]') .eq(1) .trigger('dragend', { dataTransfer }); cy.get('[data-testid=discover-slider-edit-mode]') .eq(1) .should('contain', 'Recent Requests'); cy.get('[data-testid=discover-customize-submit').click(); cy.wait('@getDiscoverSliders'); }); it('can create a new discover option and remove it', () => { cy.visit('/'); cy.intercept('/api/v1/settings/discover/*').as('discoverSlider'); cy.intercept('/api/v1/search/keyword*').as('searchKeyword'); cy.get('[data-testid=discover-start-editing]').click(); const sliderTitle = 'Custom Keyword Slider'; cy.get('#sliderType').select('TMDB Movie Keyword'); cy.get('#title').type(sliderTitle); // First confirm that an invalid keyword doesn't allow us to submit anything cy.get('#data').type('invalidkeyword{enter}', { delay: 100 }); cy.wait('@searchKeyword'); cy.get('[data-testid=create-discover-option-form]') .find('button') .should('be.disabled'); cy.get('#data').clear(); cy.get('#data').type('christmas{enter}', { delay: 100 }); // Confirming we have some results cy.contains('.slider-header', sliderTitle) .next('[data-testid=media-slider]') .find('[data-testid=title-card]'); cy.get('[data-testid=create-discover-option-form]').submit(); cy.wait('@discoverSlider'); cy.wait('@getDiscoverSliders'); cy.wait(1000); cy.get('[data-testid=discover-slider-edit-mode]') .first() .should('contain', sliderTitle); // Make sure its still there even if we reload cy.reload(); cy.get('[data-testid=discover-start-editing]').click(); cy.get('[data-testid=discover-slider-edit-mode]') .first() .should('contain', sliderTitle); // Verify it's not rendering on our discover page (its still disabled!) cy.visit('/'); cy.get('.slider-header').should('not.contain', sliderTitle); cy.get('[data-testid=discover-start-editing]').click(); // Enable it, and check again cy.get('[data-testid=discover-slider-edit-mode]') .first() .find('[role="checkbox"]') .click(); cy.get('[data-testid=discover-customize-submit').click(); cy.wait('@getDiscoverSliders'); cy.visit('/'); cy.contains('.slider-header', sliderTitle) .next('[data-testid=media-slider]') .find('[data-testid=title-card]'); cy.get('[data-testid=discover-start-editing]').click(); // let's delete it and confirm its deleted. cy.get('[data-testid=discover-slider-edit-mode]') .first() .find('[data-testid=discover-slider-remove-button]') .click(); cy.wait('@discoverSlider'); cy.wait('@getDiscoverSliders'); cy.wait(1000); cy.get('[data-testid=discover-slider-edit-mode]') .first() .should('not.contain', sliderTitle); }); }); ================================================ FILE: cypress/e2e/settings/general-settings.cy.ts ================================================ describe('General Settings', () => { beforeEach(() => { cy.loginAsAdmin(); }); it('opens the settings page from the home page', () => { cy.visit('/'); cy.get('[data-testid=sidebar-toggle]').click(); cy.get('[data-testid=sidebar-menu-settings-mobile]').click(); cy.get('.heading').should('contain', 'General Settings'); }); it('modifies setting that requires restart', () => { cy.visit('/settings/network'); cy.get('#trustProxy').click(); cy.get('[data-testid=settings-network-form]').submit(); cy.get('[data-testid=modal-title]').should( 'contain', 'Server Restart Required' ); cy.get('[data-testid=modal-ok-button]').click(); cy.get('[data-testid=modal-title]').should('not.exist'); cy.get('[type=checkbox]#trustProxy').click(); cy.get('[data-testid=settings-network-form]').submit(); cy.get('[data-testid=modal-title]').should('not.exist'); }); }); ================================================ FILE: cypress/e2e/tv-details.cy.ts ================================================ describe('TV Details', () => { it('loads a tv details page', () => { cy.loginAsAdmin(); // Try to load stranger things cy.visit('/tv/66732'); cy.get('[data-testid=media-title]').should( 'contain', 'Stranger Things (2016)' ); }); it('shows seasons and expands episodes', () => { cy.loginAsAdmin(); // Try to load stranger things cy.visit('/tv/66732'); // intercept request for season info cy.intercept('/api/v1/tv/66732/season/4').as('season4'); cy.contains('Season 4').should('be.visible').scrollIntoView().click(); cy.wait('@season4'); cy.contains('Chapter Nine').should('be.visible'); }); }); ================================================ FILE: cypress/e2e/user/auto-request-settings.cy.ts ================================================ const visitUserEditPage = (email: string): void => { cy.visit('/users'); cy.contains('[data-testid=user-list-row]', email).contains('Edit').click(); }; describe('Auto Request Settings', () => { beforeEach(() => { cy.loginAsAdmin(); }); it('should not see watchlist sync settings on an account without permissions', () => { visitUserEditPage(Cypress.env('USER_EMAIL')); cy.contains('Auto-Request Movies').should('not.exist'); cy.contains('Auto-Request Series').should('not.exist'); }); it('should see watchlist sync settings on an admin account', () => { visitUserEditPage(Cypress.env('ADMIN_EMAIL')); cy.contains('Auto-Request Movies').should('exist'); cy.contains('Auto-Request Series').should('exist'); }); it('should see auto-request settings after being given permission', () => { visitUserEditPage(Cypress.env('USER_EMAIL')); cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click(); cy.get('#autorequest').should('not.be.checked').click(); cy.intercept('/api/v1/user/*/settings/permissions').as('userPermissions'); cy.contains('Save Changes').click(); cy.wait('@userPermissions'); cy.reload(); cy.get('#autorequest').should('be.checked'); cy.get('#autorequestmovies').should('be.checked'); cy.get('#autorequesttv').should('be.checked'); cy.get('[data-testid=settings-nav-desktop').contains('General').click(); cy.contains('Auto-Request Movies').should('exist'); cy.contains('Auto-Request Series').should('exist'); cy.get('#watchlistSyncMovies').should('not.be.checked').click(); cy.get('#watchlistSyncTv').should('not.be.checked').click(); cy.intercept('/api/v1/user/*/settings/main').as('userMain'); cy.contains('Save Changes').click(); cy.wait('@userMain'); cy.reload(); cy.get('#watchlistSyncMovies').should('be.checked').click(); cy.get('#watchlistSyncTv').should('be.checked').click(); cy.contains('Save Changes').click(); cy.wait('@userMain'); cy.get('[data-testid=settings-nav-desktop').contains('Permissions').click(); cy.get('#autorequest').should('be.checked').click(); cy.contains('Save Changes').click(); }); }); ================================================ FILE: cypress/e2e/user/profile.cy.ts ================================================ describe('User Profile', () => { beforeEach(() => { cy.loginAsAdmin(); }); it('opens user profile page from the home page', () => { cy.visit('/'); cy.get('[data-testid=user-menu]').click(); cy.get('[data-testid=user-menu-profile]').click(); cy.get('h1').should('contain', Cypress.env('ADMIN_EMAIL')); }); it('loads plex watchlist', () => { cy.intercept('/api/v1/user/[0-9]*/watchlist', { fixture: 'watchlist.json', }).as('getWatchlist'); // Wait for one of the watchlist movies to resolve cy.intercept('/api/v1/movie/361743').as('getTmdbMovie'); cy.visit('/profile'); cy.wait('@getWatchlist'); const sliderHeader = cy.contains('.slider-header', 'Plex Watchlist'); sliderHeader.scrollIntoView(); cy.wait('@getTmdbMovie'); // Wait a little longer to make sure the movie component reloaded cy.wait(500); sliderHeader .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .trigger('mouseover') .find('[data-testid=title-card-title]') .invoke('text') .then((text) => { cy.contains('.slider-header', 'Plex Watchlist') .next('[data-testid=media-slider]') .find('[data-testid=title-card]') .first() .click(); cy.get('[data-testid=media-title]').should('contain', text); }); }); }); ================================================ FILE: cypress/e2e/user/user-list.cy.ts ================================================ const testUser = { username: 'Test User', emailAddress: 'test@seeerr.dev', password: 'test1234', }; describe('User List', () => { beforeEach(() => { cy.loginAsAdmin(); }); it('opens the user list from the home page', () => { cy.visit('/'); cy.get('[data-testid=sidebar-toggle]').click(); cy.get('[data-testid=sidebar-menu-users-mobile]').click(); cy.get('[data-testid=page-header]').should('contain', 'User List'); }); it('can find the admin user and friend user in the user list', () => { cy.visit('/users'); cy.get('[data-testid=user-list-row]').contains(Cypress.env('ADMIN_EMAIL')); cy.get('[data-testid=user-list-row]').contains(Cypress.env('USER_EMAIL')); }); it('can create a local user', () => { cy.visit('/users'); cy.contains('Create Local User').click(); cy.get('[data-testid=modal-title]').should('contain', 'Create Local User'); cy.get('#username').type(testUser.username); cy.get('#email').type(testUser.emailAddress); cy.get('#password').type(testUser.password); cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user'); cy.get('[data-testid=modal-ok-button]').click(); cy.wait('@user'); // Wait a little longer for the user list to fully re-render cy.wait(1000); cy.get('[data-testid=user-list-row]').contains(testUser.emailAddress); }); it('can delete the created local test user', () => { cy.visit('/users'); cy.contains('[data-testid=user-list-row]', testUser.emailAddress) .contains('Delete') .click(); cy.get('[data-testid=modal-title]').should('contain', `Delete User`); cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user'); cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click(); cy.wait('@user'); cy.wait(1000); cy.get('[data-testid=user-list-row]') .contains(testUser.emailAddress) .should('not.exist'); }); }); ================================================ FILE: cypress/fixtures/watchlist.json ================================================ { "page": 1, "totalPages": 1, "totalResults": 3, "results": [ { "ratingKey": "5d776be17a53e9001e732ab9", "title": "Top Gun: Maverick", "mediaType": "movie", "tmdbId": 361743 }, { "ratingKey": "5e16338fbc1372003ea68ab3", "title": "Nope", "mediaType": "movie", "tmdbId": 762504 }, { "ratingKey": "5f409b8452f200004161e126", "title": "Hocus Pocus 2", "mediaType": "movie", "tmdbId": 642885 } ] } ================================================ FILE: cypress/support/commands.ts ================================================ /// import 'cy-mobile-commands'; Cypress.Commands.add('login', (email, password) => { cy.session( [email, password], () => { cy.visit('/login'); cy.get('[data-testid=email]').type(email); cy.get('[data-testid=password]').type(password); cy.intercept('/api/v1/auth/local').as('localLogin'); cy.get('[data-testid=local-signin-button]').click(); cy.wait('@localLogin'); cy.url().should('contain', '/'); }, { validate() { cy.request('/api/v1/auth/me').its('status').should('eq', 200); }, } ); }); Cypress.Commands.add('loginAsAdmin', () => { cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); }); Cypress.Commands.add('loginAsUser', () => { cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD')); }); ================================================ FILE: cypress/support/e2e.ts ================================================ import './commands'; before(() => { if (Cypress.env('SEED_DATABASE')) { cy.exec('pnpm cypress:prepare'); } }); ================================================ FILE: cypress/support/index.ts ================================================ /* eslint-disable @typescript-eslint/no-namespace */ /// declare global { namespace Cypress { interface Chainable { login(email?: string, password?: string): Chainable; loginAsAdmin(): Chainable; loginAsUser(): Chainable; } } } export {}; ================================================ FILE: cypress/tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["es5", "dom"], "types": ["cypress", "node"], "resolveJsonModule": true, "esModuleInterop": true }, "include": ["**/*.ts"] } ================================================ FILE: cypress.config.ts ================================================ import { defineConfig } from 'cypress'; export default defineConfig({ projectId: 'onnqy3', e2e: { baseUrl: 'http://localhost:5055', video: true, }, env: { ADMIN_EMAIL: 'admin@seerr.dev', ADMIN_PASSWORD: 'test1234', USER_EMAIL: 'friend@seerr.dev', USER_PASSWORD: 'test1234', }, retries: { runMode: 2, openMode: 0, }, }); ================================================ FILE: docs/README.md ================================================ --- slug: / sidebar_position: 1 --- # Introduction Welcome to the Seerr Documentation. **Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**. ## Features - **Full Jellyfin/Emby/Plex integration**. Login and manage user access with Jellyfin/Emby/Plex. - **Syncs to your Jellyfin/Emby/Plex library** to show what titles you already have. - Supports Movies, Shows and Mixed Libraries. - **Integrates with Sonarr and Radarr**. With more services to come in the future. - Optionally set **Override rules** for requests to match with your defined conditions. - **Easy to use request system** allowing users to request individual seasons or movies in a friendly, clean UI. - **Simple request management UI**. Don't dig through the app to approve recent requests. - **Mobile-friendly design**, for when you need to approve requests on the go. - Granular permission system. - Localization into other languages. - Support for **PostgreSQL** and **SQLite** databases. - Support for various notification agents. - Easily **Watchlist** or **Blocklist** media. - More features to come! ## We need your help! [Seerr](https://github.com/seerr-team/seerr) is an ambitious project developers/contributors poured a lot of work into, and we still have a lot more to do. Seerr is the result of a collaborative effort between the original Overseerr project and the Jellyseerr fork, created to deliver an excellent request management solution for Plex, Jellyfin and Emby users. We value your feedback and support in identifying and fixing bugs to make Seerr even better. As an open-source project, we welcome contributions from everyone. Contribution includes building new features, patching bugs, translating the application, or even just writing documentation. If you would like to contribute, please be sure to review our [contribution guidelines](https://github.com/seerr-team/seerr/blob/develop/CONTRIBUTING.md). ================================================ FILE: docs/extending-seerr/_category_.json ================================================ { "label": "Extending Seerr", "position": 3, "link": { "type": "generated-index", "title": "Extending Seerr", "description": "Extend Seerr to your liking" } } ================================================ FILE: docs/extending-seerr/database-config.mdx ================================================ --- title: Configuring the Database (Advanced) description: Configure the database for Seerr sidebar_position: 2 --- # Configuring the Database Seerr supports SQLite and PostgreSQL. The database connection can be configured using the following environment variables: ## SQLite Options If you want to use SQLite, you can simply set the `DB_TYPE` environment variable to `sqlite`. This is the default configuration so even if you don't set any other options, SQLite will be used. ```dotenv DB_TYPE=sqlite # Which DB engine to use, either sqlite or postgres. The default is sqlite. CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config". DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false". ``` ## PostgreSQL Options :::caution When migrating Postgres from version 17 to 18 in Docker, note that the data mount point has changed. Instead of using `/var/lib/postgresql/data`, the correct mount path is now `/var/lib/postgresql`. Refer to the [PostgreSQL Docker documentation](https://hub.docker.com/_/postgres/#pgdata) to learn how to migrate or opt out of this change. ::: ### TCP Connection If your PostgreSQL server is configured to accept TCP connections, you can specify the host and port using the `DB_HOST` and `DB_PORT` environment variables. This is useful for remote connections where the server uses a network host and port. ```dotenv DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite. DB_HOST=localhost # (optional) The host (URL) of the database. The default is "localhost". DB_PORT="5432" # (optional) The port to connect to. The default is "5432". DB_USER= # (required) Username used to connect to the database. DB_PASS= # (required) Password of the user used to connect to the database. DB_NAME="seerr" # (optional) The name of the database to connect to. The default is "seerr". DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false". ``` ### Unix Socket Connection If your PostgreSQL server is configured to accept Unix socket connections, you can specify the path to the socket directory using the `DB_SOCKET_PATH` environment variable. This is useful for local connections where the server uses a Unix socket. ```dotenv DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite. DB_SOCKET_PATH="/var/run/postgresql" # (required) The path to the PostgreSQL Unix socket directory. DB_USER= # (required) Username used to connect to the database. DB_PASS= # (optional) Password of the user used to connect to the database, depending on the server's authentication configuration. DB_NAME="seerr" # (optional) The name of the database to connect to. The default is "seerr". DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false". ``` :::info **Finding Your PostgreSQL Socket Path** The PostgreSQL socket path varies by operating system and installation method: - **Ubuntu/Debian**: `/var/run/postgresql` - **CentOS/RHEL/Fedora**: `/var/run/postgresql` - **macOS (Homebrew)**: `/tmp` or `/opt/homebrew/var/postgresql` - **macOS (Postgres.app)**: `/tmp` - **Windows**: Not applicable (uses TCP connections) You can find your socket path by running: ```bash # Find PostgreSQL socket directory find /tmp /var/run /run -name ".s.PGSQL.*" 2>/dev/null | head -1 | xargs dirname # Or check PostgreSQL configuration sudo -u postgres psql -c "SHOW unix_socket_directories;" ``` ::: ### SSL configuration The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence. ```dotenv DB_USE_SSL="false" # (optional) Whether to enable ssl for database connection. This must be "true" to use the other ssl options. The default is "false". DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections with unverifiable certificates i.e. self-signed certificates without providing the below settings. The default is "true". DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "". DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "". DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "". DB_SSL_KEY_FILE= # (optional) Path to the private key for the connection in PEM format. The default is "". DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "". DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "". ``` --- ### Migrating from SQLite to PostgreSQL 1. Set up your PostgreSQL database and configure Seerr to use it 2. Run Seerr to create the tables in the PostgreSQL database 3. Stop Seerr 4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database: :::info Edit the postgres connection string (without the \{\{ and \}\} brackets) to match your setup. If you don't have or don't want to use docker, you can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below. ::: :::caution The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue. ::: import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; **Recommended method**: Use the pgloader container even for standalone Seerr installations. This avoids building from source and ensures compatibility. ```bash # For standalone installations (no Docker network needed) docker run --rm \ -v /path/to/your/config/db.sqlite3:/db.sqlite3:ro \ ghcr.io/ralgar/pgloader:pr-1531 \ pgloader --with "quote identifiers" --with "data only" \ /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}} ``` **For Docker Compose setups**: Add the network parameter if your PostgreSQL is also in a container: ```bash docker run --rm \ --network your-seerr-network \ -v /path/to/your/config/db.sqlite3:/db.sqlite3:ro \ ghcr.io/ralgar/pgloader:pr-1531 \ pgloader --with "quote identifiers" --with "data only" \ /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}} ``` For users who prefer not to use Docker or need a custom build: ```bash # Clone the repository and checkout the working version git clone https://github.com/dimitri/pgloader.git cd pgloader git fetch origin pull/1531/head:pr-1531 git checkout pr-1531 # Follow the official installation instructions # See: https://github.com/dimitri/pgloader/blob/master/INSTALL.md ``` :::info **Building pgloader from source requires following the complete installation process outlined in the [official pgloader INSTALL.md](https://github.com/dimitri/pgloader/blob/master/INSTALL.md).** Please refer to the official documentation for detailed, up-to-date installation instructions. ::: Once pgloader is built, run the migration: ```bash # Run migration (adjust path to your config directory) ./pgloader --with "quote identifiers" --with "data only" \ /path/to/your/config/db.sqlite3 \ postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}} ``` 5. Start Seerr ================================================ FILE: docs/extending-seerr/reverse-proxy.mdx ================================================ --- title: Reverse Proxy description: Configure a reverse proxy for Seerr. sidebar_position: 1 --- # Reverse Proxy :::warning Base URLs cannot be configured in Seerr. With this limitation, only subdomain configurations are supported. A Nginx subfolder workaround configuration is provided below, but it is not officially supported. ::: ## Nginx import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Add the following configuration to a new file `/etc/nginx/sites-available/seerr.example.com.conf`: ```nginx server { listen 80; server_name seerr.example.com; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name seerr.example.com; ssl_certificate /etc/letsencrypt/live/seerr.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/seerr.example.com/privkey.pem; proxy_set_header Referer $http_referer; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-Port $remote_port; proxy_set_header X-Forwarded-Host $host:$remote_port; proxy_set_header X-Forwarded-Server $host; proxy_set_header X-Forwarded-Port $remote_port; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Ssl on; location / { proxy_pass http://127.0.0.1:5055; } } ``` Then, create a symlink to `/etc/nginx/sites-enabled`: ```bash sudo ln -s /etc/nginx/sites-available/seerr.example.com.conf /etc/nginx/sites-enabled/seerr.example.com.conf ``` :::warning This Nginx subfolder reverse proxy is an unsupported workaround, and only provided as an example. The filters may stop working when Seerr is updated. If you encounter any issues with Seerr while using this workaround, we may ask you to try to reproduce the problem without the Nginx proxy. ::: Add the following location block to your existing `nginx.conf` file. ```nginx location ^~ /seerr { set $app 'seerr'; # Remove /seerr path to pass to the app rewrite ^/seerr/?(.*)$ /$1 break; proxy_pass http://127.0.0.1:5055; # NO TRAILING SLASH # Redirect location headers proxy_redirect ^ /$app; proxy_redirect /setup /$app/setup; proxy_redirect /login /$app/login; # Sub filters to replace hardcoded paths proxy_set_header Accept-Encoding ""; sub_filter_once off; sub_filter_types *; sub_filter 'href="/"' 'href="/$app"'; sub_filter 'href="/login"' 'href="/$app/login"'; sub_filter 'href:"/"' 'href:"/$app"'; sub_filter '\/_next' '\/$app\/_next'; sub_filter '/_next' '/$app/_next'; sub_filter '/api/v1' '/$app/api/v1'; sub_filter '/login/plex/loading' '/$app/login/plex/loading'; sub_filter '/images/' '/$app/images/'; sub_filter '/imageproxy/' '/$app/imageproxy/'; sub_filter '/avatarproxy/' '/$app/avatarproxy/'; sub_filter '/android-' '/$app/android-'; sub_filter '/apple-' '/$app/apple-'; sub_filter '/favicon' '/$app/favicon'; sub_filter '/logo_' '/$app/logo_'; sub_filter '/site.webmanifest' '/$app/site.webmanifest'; } ``` A sample proxy configuration is included in [SWAG (Secure Web Application Gateway)](https://github.com/linuxserver/docker-swag). However, this page is still the only source of truth, so the SWAG sample configuration is not guaranteed to be up-to-date. If you find an inconsistency, please [report it to the LinuxServer team](https://github.com/linuxserver/reverse-proxy-confs/issues/new) or [submit a pull request to update it](https://github.com/linuxserver/reverse-proxy-confs/pulls). To use the bundled configuration file, simply rename `seerr.subdomain.conf.sample` in the `proxy-confs` folder to `seerr.subdomain.conf`. Alternatively, you can create a new file `seerr.subdomain.conf` in `proxy-confs` with the following configuration: ```nginx server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name seerr.*; include /config/nginx/ssl.conf; client_max_body_size 0; location / { include /config/nginx/proxy.conf; resolver 127.0.0.11 valid=30s; set $upstream_app seerr; set $upstream_port 5055; set $upstream_proto http; proxy_pass $upstream_proto://$upstream_app:$upstream_port; } } ``` Add a new proxy host with the following settings: ### Details - **Domain Names:** Your desired external Seerr hostname; e.g., `seerr.example.com` - **Scheme:** `http` - **Forward Hostname / IP:** Internal Seerr hostname or IP - **Forward Port:** `5055` - **Cache Assets:** yes - **Block Common Exploits:** yes ### SSL - **SSL Certificate:** Select one of the options; if you are not sure, pick “Request a new SSL Certificate” - **Force SSL:** yes - **HTTP/2 Support:** yes Then, click “Save” and “Apply Changes”. ## Caddy (v2) Create a Caddyfile with the following content: ```caddyfile seerr.example.com { reverse_proxy http://127.0.0.1:5055 } ``` Deploy the Caddyfile by running: ```bash sudo caddy run --config /path/to/Caddyfile ``` Verify by visiting https://seerr.example.com in your browser. :::note Caddy will automatically obtain and renew SSL certificates for your domain. ::: ## Traefik (v2) Add the following labels to the Seerr service in your `compose.yaml` file: ```yaml labels: - 'traefik.enable=true' ## HTTP Routers - 'traefik.http.routers.seerr-rtr.entrypoints=https' - 'traefik.http.routers.seerr-rtr.rule=Host(`seerr.domain.com`)' - 'traefik.http.routers.seerr-rtr.tls=true' ## HTTP Services - 'traefik.http.routers.seerr-rtr.service=seerr-svc' - 'traefik.http.services.seerr-svc.loadbalancer.server.port=5055' ``` For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/). ## Apache2 HTTP Server Add the following Location block to your existing Server configuration. ```apache # Seerr ProxyPreserveHost On ProxyPass / http://localhost:5055 retry=0 connectiontimeout=5 timeout=30 keepalive=on ProxyPassReverse http://localhost:5055 / RequestHeader set Connection "" ``` :::warning This Apache2 subfolder reverse proxy is an unsupported workaround, and only provided as an example. The filters may stop working when Seerr is updated. If you encounter any issues with Seerr while using this workaround, we may ask you to try to reproduce the problem without the Apache2 proxy. ::: Add the following Location block to your existing Server configuration. ```apache # Seerr # We will use "/seerr" as subfolder # You can replace it with any that you like ProxyPreserveHost On ProxyPass http://localhost:5055 retry=0 connectiontimeout=5 timeout=30 keepalive=on ProxyPassReverse http://localhost:5055 RequestHeader set Connection "" # Header update, to support subfolder # Please Replace "FQDN" with your domain Header edit location ^/login https://FQDN/seerr/login Header edit location ^/setup https://FQDN/seerr/setup AddOutputFilterByType INFLATE;SUBSTITUTE text/html application/javascript application/json SubstituteMaxLineLength 2000K # This is HTML and JS update # Please update "/seerr" if needed Substitute "s|href=\"|href=\"/seerr|inq" Substitute "s|src=\"|src=\"/seerr|inq" Substitute "s|/api/|/seerr/api/|inq" Substitute "s|\"/_next/|\"/seerr/_next/|inq" # This is JSON update Substitute "s|\"/avatarproxy/|\"/seerr/avatarproxy/|inq" ``` ## HAProxy (v3) :::warning This is a third-party documentation maintained by the community. We can't provide support for this setup and are unable to test it. ::: Add the following frontend and backend configurations for your seerr instance: ```haproxy frontend seerr-frontend bind 0.0.0.0:80 bind 0.0.0.0:443 ssl crt /etc/ssl/private/seerr.example.com.pem mode http log global option httplog option http-keep-alive http-request set-header X-Real-IP %[src] option forwardfor acl seerr hdr(host) -i seerr.example.com redirect scheme https code 301 if !{ ssl_fc } use_backend seerr-backend if seerr backend seerr-backend mode http log global option httplog http-response set-header Strict-Transport-Security max-age=15552000 option httpchk GET /api/v1/status timeout connect 30000 timeout server 30000 retries 3 server seerr 127.0.0.1:5055 check inter 1000 ``` ================================================ FILE: docs/getting-started/_category_.json ================================================ { "label": "Getting Started", "position": 2 } ================================================ FILE: docs/getting-started/buildfromsource.mdx ================================================ --- title: Build From Source (Advanced) description: Install Seerr by building from source sidebar_position: 2 --- # Build from Source (Advanced) :::warning This method is not recommended for most users. It is intended for advanced users who are familiar with managing their own server infrastructure. Refer to [Configuring Databases](/extending-seerr/database-config#postgresql-options) for details on how to configure your database. ::: import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ### Prerequisites - [Node.js 22.x](https://nodejs.org/en/download/) - [Pnpm 10.x](https://pnpm.io/installation) - [Git](https://git-scm.com/downloads) ## Unix (Linux, macOS) ### Installation 1. Assuming you want the working directory to be `/opt/seerr`, create the directory and navigate to it: ```bash sudo mkdir -p /opt/seerr && cd /opt/seerr ``` 2. Clone the Seerr repository and checkout the main branch: ```bash git clone https://github.com/seerr-team/seerr.git . git checkout main ``` 3. Install the dependencies: ```bash CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile ``` 4. Build the project: ```bash pnpm build ``` 5. Start Seerr: ```bash pnpm start ``` :::info You can now access Seerr by visiting `http://localhost:5055` in your web browser. ::: #### Extending the installation To run seerr as a systemd service: 1. create the environment file at `/etc/seerr/seerr.conf`: ```bash ## Seerr's default port is 5055, if you want to use both, change this. ## specify on which port to listen PORT=5055 ## specify on which interface to listen, by default seerr listens on all interfaces #HOST=127.0.0.1 ## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only) # FORCE_IPV4_FIRST=true ``` 2. Then run the following commands: ```bash which node ``` Copy the path to node, it should be something like `/usr/bin/node`. 3. Create the systemd service file at `/etc/systemd/system/seerr.service`, using either `sudo systemctl edit seerr` or `sudo nano /etc/systemd/system/seerr.service`: ```bash [Unit] Description=Seerr Service Wants=network-online.target After=network-online.target [Service] EnvironmentFile=/etc/seerr/seerr.conf Environment=NODE_ENV=production Type=exec Restart=on-failure WorkingDirectory=/opt/seerr ExecStart=/usr/bin/node dist/index.js [Install] WantedBy=multi-user.target ``` :::note If you are using a different path to node, replace `/usr/bin/node` with the path to node. ::: 4. Enable and start the service: ```bash sudo systemctl enable seerr sudo systemctl start seerr ``` To run seerr as a launchd service: 1. Find the path to node: ```bash which node ``` Copy the path to node, it should be something like `/usr/local/bin/node`. 2. Create a launchd plist file at `~/Library/LaunchAgents/com.seerr.plist`: ```xml Label com.seerr ProgramArguments /usr/local/bin/node /opt/seerr/dist/index.js WorkingDirectory /opt/seerr EnvironmentVariables NODE_ENV production PORT 5055 RunAtLoad KeepAlive ``` :::note If you are using a different path to node, replace `/usr/local/bin/node` with the path to node. ::: 3. Load the service: ```bash sudo launchctl load ~/Library/LaunchAgents/com.seerr.plist ``` 3. Start the service: ```bash sudo launchctl start com.seerr ``` 4. To ensure the service starts on boot, run the following command: ```bash sudo lauchctl load ``` To run seerr as a PM2 service: 1. Install PM2: ```bash npm install -g pm2 ``` 2. Start seerr with PM2: ```bash pm2 start dist/index.js --name seerr --node-args="--NODE_ENV=production" ``` 3. Save the process list: ```bash pm2 save ``` 4. Ensure PM2 starts on boot: ```bash pm2 startup ``` **Managing the service** - To start the service: ```powershell pm2 start seerr ``` - To stop the service: ```powershell pm2 stop seerr ``` - To restart the service: ```powershell pm2 restart seerr ``` - To view the logs: ```powershell pm2 logs seerr ``` - To view the status: ```powershell pm2 status seerr ``` ## Windows ### Installation 1. Assuming you want the working directory to be `C:\seerr`, create the directory and navigate to it: ```powershell mkdir C:\seerr cd C:\seerr ``` 2. Clone the Seerr repository and checkout the main branch: ```powershell git clone https://github.com/seerr-team/seerr.git . git checkout main ``` 3. Install the dependencies: ```powershell npm install -g win-node-env set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile ``` 4. Build the project: ```powershell pnpm build ``` 5. Start Seerr: ```powershell pnpm start ``` :::tip You can add the environment variables to a `.env` file in the Seerr directory. ::: :::info You can now access Seerr by visiting `http://localhost:5055` in your web browser. ::: #### Extending the installation To run seerr as a bat script: 1. Create a file named `start-seerr.bat` in the seerr directory: ```batch @echo off set PORT=5055 set NODE_ENV=production node dist/index.js ``` 2. Create a task in Task Scheduler: - Open Task Scheduler - Click on "Create Basic Task" - Name the task "Seerr" - Set the trigger to "When the computer starts" - Set the action to "Start a program" - Set the program/script to the path of the `start-seerr.bat` file - Set the "Start in" to the seerr directory. - Click "Finish" Now, Seerr will start when the computer boots up in the background. To run seerr as a service: 1. Download the [Non-Sucking Service Manager](https://nssm.cc/download) 2. Install NSSM: ```powershell nssm install Seerr "C:\Program Files\nodejs\node.exe" "C:\seerr\dist\index.js" nssm set Seerr AppDirectory "C:\seerr" nssm set Seerr AppEnvironmentExtra NODE_ENV=production ``` 3. Start the service: ```powershell nssm start Seerr ``` 4. To ensure the service starts on boot, run the following command: ```powershell nssm set Seerr Start SERVICE_AUTO_START ``` To run seerr as a PM2 service: 1. Install PM2: ```powershell npm install -g pm2 ``` 2. Start seerr with PM2: ```powershell pm2 start dist/index.js --name seerr --node-args="--NODE_ENV=production" ``` 3. Save the process list: ```powershell pm2 save ``` 4. Ensure PM2 starts on boot: ```powershell pm2 startup ``` ##### Managing the service - To start the service: ```powershell pm2 start seerr ``` - To stop the service: ```powershell pm2 stop seerr ``` - To restart the service: ```powershell pm2 restart seerr ``` - To view the logs: ```powershell pm2 logs seerr ``` - To view the status: ```powershell pm2 status seerr ``` ### Updating To update Seerr, navigate to the Seerr directory and run the following commands: ```bash git pull ``` Then, follow the steps in the installation section to rebuild and restart Seerr. ================================================ FILE: docs/getting-started/docker.mdx ================================================ --- title: Docker (Recommended) description: Install Seerr using Docker sidebar_position: 1 --- # Docker :::info This is the recommended method for most users. Details on how to install Docker can be found on the [official Docker website](https://docs.docker.com/get-docker/). Refer to [Configuring Databases](/extending-seerr/database-config#postgresql-options) for details on how to configure your database. ::: :::info An alternative Docker image is available on Docker Hub for this project. You can find it at [Docker Hub Repository Link](https://hub.docker.com/r/seerr/seerr) Our Docker images are available with the following tags: - `latest`: Always points to the most recent stable release. - Version tags (e.g., `v3.0.0`): For specific stable versions. - `develop`: Rolling release/nightly builds for using the latest changes (use with caution). ::: :::info All official Seerr images are cryptographically signed and include a verified [Software Bill of Materials (SBOM)](https://cyclonedx.org/). To confirm that the container image you are using is authentic and unmodified, please refer to the [Verifying Signed Artifacts](/using-seerr/advanced/verifying-signed-artifacts#verifying-signed-images) guide. ::: ## Unix (Linux, macOS) :::warning Be sure to replace `/path/to/appdata/config` in the below examples with a valid host directory path. If this volume mount is not configured correctly, your Seerr settings/data will not be persisted when the container is recreated (e.g., when updating the image or rebooting your machine). The `TZ` environment variable value should also be set to the [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) of your time zone! ::: import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; For details on the Docker CLI, please [review the official `docker run` documentation](https://docs.docker.com/engine/reference/run/). #### Installation: ```bash # Create the appdata folder mkdir /path/to/appdata/config # Chown the folder as the container runs as the `node` user (UID 1000). chown -R 1000:1000 /path/to/appdata/config ``` ```bash docker run -d \ --name seerr \ --init \ -e LOG_LEVEL=debug \ -e TZ=Asia/Tashkent \ -e PORT=5055 \ -p 5055:5055 \ -v /path/to/appdata/config:/app/config \ --restart unless-stopped \ --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \ --health-start-period 20s \ --health-timeout 3s \ --health-interval 15s \ --health-retries 3 \ ghcr.io/seerr-team/seerr:latest ``` The argument `-e PORT=5055` is optional. To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command. #### Updating: Stop and remove the existing container: ```bash docker stop seerr && docker rm seerr ``` Pull the latest image: ```bash docker pull ghcr.io/seerr-team/seerr:latest ``` Finally, run the container with the same parameters originally used to create the container: ```bash docker run -d ... ``` :::tip You may alternatively use a third-party updating mechanism, such as [Watchtower](https://github.com/containrrr/watchtower) or [Ouroboros](https://github.com/pyouroboros/ouroboros), to keep Seerr up-to-date automatically. You could also use [diun](https://github.com/crazy-max/diun) to receive notifications when a new image is available. ::: For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/). #### Installation: Define the `seerr` service in your `compose.yaml` as follows: ```yaml --- services: seerr: image: ghcr.io/seerr-team/seerr:latest init: true container_name: seerr environment: - LOG_LEVEL=debug - TZ=Asia/Tashkent - PORT=5055 #optional ports: - 5055:5055 volumes: - /path/to/appdata/config:/app/config healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1 start_period: 20s timeout: 3s interval: 15s retries: 3 restart: unless-stopped ``` ```bash # Create the appdata folder mkdir /path/to/appdata/config # Chown the folder as the container runs as the `node` user (UID 1000). chown -R 1000:1000 /path/to/appdata/config ``` Then, start all services defined in the Compose file: ```bash docker compose up -d ``` #### Updating: Pull the latest image: ```bash docker compose pull seerr ``` Then, restart all services defined in the Compose file: ```bash docker compose up -d ``` ## Windows Please refer to the [Docker Desktop for Windows user manual](https://docs.docker.com/docker-for-windows/) for details on how to install Docker on Windows. There is no need to install a Linux distro if using named volumes like in the example below. :::warning **WSL2 will need to be installed to prevent DB corruption!** Please see the [Docker Desktop WSL 2 backend documentation](https://docs.docker.com/docker-for-windows/wsl/) for instructions on how to enable WSL2. The commands below will only work with WSL2 installed! ::: First, create a volume to store the configuration data for Seerr using using either the Docker CLI: ```bash docker volume create seerr-data ``` or the Docker Desktop app: 1. Open the Docker Desktop app 2. Head to the Volumes tab 3. Click on the "New Volume" button near the top right 4. Enter a name for the volume (example: `seerr-data`) and hit "Create" Then, create and start the Seerr container: ```powershell docker run -d ` --name seerr ` --init ` -e LOG_LEVEL=debug ` -e TZ=Asia/Tashkent ` -e PORT=5055 ` -p 5055:5055 ` -v seerr-data:/app/config ` --restart unless-stopped ` --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" ` --health-start-period 20s ` --health-timeout 3s ` --health-interval 15s ` --health-retries 3 ` ghcr.io/seerr-team/seerr:latest ``` The argument `-e PORT=5055` is optional. #### Updating: Stop and remove the existing container: ```powershell docker stop seerr; docker rm seerr ``` Pull the latest image: ```powershell docker pull ghcr.io/seerr-team/seerr:latest ``` Finally, run the container with the same parameters originally used to create the container: ```powershell docker run -d ... ``` ```batch docker run -d ^ --name seerr ^ --init ^ -e LOG_LEVEL=debug ^ -e TZ=Asia/Tashkent ^ -e PORT=5055 ^ -p 5055:5055 ^ -v seerr-data:/app/config ^ --restart unless-stopped ^ --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" ^ --health-start-period 20s ^ --health-timeout 3s ^ --health-interval 15s ^ --health-retries 3 ^ ghcr.io/seerr-team/seerr:latest ``` The argument `-e PORT=5055` is optional. #### Updating: Stop and remove the existing container: ```batch docker stop seerr && docker rm seerr ``` Pull the latest image: ```batch docker pull ghcr.io/seerr-team/seerr:latest ``` Finally, run the container with the same parameters originally used to create the container: ```batch docker run -d ... ``` ```yaml --- services: seerr: image: ghcr.io/seerr-team/seerr:latest init: true container_name: seerr environment: - LOG_LEVEL=debug - TZ=Asia/Tashkent ports: - 5055:5055 volumes: - seerr-data:/app/config healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1 start_period: 20s timeout: 3s interval: 15s retries: 3 restart: unless-stopped volumes: seerr-data: external: true ``` #### Updating: Pull the latest image: ```bash docker compose pull seerr ``` Then, restart all services defined in the Compose file: ```bash docker compose up -d ``` To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\seerr-data\_data` using File Explorer. :::info Docker on Windows works differently than it does on Linux; it runs Docker inside of a stripped-down Linux VM. Volume mounts are exposed to Docker inside this VM via SMB mounts. While this is fine for media, it is unacceptable for the `/app/config` directory because SMB does not support file locking. This will eventually corrupt your database, which can lead to slow behavior and crashes. **If you must run Docker on Windows, you should put the `/app/config` directory mount inside the VM and not on the Windows host.** (This also applies to other containers with SQLite databases.) Named volumes, like in the example commands above, are automatically mounted inside the VM. Therefore the warning on the setup about the `/app/config` folder being incorrectly mounted page should be ignored. ::: ================================================ FILE: docs/getting-started/index.mdx ================================================ --- title: Getting Started --- import DocCardList from '@theme/DocCardList'; :::info After running Seerr for the first time, configure it by visiting the web UI at `http://[address]:5055` and completing the setup steps. ::: ================================================ FILE: docs/getting-started/kubernetes.mdx ================================================ --- title: Kubernetes (Advanced) description: Install Seerr in Kubernetes sidebar_position: 3 --- # Kubernetes :::warning This method is not recommended for most users. It is intended for advanced users who are using Kubernetes. ::: :::info All official Seerr charts are cryptographically signed and include a verified [Software Bill of Materials (SBOM)](https://cyclonedx.org/). To confirm that the chart you are using is authentic and unmodified, please refer to the [Verifying Signed Artifacts](/using-seerr/advanced/verifying-signed-artifacts#verifying-signed-helm-charts) guide. ::: ## Installation ```console helm install seerr oci://ghcr.io/seerr-team/seerr/seerr-chart ``` Helm values can be found in the Seerr repository under [charts/seerr-chart/README.md](https://github.com/seerr-team/seerr/tree/develop/charts/seerr-chart). Verify the signature with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) (replace [tag], with the TAG you want to verify) : ```console cosign verify ghcr.io/seerr-team/seerr/seerr-chart:[tag] --certificate-identity=https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main --certificate-oidc-issuer=https://token.actions.githubusercontent.com ``` ================================================ FILE: docs/getting-started/third-parties/aur.mdx ================================================ --- title: AUR (Advanced) description: Install Seerr using the Arch User Repository sidebar_position: 2 --- # AUR :::warning Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages. ::: :::warning This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution. ::: ## Installation To install Seerr from the AUR, you can use an AUR helper like `yay` or `paru`: import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ```bash yay -S seerr ``` ```bash paru -S seerr ``` :::info After installing Seerr, configure it by visiting the web UI at `http://[address]:5055` and completing the setup steps. ::: :::tip You can find the environment file at `/etc/conf.d/seerr` and the service file at `/etc/systemd/system/seerr.service`. ::: ================================================ FILE: docs/getting-started/third-parties/index.mdx ================================================ --- title: Third-party Installation Methods --- import DocCardList from '@theme/DocCardList'; :::warning Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages. ::: :::info Want to add a third-party installation method? Contributions are welcome! Feel free to open a pull request. ::: ================================================ FILE: docs/getting-started/third-parties/nixpkg.mdx ================================================ --- title: Nix Package Manager (Advanced) description: Install Seerr using Nixpkgs sidebar_position: 1 --- import { SeerrVersion, NixpkgVersion } from '@site/src/components/SeerrVersion'; import Admonition from '@theme/Admonition'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Nix Package Manager :::danger This method has not yet been updated for Seerr and is currently a work in progress. You can follow the ongoing work on these pull requests: - https://github.com/NixOS/nixpkgs/pull/450096 - https://github.com/NixOS/nixpkgs/pull/450093 ::: ================================================ FILE: docs/getting-started/third-parties/synology.mdx ================================================ --- title: Synology (Advanced) description: Install Seerr on Synology NAS using SynoCommunity sidebar_position: 5 --- # Synology :::warning Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages. ::: :::warning This method is not recommended for most users. It is intended for advanced users who are using Synology NAS. ::: ## Prerequisites - Synology NAS running **DSM 7.2** or later - 64-bit architecture (x86_64 or ARMv8) - [SynoCommunity package source](https://synocommunity.com/) added to Package Center ## Adding the SynoCommunity Package Source If you haven't already added SynoCommunity to your Package Center: 1. Open **Package Center** in DSM 2. Click **Settings** in the top-right corner 3. Go to the **Package Sources** tab 4. Click **Add** 5. Enter the following: - **Name**: `SynoCommunity` - **Location**: `https://packages.synocommunity.com` 6. Click **OK** ## Installation 1. In **Package Center**, search for **Seerr** 2. Click **Install** 3. Follow the installation wizard prompts 4. Package Center will automatically install any required dependencies (Node.js v22) ### Access Seerr Once installed, access Seerr at: ``` http://:5055 ``` You can also click the **Open** button in Package Center or find Seerr in the DSM main menu. ## Configuration Seerr's configuration files are stored at: ``` /var/packages/seerr/var/config ``` :::info The Seerr package runs as a dedicated service user managed by DSM. No manual permission configuration is required. ::: ## Managing the Service You can start, stop, and restart Seerr from **Package Center** → Find Seerr → Use the action buttons. ## Updating When a new version is available: 1. Open **Package Center** 2. Go to **Installed** packages 3. Find **Seerr** and click **Update** if available :::tip Enable automatic updates in Package Center settings to keep Seerr up to date. ::: ## Troubleshooting ### Viewing Logs Seerr logs are located at `/var/packages/seerr/var/config/logs` and can be accessed using: - **File Browser** package (recommended for most users) - SSH (advanced users) ### Port Conflicts Seerr uses port 5055. If this port is already in use: - **Docker containers**: Remap the conflicting container to a different port - **Other packages**: The conflicting package will need to be uninstalled as Seerr's port cannot be changed SynoCommunity ensures there are no port conflicts with other SynoCommunity packages or official Synology packages. ### Package Won't Start Ensure Node.js v22 is installed and running by checking its status in **Package Center**. ## Uninstallation 1. Open **Package Center** 2. Find **Seerr** in your installed packages 3. Click **Uninstall** :::caution Uninstalling will remove the application but preserve your configuration data by default. Select "Remove data" during uninstallation if you want a complete removal. ::: ================================================ FILE: docs/getting-started/third-parties/truenas.mdx ================================================ --- title: TrueNAS (Advanced) description: Install Seerr using TrueNAS sidebar_position: 4 --- # TrueNAS :::warning Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages. ::: :::warning This method is not recommended for most users. It is intended for advanced users who are using TrueNAS distribution. ::: ## Installation Go to the 'Apps' menu, click the 'Discover Apps' button in the top right, search for 'Seerr' in the search bar, and install the app. ================================================ FILE: docs/getting-started/third-parties/unraid.mdx ================================================ --- title: Unraid (Advanced) description: Install Seerr using Unraid sidebar_position: 3 --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Unraid :::warning Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages. ::: :::warning This method is not recommended for most users. It is intended for advanced users who are using Unraid. ::: ## Template installation Several templates from Unraid Community are available right now - [Unraid Community Apps](https://unraid.net/community/apps#community-apps-iframe) In case you still want to do it manually, please follow the instructions below. ## Manual Installation Before proceeding, choose which installation method best suits your server: **Seerr Default** (`UID 1000:1000`) — Runs with the container's native `node` user. Matches the official image, but may break SMB share access, trigger "Fix Common Problems" warnings, and cause issues with backup plugins (such as CA Appdata Backup) and network integrations (such as Tailscale). **Unraid Default** (`UID 99:100`) — Runs with Unraid's native `nobody:users` ownership. Follows Unraid best practices, but overrides Seerr's built-in rootless user and may cause compatibility issues with future Seerr updates. ### 1. Create the config directory Open the Unraid terminal and run: ```bash mkdir -p /mnt/user/appdata/seerr ``` ### 2. Set folder permissions ```bash chown -R 1000:1000 /mnt/user/appdata/seerr ``` ### 3. Add the Docker container :::warning The **Extra Parameters** field is critical. You **must** add `--init --restart=unless-stopped` (see table below). Without `--init`, the container will not handle signals properly or shut down cleanly. Without `--restart=unless-stopped`, the container will not automatically restart after a reboot. To see the **Extra Parameters** field, click **Basic View** in the top-right corner of the template page to switch to the advanced editor. ::: Navigate to the **Docker** tab in Unraid and click **Add Container**. Fill in the following: | Field | Value | |---|---| | **Name** | `seerr` | | **Repository** | `ghcr.io/seerr-team/seerr:latest` | | **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` | | **WebUI** | `http://[IP]:[PORT:5055]` | | **Extra Parameters** | `--init --restart=unless-stopped` | | **Network Type** | `bridge` | | **Privileged** | `Off` | Then click **Add another Path, Port, Variable** to add: **Port:** | Field | Value | |---|---| | Container Port | `5055` | | Host Port | `5055` | | Connection Type | `TCP` | **Path:** | Field | Value | |---|---| | Container Path | `/app/config` | | Host Path | `/mnt/user/appdata/seerr` | **Variable:** | Field | Value | |---|---| | Key | `TZ` | | Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) | **Variable (optional):** | Field | Value | |---|---| | Key | `LOG_LEVEL` | | Value | `info` | Click **Apply** to create and start the container. ### 4. Access Seerr Open the WebUI at `http://:5055` and follow the setup wizard. ### 1. Create the config directory Open the Unraid terminal and run: ```bash mkdir -p /mnt/user/appdata/seerr ``` ### 2. Set folder permissions ```bash chown -R 99:100 /mnt/user/appdata/seerr ``` ### 3. Add the Docker container :::warning The **Extra Parameters** field is critical. You **must** add `--init --restart=unless-stopped --user 99:100` (see table below). Without `--init`, the container will not handle signals properly or shut down cleanly. Without `--restart=unless-stopped`, the container will not automatically restart after a reboot. The `--user 99:100` parameter runs the container process with Unraid's default UID/GID and avoids permission errors accessing your shares without changing host folder ownership. To see the **Extra Parameters** field, click **Basic View** in the top-right corner of the template page to switch to the advanced editor. ::: Navigate to the **Docker** tab in Unraid and click **Add Container**. Fill in the following: | Field | Value | |---|---| | **Name** | `seerr` | | **Repository** | `ghcr.io/seerr-team/seerr:latest` | | **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` | | **WebUI** | `http://[IP]:[PORT:5055]` | | **Extra Parameters** | `--init --restart=unless-stopped --user 99:100` | | **Network Type** | `bridge` | | **Privileged** | `Off` | Then click **Add another Path, Port, Variable** to add: **Port:** | Field | Value | |---|---| | Container Port | `5055` | | Host Port | `5055` | | Connection Type | `TCP` | **Path:** | Field | Value | |---|---| | Container Path | `/app/config` | | Host Path | `/mnt/user/appdata/seerr` | **Variable:** | Field | Value | |---|---| | Key | `TZ` | | Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) | **Variable (optional):** | Field | Value | |---|---| | Key | `LOG_LEVEL` | | Value | `info` | Click **Apply** to create and start the container. ### 4. Access Seerr Open the WebUI at `http://:5055` and follow the setup wizard. ================================================ FILE: docs/migration-guide.mdx ================================================ --- title: Migration guide --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; :::important Read our [release announcement](/blog/seerr-release) to learn what Seerr means for Jellyseerr and Overseerr users. ::: Whether you come from Overseerr or Jellyseerr, you don't need to perform any manual migration steps, your instance will automatically be migrated to Seerr. This migration will run automatically the first time you start your instance using the Seerr codebase (Docker image or source build or Kubernetes, etc.). An additional migration will happen for Overseerr users, to migrate their configuration to the new codebase. :::danger Before doing anything you should backup your existing instance so that you can rollback in case something goes wrong. See [Backups](/using-seerr/backups) for details on how to properly backup your instance. ::: :::warning Installation methods are now divided into two categories: official and third-party methods. The Seerr team is only responsible for official installation methods, while third-party methods are maintained by the community. Some methods are currently not maintained, but this does not mean they are permanently discontinued. The community may restore and support them if they choose to do so. - **Snap package:** Not maintained ::: ## Docker Refer to [Seerr Docker Documentation](/getting-started/docker), all of our examples have been updated to reflect the below change. :::info Seerr provides a secure, fully featured image with everything you need included. We sincerely appreciate the past contributions from third-party maintainers, which helped enhance this image and its capabilities. To maintain consistency and security, we encourage everyone to use the features available in the official Seerr image. If you feel something is missing, please submit a feature request—your feedback is always welcome! Our Docker images are available with the following tags: - `latest`: Always points to the most recent stable release. - Version tags (e.g., `v3.0.0`): For specific stable versions. - `develop`: Rolling release/nightly builds for using the latest changes (use with caution). ::: Changes : - Renamed all references from `overseerr` or `jellyseerr` to `seerr`. - The container image reference has been updated. - The container can now be run as a non-root user (`node` user); remove the `user` directive if you have configured it. - The container no longer provides an init process, so you must configure it by adding `init: true` for Docker Compose or `--init` for the Docker CLI. #### Config folder permissions :::info Since the container now runs as the `node` user (UID 1000), you must ensure your config folder has the correct permissions. The `node` user must have read and write access to the `/app/config` directory. If you're migrating from a previous installation, you may need to update the ownership of your config folder: ```bash docker run --rm -v /path/to/appdata/config:/data alpine chown -R 1000:1000 /data ``` This ensures the `node` user (UID 1000) owns the config directory and can read and write to it. ::: ### Unix Summary of changes : ```yaml {3-6} --- services: seerr: image: ghcr.io/seerr-team/seerr:latest init: true container_name: seerr environment: - LOG_LEVEL=debug - TZ=Asia/Tashkent - PORT=5055 #optional ports: - 5055:5055 volumes: - /path/to/appdata/config:/app/config healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1 start_period: 20s timeout: 3s interval: 15s retries: 3 restart: unless-stopped ``` ```bash {2-3,10} docker run -d \ --name seerr \ --init \ -e LOG_LEVEL=debug \ -e TZ=Asia/Tashkent \ -e PORT=5055 \ -p 5055:5055 \ -v /path/to/appdata/config:/app/config \ --restart unless-stopped \ ghcr.io/seerr-team/seerr:latest ``` ### Windows Summary of changes : ```yaml {3-6,13,23} --- services: seerr: image: ghcr.io/seerr-team/seerr:latest init: true container_name: seerr environment: - LOG_LEVEL=debug - TZ=Asia/Tashkent ports: - 5055:5055 volumes: - seerr-data:/app/config healthcheck: test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1 start_period: 20s timeout: 3s interval: 15s retries: 3 restart: unless-stopped volumes: seerr-data: external: true ``` ```powershell {2-3,8,10} docker run -d ` --name seerr ` --init ` -e LOG_LEVEL=debug ` -e TZ=Asia/Tashkent ` -e PORT=5055 ` -p 5055:5055 ` -v seerr-data:/app/config ` --restart unless-stopped ` ghcr.io/seerr-team/seerr:latest ``` ```batch {2-3,8,10} docker run -d ^ --name seerr ^ --init ^ -e LOG_LEVEL=debug ^ -e TZ=Asia/Tashkent ^ -e PORT=5055 ^ -p 5055:5055 ^ -v seerr-data:/app/config ^ --restart unless-stopped ^ ghcr.io/seerr-team/seerr:latest ``` ## Build From Source Refer to [Seerr Build From Source Documentation](/getting-started/buildfromsource), all of our examples have been updated to reflect the below change. Install from scratch by following the documentation, restore your data as described in [Backups](/using-seerr/backups), and then start Seerr. No additional steps are required. ## Kubernetes Refer to [Seerr Kubernetes Documentation](/getting-started/kubernetes), all of our examples have been updated to reflect the below change. Changes : - All references to `jellyseerr` have been renamed to `seerr` in the manifests. - The container image reference has been updated. - The default `securityContext` and `podSecurityContext` have been updated to support running the container without root permissions. Summary of changes : ```yaml image: repository: fallenbagel/jellyseerr podSecurityContext: {} securityContext: {} ``` ```yaml image: repository: seerr-team/seerr podSecurityContext: fsGroup: 1000 fsGroupChangePolicy: OnRootMismatch securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL readOnlyRootFilesystem: false runAsNonRoot: true privileged: false runAsUser: 1000 runAsGroup: 1000 seccompProfile: type: RuntimeDefault ``` ## Third-party installation methods :::warning Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages. ::: ### Nix Waiting for https://github.com/NixOS/nixpkgs/pull/450096 and https://github.com/NixOS/nixpkgs/pull/450093 ### AUR See https://aur.archlinux.org/packages/seerr ### TrueNAS Refer to [Seerr TrueNAS Documentation](/getting-started/third-parties/truenas), all of our examples have been updated to reflect the below change. **This guide describes how to migrate from Host Path storage (not ixVolume).** 1. Stop Jellyseerr/Overseerr 2. Install Seerr and use the same Host Path storage that was used by Jellyseerr/Overseerr 3. Start Seerr app 4. Delete Jellyseerr/Overseerr app **This guide describes how to migrate from ixVolume storage (not Host Path).** 1. Stop Jellyseerr/Overseerr 2. Create a dataset for Seerr If your apps normally store data under something like: ``` /mnt/storage/ ``` then create a dataset named: ``` storage/seerr ``` resulting in: ``` /mnt/storage/seerr ``` 3. Copy ixVolume Data Open System Settings → Shell, or SSH into your TrueNAS server as root and run : ```bash rsync -av /mnt/.ix-apps/app_mounts/jellyseerr/ /mnt/storage/seerr/ ``` 4. Install Seerr and use the same Host Path storage that was created before (`/mnt/storage/seerr/config` in our example) 5. Start Seerr app 6. Delete Jellyseerr/Overseerr app ### Unraid Refer to [Seerr Unraid Documentation](/getting-started/third-parties/unraid), all of our examples have been updated to reflect the below change. Seerr will automatically migrate your existing Overseerr or Jellyseerr data on first startup. No manual database migration is needed. **1. Stop the existing container** In the Unraid **Docker** tab, stop your Overseerr (or Jellyseerr) container. **⚠️ Do not remove the container or delete the appdata folder yet ⚠️** **2. Copy existing data to Seerr appdata** Open the Unraid terminal and copy your existing appdata folder into the new Seerr appdata directory: ```bash cp -a /mnt/user/appdata/overseerr /mnt/user/appdata/seerr ``` *(For Jellyseerr users, replace `overseerr` with `jellyseerr` in the paths).* **3. Set permissions and install Seerr** Follow the [Unraid Installation Guide](/getting-started/third-parties/unraid#2-set-folder-permissions), **starting from step 2** — this covers setting the correct folder permissions and adding the Docker container. The guide offers two permission methods (**Seerr Default** and **Unraid Default**), each with trade-offs — read the descriptions before choosing. **4. Start the new Seerr app** Start the newly created Seerr container. Check the container logs to confirm the automatic migration completed successfully. **5. Remove the old app** Once you have confirmed Seerr is working properly and your data has successfully migrated, you can safely **Remove** the old Overseerr (or Jellyseerr) container from Unraid. ================================================ FILE: docs/troubleshooting.mdx ================================================ --- title: Troubleshooting --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ## [TMDB] failed to retrieve/fetch XXX ### Option 1: Change your DNS servers This error often comes from your Internet Service Provider (ISP) blocking TMDB API. The ISP may block the DNS resolution to the TMDB API hostname. To fix this, you can change your DNS servers to a public DNS service like Google's DNS or Cloudflare's DNS: Add the following to your `docker run` command to use Google's DNS: ```bash --dns=8.8.8.8 ``` or for Cloudflare's DNS: ```bash --dns=1.1.1.1 ``` or for Quad9 DNS: ```bash --dns=9.9.9.9 ``` You can try them all and see which one works for your network. Add the following to your `compose.yaml` to use Google's DNS: ```yaml --- services: seerr: dns: - 8.8.8.8 ``` or for Cloudflare's DNS: ```yaml --- services: seerr: dns: - 1.1.1.1 ``` or for Quad9's DNS: ```yaml --- services: seerr: dns: - 9.9.9.9 ``` You can try them all and see which one works for your network. 1. Open the Control Panel. 2. Click on Network and Internet. 3. Click on Network and Sharing Center. 4. Click on Change adapter settings. 5. Right-click the network interface connected to the internet and select Properties. 6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties. 7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS or `9.9.9.9` for Quad9's DNS. 1. Open a terminal. 2. Edit the `/etc/resolv.conf` file with your favorite text editor. 3. Add the following line to use Google's DNS: ```bash nameserver 8.8.8.8 ``` or for Cloudflare's DNS: ```bash nameserver 1.1.1.1 ``` or for Quad9's DNS: ```bash nameserver 9.9.9.9 ``` ### Option 2: Use Seerr through a proxy If you can't change your DNS servers or force IPV4 resolution, you can use Seerr through a proxy. In some places (like China), the ISP blocks not only the DNS resolution but also the connection to the TMDB API. You can configure Seerr to use a proxy with the [HTTP(S) Proxy](/using-seerr/settings/general#enable-proxy-support) setting. ### Option 3: Force IPV4 resolution first Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly. You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting Seerr. ### Option 4: Check that your server can reach TMDB API Make sure that your server can reach the TMDB API by running the following command: ```bash docker exec -it seerr sh -c "apk update && apk add curl && curl -L https://api.themoviedb.org" ``` ```bash docker compose exec seerr sh -c "apk update && apk add curl && curl -L https://api.themoviedb.org" ``` In a terminal: ```bash curl -L https://api.themoviedb.org ``` In a PowerShell window: ```powershell (Invoke-WebRequest -Uri "https://api.themoviedb.org" -Method Get).Content ``` If you can't get a response, then your server can't reach the TMDB API. This is usually due to a network configuration issue or a firewall blocking the connection. ## Account does not have admin privileges If your admin account no longer has admin privileges, this is typically because your Jellyfin/Emby user ID has changed on the server side. This can happen if you have a new installation of Jellyfin/Emby or if you have changed the user ID of your admin account. ### Solution: Reset admin access 1. Back up your `settings.json` file (located in your Seerr data directory) 2. Stop the Seerr container/service 3. Delete the `settings.json` file 4. Start Seerr again 5. This will force the setup page to appear 6. Go through the setup process with the same login details 7. You can skip the services setup 8. Once you reach the discover page, stop Seerr 9. Restore your backed-up `settings.json` file 10. Start Seerr again This process should restore your admin privileges while preserving your settings. ## Failed to enable web push notifications ### Option 1: You are using Pi-hole When using Pi-hole, you need to whitelist the proper domains in order for the queries to not be intercepted and blocked by Pi-hole. If you are using a chromium based browser (eg: Chrome, Brave, Edge...), the domain you need to whitelist is `fcm.googleapis.com` If you are using Firefox, the domain you need to whitelist is `push.services.mozilla.com` 1. Log into your Pi-hole through the admin interface, then click on Domains situated under GROUP MANAGEMENT. 2. Add the domain corresponding to your browser in the `Domain to be added` field and then click on Add to allowed domains. 3. Now in order for those changes to be used you need to flush your current dns cache. 4. You can do so by using this command line in your Pi-hole terminal: ```bash pihole restartdns ``` If this command fails (which is unlikely), use this equivalent: ```bash pihole -f && pihole restartdns ``` 5. Then restart your Seerr instance and try to enable the web push notifications again. ### Option 2: You are using Brave browser Brave is a "De-Googled" browser. So by default or if you refused a prompt in the past, it cuts the access to the FCM (Firebase Cloud Messaging) service, which is mandatory for the web push notifications on Chromium based browsers. 1. Open Brave and paste this address in the url bar: `brave://settings/privacy` 2. Look for the option: "Use Google services for push messaging" 3. Activate this option 4. Relaunch Brave completely 5. You should now see the notifications prompt appearing instead of an error message. If you still encounter issues, please reach out on our support channels. ================================================ FILE: docs/using-seerr/_category_.json ================================================ { "label": "Using Seerr", "position": 2, "link": { "type": "generated-index", "title": "Using Seerr", "description": "Learn how to use Seerr" } } ================================================ FILE: docs/using-seerr/advanced/index.mdx ================================================ --- title: Advanced Features description: Advanced configuration and use cases. sidebar_position: 6 --- # Advanced Features ## Advanced Configuration and Use Cases Seerr currently offers advanced features for power users and specific use cases: import DocCardList from '@theme/DocCardList'; ================================================ FILE: docs/using-seerr/advanced/verifying-signed-artifacts.mdx ================================================ --- id: verifying-signed-artifacts title: Verifying Signed Artifacts sidebar_label: Verify Signed Artifacts description: Learn how to verify Seerr's signed artifacts and SBOM attestations. --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Verifying Signed Artifacts These artifacts are cryptographically signed using [Sigstore Cosign](https://docs.sigstore.dev/quickstart/quickstart-cosign/): - Container images - Helm charts This ensures that the images you pull are authentic, tamper-proof, and built by the official Seerr release pipeline. Additionally each container image also includes a CycloneDX SBOM (Software Bill of Materials) attestation, generated with [Trivy](https://aquasecurity.github.io/trivy/), providing transparency about all dependencies included in the image. --- ## Prerequisites You will need the following tools installed: - [Cosign](https://docs.sigstore.dev/cosign/system_config/installation/) To verify images: - [Docker](https://docs.docker.com/get-docker/) **or** [Podman](https://podman.io/getting-started/installation) (including [Skopeo](https://github.com/containers/skopeo/blob/main/install.md)) --- ## Verifying Signed Images ### Image Locations Official Seerr images are available from: - GitHub Container Registry (GHCR): `ghcr.io/seerr-team/seerr:` - Docker Hub: `seerr/seerr:` You can view all available tags on the [Seerr Releases page](https://github.com/seerr-team/seerr/releases). --- ### Verifying a Specific Release Tag Each tagged release (for example `v2.7.4`) is immutable and cryptographically signed. Verification should always be performed using the image digest (SHA256). #### Retrieve the Image Digest ```bash docker buildx imagetools inspect ghcr.io/seerr-team/seerr:v2.7.4 --format '{{json .Manifest.Digest}}' | tr -d '"' ``` ```bash skopeo inspect docker://ghcr.io/seerr-team/seerr:v2.7.4 --format '{{.Digest}}' ``` Example output: ``` sha256:abcd1234... ``` --- #### Verify the Image Signature ```bash cosign verify ghcr.io/seerr-team/seerr@sha256:abcd1234... \ --certificate-identity "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v2.7.4" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` ```bash cosign verify seerr/seerr@sha256:abcd1234... \ --certificate-identity "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v2.7.4" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` :::info Successful Verification Example Verification for `ghcr.io/seerr-team/seerr@sha256:abcd1234...` The following checks were performed: - Cosign claims validated - Signatures verified against the transparency log - Certificate issued by Fulcio to the expected workflow identity ::: --- ### Verifying the `latest` Tag :::warning Latest Tag Warning The `latest` tag is **mutable**, meaning it will change with each new release. Always verify the digest that `latest` currently points to. ::: #### Retrieve the Digest for `latest` ```bash docker buildx imagetools inspect ghcr.io/seerr-team/seerr:latest --format '{{json .Manifest.Digest}}' | tr -d '"' ``` ```bash skopeo inspect docker://ghcr.io/seerr-team/seerr:latest --format '{{.Digest}}' ``` Example output: ``` sha256:abcd1234... ``` #### Verify the Signature ```bash cosign verify ghcr.io/seerr-team/seerr@sha256:abcd1234... \ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v.*" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` ```bash cosign verify seerr/seerr@sha256:abcd1234... \ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v.*" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` :::tip The wildcard `v.*` ensures verification works for any versioned release that `latest` represents. ::: --- ### Verifying SBOM Attestations Each image includes a CycloneDX SBOM attestation. #### Verify the Attestation ```bash cosign verify-attestation ghcr.io/seerr-team/seerr@sha256:abcd1234... \ --type cyclonedx \ --certificate-identity "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v2.7.4" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` :::info Successful Verification Example Verification for `ghcr.io/seerr-team/seerr@sha256:abcd1234...` The following checks were performed: - Cosign claims validated - Signatures verified against the transparency log - Certificate issued by Fulcio to the expected workflow identity ::: #### Extract the SBOM for Inspection ```bash cosign verify-attestation ghcr.io/seerr-team/seerr@sha256:abcd1234... \ --type cyclonedx \ --certificate-identity "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v2.7.4" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" | jq -r '.payload | @base64d' > sbom.json ``` You can open `sbom.json` in a CycloneDX viewer or analyse it with [Trivy](https://aquasecurity.github.io/trivy/) or [Dependency-Track](https://dependencytrack.org/). --- ### Expected Certificate Identity The expected certificate identity for all signed Seerr images is: ``` https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v* ``` This confirms that the image was: - Built by the official Seerr Release workflow - Produced from the seerr-team/seerr repository - Signed using GitHub’s OIDC identity via Sigstore Fulcio --- ### Example: Full Verification Flow ```bash DIGEST=$(docker buildx imagetools inspect ghcr.io/seerr-team/seerr:latest --format '{{json .Manifest.Digest}}' | tr -d '"') cosign verify ghcr.io/seerr-team/seerr@"$DIGEST" \ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v.*" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" cosign verify-attestation ghcr.io/seerr-team/seerr@"$DIGEST" \ --type cyclonedx \ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v.*" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` ```bash DIGEST=$(skopeo inspect docker://ghcr.io/seerr-team/seerr:latest --format '{{.Digest}}') cosign verify ghcr.io/seerr-team/seerr@"$DIGEST" \ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/release.yml@refs/tags/v.*" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` ## Verifying Signed Helm charts ### Helm Chart Locations Official Seerr helm charts are available from: - GitHub Container Registry (GHCR): `ghcr.io/seerr-team/seerr/seerr-chart/seerr-chart:` You can view all available tags on the [Seerr Releases page](https://github.com/seerr-team/seerr/pkgs/container/seerr%2Fseerr-chart). --- ### Verifying a Specific Release Tag Each tagged release (for example `3.0.0`) is immutable and cryptographically signed. Verification should always be performed using the image digest (SHA256). #### Retrieve the Helm Chart Digest ```bash docker buildx imagetools inspect ghcr.io/seerr-team/seerr/seerr-chart:3.0.0 --format '{{json .Manifest.Digest}}' | tr -d '"' ``` ```bash skopeo inspect docker://ghcr.io/seerr-team/seerr/seerr-chart:3.0.0 --format '{{.Digest}}' ``` Example output: ``` sha256:abcd1234... ``` --- #### Verify the Helm Chart Signature ```bash cosign verify ghcr.io/seerr-team/seerr/seerr-chart@sha256:abcd1234... \ --certificate-identity "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` :::info Successful Verification Example Verification for `ghcr.io/seerr-team/seerr/seerr-chart@sha256:abcd1234...` The following checks were performed: - Cosign claims validated - Signatures verified against the transparency log - Certificate issued by Fulcio to the expected workflow identity ::: --- ### Expected Certificate Identity The expected certificate identity for all signed Seerr images is: ``` https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main ``` This confirms that the image was: - Built by the official Seerr Release workflow - Produced from the seerr-team/seerr repository - Signed using GitHub’s OIDC identity via Sigstore Fulcio --- ### Example: Full Verification Flow ```bash DIGEST=$(docker buildx imagetools inspect ghcr.io/seerr-team/seerr/seerr-chart:3.0.0 --format '{{json .Manifest.Digest}}' | tr -d '"') cosign verify ghcr.io/seerr-team/seerr/seerr-chart@"$DIGEST" \ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" cosign verify-attestation ghcr.io/seerr-team/seerr/seerr-chart@"$DIGEST" \ --type cyclonedx \ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` ```bash DIGEST=$(skopeo inspect docker://ghcr.io/seerr-team/seerr/seerr-chart:3.0.0 --format '{{.Digest}}') cosign verify ghcr.io/seerr-team/seerr/seerr-chart@"$DIGEST" \ --certificate-identity-regexp "https://github.com/seerr-team/seerr/.github/workflows/helm.yml@refs/heads/main" \ --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ``` --- ## Troubleshooting | Issue | Likely Cause | Suggested Fix | |-------|---------------|----------------| | `no matching signatures` | Incorrect digest or tag | Retrieve the digest again using Docker or Skopeo | | `certificate identity does not match expected` | Workflow reference changed | Ensure your `--certificate-identity` matches this documentation | | `cosign: command not found` | Cosign not installed | Install Cosign from the official release | | `certificate expired` | Old release | Verify a newer tag or digest | --- ## Further Reading - [Sigstore Documentation](https://docs.sigstore.dev) - [Cosign Verification Guide](https://docs.sigstore.dev/cosign/verifying/verify/) - [CycloneDX Specification](https://cyclonedx.org/specification/overview/) - [Trivy Documentation](https://trivy.dev/latest/docs/) - [Skopeo Documentation](https://github.com/containers/skopeo) - [Podman Documentation](https://podman.io/get-started/) - [Docker Documentation](https://docs.docker.com/) - [Seerr GitHub Repository](https://github.com/seerr-team/seerr) ================================================ FILE: docs/using-seerr/backups.md ================================================ --- title: Backups description: Understand which data you should back up. sidebar_position: 4 --- # Which data does Seerr save and where? ## Settings All configurations from the **Settings** panel in the Seerr web UI are saved, including integrations with Radarr, Sonarr, Jellyfin, Plex, and notification settings. These settings are stored in the `settings.json` file located in the Seerr data folder. ## User Data Apart from the settings, all other data—including user accounts, media requests, blocklist etc. are stored in the database (either SQLite or PostgreSQL). # Backup ### SQLite If your backup system uses filesystem snapshots (such as Kubernetes with Volsync), you can directly back up the Seerr data folder. Otherwise, you need to stop the Seerr application and back up the `config` folder. For advanced users, it's possible to back up the database without stopping the application by using the [SQLite CLI](https://www.sqlite.org/download.html). Run the following command to create a backup: ```bash sqlite3 db/db.sqlite3 ".backup '/tmp/seerr_db.sqlite3.bak'" ``` Then, copy the `/tmp/seerr_dump.sqlite3.bak` file to your desired backup location. ### PostgreSQL You can back up the `config` folder and dump the PostgreSQL database without stopping the Seerr application. Install [postgresql-client](https://www.postgresql.org/download/) and run the following command to create a backup (just replace the placeholders): :::info Depending on how your PostgreSQL instance is configured, you may need to add these options to the command below. -h, --host=HOSTNAME database server host or socket directory -p, --port=PORT database server port number ::: ```bash pg_dump -U -d -f /tmp/seerr_db.sql ``` # Restore ### SQLite After restoring your `db/db.sqlite3` file and, optionally, the `settings.json` file, the `config` folder structure should look like this: ``` . ├── cache <-- Optional ├── db │ └── db.sqlite3 ├── logs <-- Optional └── settings.json <-- Optional (required if you want to avoid reconfiguring Seerr) ``` Once the files are restored, start the Seerr application. ### PostgreSQL Install the [PostgreSQL client](https://www.postgresql.org/download/) and restore the PostgreSQL database using the following command (replace the placeholders accordingly): :::info Depending on how your PostgreSQL instance is configured, you may need to add these options to the command below. -h, --host=HOSTNAME database server host or socket directory -p, --port=PORT database server port number ::: ```bash pg_restore -U -d /tmp/seerr_db.sql ``` Optionally, restore the `settings.json` file. The `config` folder structure should look like this: ``` . ├── cache <-- Optional ├── logs <-- Optional └── settings.json <-- Optional (required if you want to avoid reconfiguring Seerr) ``` Once the database and files are restored, start the Seerr application. ================================================ FILE: docs/using-seerr/notifications/discord.md ================================================ --- title: Discord description: Configure Discord notifications. sidebar_position: 3 --- # Discord The Discord notification agent enables you to post notifications to a channel in a server you manage. :::info Users can optionally opt-in to being mentioned in Discord notifications by configuring their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) in their user settings. ::: ## Configuration ### Webhook URL You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**. ### Notification Role ID (optional) If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID). ### Bot Username (optional) If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like! ### Bot Avatar URL (optional) Similar to the bot username, you can override the avatar for your bot. ================================================ FILE: docs/using-seerr/notifications/email.md ================================================ --- title: Email description: Configure email notifications for your users. sidebar_position: 1 --- # Email ## Configuration :::info If the [Application URL](/using-seerr/settings/general#application-title) setting is configured in **Settings → General**, Seerr will explicitly set the origin server hostname when connecting to the SMTP host. ::: ### Sender Name (optional) Configure a friendly name for the email sender (e.g., "Seerr"). ### Sender Address Set this to the email address you would like to appear in the "from" field of the email message. Depending on your email provider, this may need to be an address you own. For example, Gmail requires this to be your actual email address. ### SMTP Host Set this to the hostname or IP address of your SMTP server. ### SMTP Port Set this to a supported port number for your SMTP host. `465` and `587` are commonly used. ### Encryption Method In most cases, [Use Implicit TLS](https://tools.ietf.org/html/rfc8314) should be selected for port 465, and [Use STARTTLS](https://en.wikipedia.org/wiki/Opportunistic_TLS) if available for port 587. Please refer to your email provider's documentations for details on how to configure this setting. The default value for this setting is **Use STARTTLS if available**. ### SMTP Username & Password :::info If your account has two-factor authentication enabled, you may need to create an application password instead of using your account password. ::: Configure these values as appropriate to authenticate with your SMTP host. ### PGP Private Key & Password (optional) Configure these values to enable encrypting and signing of email messages using [OpenPGP](https://www.openpgp.org/). Note that individual users must also have their **PGP public keys** configured in their user settings in order for PGP encryption to be used in messages addressed to them. When configuring the PGP keys, be sure to keep the entire contents of the key intact. For example, private keys always begin with `-----BEGIN PGP PRIVATE KEY BLOCK-----` and end with `-----END PGP PRIVATE KEY BLOCK-----`. ================================================ FILE: docs/using-seerr/notifications/gotify.md ================================================ --- title: Gotify description: Configure Gotify notifications. sidebar_position: 5 --- # Gotify ## Configuration ### Server URL Set this to the URL of your Gotify server. ### Application Token Add an application to your Gotify server, and set this field to the generated application token. :::info Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications. ::: ================================================ FILE: docs/using-seerr/notifications/index.mdx ================================================ --- title: Notifications description: Configure notifications for your users. sidebar_position: 3 --- # Notifications ## Supported Notification Agents Seerr currently supports the following notification agents: import DocCardList from '@theme/DocCardList'; ## Setting Up Notifications Simply configure your desired notification agents in **Settings -> Notifications** in the Seerr web UI. Users can customize their notification preferences in their own user notification settings. ## Requesting New Notification Agents If we do not currently support your preferred notification agent, feel free to [submit a feature request on GitHub](https://github.com/seerr-team/seerr/issues). However, please be sure to search first and confirm that there is not already an existing request for the agent! ================================================ FILE: docs/using-seerr/notifications/ntfy.md ================================================ --- title: ntfy.sh description: Configure ntfy.sh notifications. sidebar_position: 6 --- # ntfy.sh ## Configuration ### Server Root URL Set this to the URL of your ntfy.sh server. ### Topic Set this to the topic you want to send notifications to. ### Username + Password authentication (optional) Set this to the username and password for your ntfy.sh server. ### Token authentication (optional) Set this to the token for your ntfy.sh server. ### Priority (optional) Set the priority level for notifications. Options range from Minimum (1) to Urgent (5), with Default (3) being the standard level. Higher priority notifications may bypass Do Not Disturb settings on some devices. :::info Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications. ::: ================================================ FILE: docs/using-seerr/notifications/pushbullet.md ================================================ --- title: Pushbullet description: Configure Pushbullet notifications. sidebar_position: 7 --- # Pushbullet :::info Users can optionally configure personal notifications in their user settings. User notifications are separate from system notifications, and the available notification types are dependent on user permissions. ::: ## Configuration ### Access Token [Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Seerr access to the Pushbullet API. ### Channel Tag (optional) Optionally, [create a channel](https://www.pushbullet.com/my-channel) to allow other users to follow the notification feed using the specified channel tag. ================================================ FILE: docs/using-seerr/notifications/pushover.md ================================================ --- title: Pushover description: Configure Pushover notifications. sidebar_position: 8 --- # Pushover :::info Users can optionally configure personal notifications in their user settings. User notifications are separate from system notifications, and the available notification types are dependent on user permissions. ::: ## Configuration ### Application/API Token [Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/seerr-team/seerr/tree/develop/public) when configuring the application.) For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration). ### User Key Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users. For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers). ================================================ FILE: docs/using-seerr/notifications/slack.md ================================================ --- title: Slack description: Configure Slack notifications. sidebar_position: 9 --- # Slack ## Configuration ### Webhook URL Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field. :::info Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications. ::: ================================================ FILE: docs/using-seerr/notifications/telegram.md ================================================ --- title: Telegram description: Configure Telegram notifications. sidebar_position: 10 --- # Telegram :::info Users can optionally configure personal notifications in their user settings. User notifications are separate from system notifications, and the available notification types are dependent on user permissions. ::: ## Configuration :::info In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather). Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications. ::: ### Bot Username (optional) If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications. The bot username should end with `_bot`, and the `@` prefix should be omitted. ### Bot Authentication Token At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token. ### Chat ID To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command. ### Send Silently (optional) Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds. ================================================ FILE: docs/using-seerr/notifications/webhook.md ================================================ --- title: Webhook description: Configure webhook notifications. sidebar_position: 4 --- # Webhook The webhook notification agent enables you to send a custom JSON payload to any endpoint for specific notification events. ## Configuration ### Webhook URL The URL you would like to post notifications to. Your JSON will be sent as the body of the request. ### Authorization Header (optional) :::info This is typically not needed. Please refer to your webhook provider's documentation for details. ::: This value will be sent as an `Authorization` HTTP header. ### Custom Headers (optional) You can add additional custom HTTP headers to be sent with each webhook request. This is useful for API keys, custom authentication schemes, or any other headers your webhook endpoint requires. - Click "Add Header" to add a new header - Enter the header name and value :::warning You cannot configure both the **Authorization Header** field and a custom `Authorization` header in Custom Headers at the same time. You must choose one method. ::: ### JSON Payload Customize the JSON payload to suit your needs. Seerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered. ## Template Variables ### General | Variable | Value | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) | | `{{event}}` | A friendly description of the notification event | | `{{subject}}` | The notification subject (typically the media title) | | `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) | | `{{image}}` | The notification image (typically the media poster) | ### Notify User These variables are for the target recipient of the notification. | Variable | Value | | ---------------------------------------- | ------------------------------------------------------------- | | `{{notifyuser_username}}` | The target notification recipient's username | | `{{notifyuser_email}}` | The target notification recipient's email address | | `{{notifyuser_avatar}}` | The target notification recipient's avatar URL | | `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) | | `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) | :::info The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users: - Request Pending Approval - Request Automatically Approved - Request Processing Failed On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types: - Request Approved - Request Declined - Request Available If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below. ::: ### Special The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`). | Variable | Value | | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `{{media}}` | The relevant media object | | `{{request}}` | The relevant request object | | `{{issue}}` | The relevant issue object | | `{{comment}}` | The relevant issue comment object | | `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) | #### Media The `{{media}}` will be `null` if there is no relevant media object for the notification. These following special variables are only included in media-related notifications, such as requests. | Variable | Value | | ------------------------------| -------------------------------------------------------------------------------------------------------------- | | `{{media_type}}` | The media type (`movie` or `tv`) | | `{{media_tmdbid}}` | The media's TMDB ID | | `{{media_tvdbid}}` | The media's TheTVDB ID | | `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | | `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | | `{{media_jellyfinMediaId}}` | The media's Jellyfin Media ID | #### Request The `{{request}}` will be `null` if there is no relevant media object for the notification. The following special variables are only included in request-related notifications. | Variable | Value | | ----------------------------------------- | ----------------------------------------------- | | `{{request_id}}` | The request ID | | `{{requestedBy_username}}` | The requesting user's username | | `{{requestedBy_email}}` | The requesting user's email address | | `{{requestedBy_avatar}}` | The requesting user's avatar URL | | `{{requestedBy_jellyfinUserId}}` | The requesting user's Jellyfin User ID | | `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) | | `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) | #### Issue The `{{issue}}` will be `null` if there is no relevant media object for the notification. The following special variables are only included in issue-related notifications. | Variable | Value | | ---------------------------------------- | ----------------------------------------------- | | `{{issue_id}}` | The issue ID | | `{{reportedBy_username}}` | The requesting user's username | | `{{reportedBy_email}}` | The requesting user's email address | | `{{reportedBy_avatar}}` | The requesting user's avatar URL | | `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) | | `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) | #### Comment The `{{comment}}` will be `null` if there is no relevant media object for the notification. The following special variables are only included in issue comment-related notifications. | Variable | Value | | ----------------------------------------- | ----------------------------------------------- | | `{{comment_message}}` | The comment message | | `{{commentedBy_username}}` | The commenting user's username | | `{{commentedBy_email}}` | The commenting user's email address | | `{{commentedBy_avatar}}` | The commenting user's avatar URL | | `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) | | `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) | ================================================ FILE: docs/using-seerr/notifications/webpush.md ================================================ --- title: Web Push description: Configure web push notifications for your users. sidebar_position: 2 --- # Web Push :::warning Web push notifications require a secure connection to your Seerr instance. Refer to the [Reverse Proxy](/extending-seerr/reverse-proxy) documentation for more information. ::: The web push notification agent enables you and your users to receive Seerr notifications in a supported browser. This offers a native notification experience without the need to install an app. This notification agent does not require any configuration, but is not enabled by default in Seerr. To set up web push notifications, simply enable the agent in **Settings → Notifications → Web Push**. You and your users have the option to enable web push notifications by going to your **User Profile → Edit Settings → Notifications → Web Push → Enable web push**. Here you can also customize the notifications you'd like to receive. :::info[Mobile Users] For Web Push notifications to work on mobile you need to add Seerr to your home screen as progressive web app (PWA). ::: :::info[iOS Users] On iOS you may need to enable the Safari notifications feature flag by going to **Settings → Safari → Advanced → Feature Flags** and enabling "Notifications". ::: ================================================ FILE: docs/using-seerr/plex/_category_.json ================================================ { "label": "Plex Integration", "position": 3, "link": { "type": "generated-index", "title": "Plex Integration", "description": "Learn about Seerr's Plex integration features" } } ================================================ FILE: docs/using-seerr/plex/index.md ================================================ --- title: Overview description: Learn about Seerr's Plex integration features sidebar_position: 1 --- # Plex Features Overview Seerr provides integration features that connect with your Plex media server to automate media management tasks. ## Available Features - [Watchlist Auto Request](./plex/watchlist-auto-request) - Automatically request media from your Plex Watchlist - More features coming soon! ## Prerequisites :::info Authentication Required To use any Plex integration features, you must have logged into Seerr at least once with your Plex account. ::: **Requirements:** - Plex account with access to the configured Plex server - Seerr configured with Plex as the media server - User authentication via Plex login - Appropriate user permissions for specific features ## Getting Started 1. Authenticate at least once using your Plex credentials 2. Verify you have the necessary permissions for desired features 3. Follow individual feature guides for setup instructions :::note Server Configuration Plex server configuration is handled by your administrator. If you cannot log in with your Plex account, contact your administrator to verify the server setup. ::: ================================================ FILE: docs/using-seerr/plex/watchlist-auto-request.md ================================================ --- title: Watchlist Auto Request description: Learn how to use the Plex Watchlist Auto Request feature sidebar_position: 1 --- # Watchlist Auto Request The Plex Watchlist Auto Request feature allows Seerr to automatically create requests for media items you add to your Plex Watchlist. Simply add content to your Plex Watchlist, and Seerr will automatically request it for you. :::info This feature is only available for Plex users. Local users cannot use the Watchlist Auto Request feature. ::: ## Prerequisites - You must have logged into Seerr at least once with your Plex account - Your administrator must have granted you the necessary permissions - Your Plex account must have access to the Plex server configured in Seerr ## Permission System The Watchlist Auto Request feature uses a two-tier permission system: ### Administrator Permissions (Required) Your administrator must grant you these permissions in your user profile: - **Auto-Request** (master permission) - **Auto-Request Movies** (for movie auto-requests) - **Auto-Request Series** (for TV series auto-requests) ### User Activation (Required) You must enable the feature in your own profile settings: - **Auto-Request Movies** toggle - **Auto-Request Series** toggle :::warning Two-Step Process Both administrator permissions AND user activation are required. Having permissions doesn't automatically enable the feature - you must also activate it in your profile. ::: ## How to Enable ### Step 1: Check Your Permissions Contact your administrator to verify you have been granted: - `Auto-Request` permission - `Auto-Request Movies` and/or `Auto-Request Series` permissions ### Step 2: Activate the Feature 1. Go to your user profile settings 2. Navigate to the "General" section 3. Find the "Auto-Request" options 4. Enable the toggles for: - **Auto-Request Movies** - to automatically request movies from your watchlist - **Auto-Request Series** - to automatically request TV series from your watchlist ### Step 3: Start Using - Add movies and TV shows to your Plex Watchlist - Seerr will automatically create requests for new items - You'll receive notifications when items are auto-requested ## How It Works Once properly configured, Seerr will: 1. Periodically checks your Plex Watchlist for new items 2. Verify if the content already exists in your media libraries 3. Automatically submits requests for new items that aren't already available 4. Only requests content types you have permissions for 5. Notifiy you when auto-requests are created :::info Content Limitations Auto-request only works for standard quality content. 4K content must be requested manually if you have 4K permissions. ::: ## For Administrators ### Granting Permissions 1. Navigate to **Users** > **[Select User]** > **Permissions** 2. Enable the required permissions: - **Auto-Request** (master toggle) - **Auto-Request Movies** (for movie auto-requests) - **Auto-Request Series** (for TV series auto-requests) 3. Optionally enable **Auto-Approve** permissions for automatic approval ### Default Permissions - Go to **Settings** > **Users** > **Default Permissions** - Configure auto-request permissions for new users - This sets the default permissions but users still need to activate the feature individually ## Limitations - Local users cannot use this feature - 4K content requires manual requests - Users must have logged into Seerr with their Plex account - Respects user request limits and quotas - Won't request content already in your libraries ================================================ FILE: docs/using-seerr/settings/_category_.json ================================================ { "label": "Settings", "position": 1, "link": { "type": "generated-index", "title": "Settings", "description": "Configure Seerr to your liking" } } ================================================ FILE: docs/using-seerr/settings/dns-caching.md ================================================ --- title: DNS Caching description: Configure DNS caching settings. sidebar_position: 7 --- # DNS Caching Seerr uses DNS caching to improve performance and reduce the number of DNS lookups required for external API calls. This can help speed up response times and reduce load on DNS servers, when something like a Pi-hole is used as a DNS resolver. ## Configuration You can enable the DNS caching settings in the Network tab of the Seerr settings. The default values follow the standard DNS caching behavior. - **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0. - **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited). ================================================ FILE: docs/using-seerr/settings/general.md ================================================ --- title: General description: Configure global and default settings for Seerr. sidebar_position: 1 --- # General ## API Key This is your Seerr API key, which can be used to integrate Seerr with third-party applications. Do **not** share this key publicly, as it can be used to gain administrator access! If you need to generate a new API key for any reason, simply click the button to the right of the text box. If you want to set the API key, rather than letting it be randomly generated, you can use the API_KEY environment variable. Whatever that variable is set to will be your API key. ## Application Title If you aren't a huge fan of the name "Seerr" and would like to display something different to your users, you can customize the application title! ## Application URL Set this to the externally-accessible URL of your Seerr instance. You must configure this setting in order to enable password reset and generation emails. ## Enable Proxy Support If you have Seerr behind a reverse proxy, enable this setting to allow Seerr to correctly register client IP addresses. For details, please see the [Express Documentation](https://expressjs.com/en/guide/behind-proxies.html). This setting is **disabled** by default. ## Enable CSRF Protection :::warning **This is an advanced setting.** Please only enable this setting if you are familiar with CSRF protection and how it works. ::: CSRF stands for [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery). When this setting is enabled, all external API access that alters Seerr application data is blocked. If you do not use Seerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks. One caveat, however, is that HTTPS is required, meaning that once this setting is enabled, you will no longer be able to access your Seerr instance over _HTTP_ (including using an IP address and port number). If you enable this setting and find yourself unable to access Seerr, you can disable the setting by modifying `settings.json` in `/app/config`. This setting is **disabled** by default. ## Enable Image Caching When enabled, Jellseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space. Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours. You should enable this if you are having issues with loading images directly from TMDB in your browser. ## Display Language Set the default display language for Seerr. Users can override this setting in their user settings. ## Discover Region, Discover Language & Streaming Region These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings. ## Blocklist Content with Tags and Limit Content Blocklisted per Tag These settings blocklist any TV shows or movies that have one of the entered tags. The "Process Blocklisted Tags" job adds entries to the blocklist based on the configured blocklisted tags. If a blocklisted tag is removed, any media blocklisted under that tag will be removed from the blocklist when the "Process Blocklisted Tags" job runs. The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blocklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blocklist, but will require more storage. Blocklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings. ## Hide Available Media When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages. Available media will still appear in search results, however, so it is possible to locate and view hidden items by searching for them by title. This setting is **disabled** by default. ## Hide Blocklisted Items When enabled, media that has been blocklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blocklisted when you have the "Manage Blocklist" permission. This setting is **disabled** by default. ## Allow Partial Series Requests When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons. This setting is **enabled** by default. ================================================ FILE: docs/using-seerr/settings/jobs&cache.md ================================================ --- title: Jobs & Cache description: Configure jobs and cache settings. sidebar_position: 6 --- # Jobs & Cache Seerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered on this page. Seerr also caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls. If necessary, the cache for any particular endpoint can be cleared by clicking the "Flush Cache" button. You can also view the current image cache size as well as the total number of cached images. ================================================ FILE: docs/using-seerr/settings/mediaserver.mdx ================================================ --- title: Mediaserver Settings description: Configure your Jellyfin, Emby, or Plex server settings. sidebar_position: 3 --- # Media Server import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; :::info To set up Jellyfin, make sure you log in using an account with administrative privileges. The email address can be any email address and is only used for notifications, password resets, and local sign-in. It is **not** tied to your Jellyfin account. ::: ### Jellyfin Libraries In this section, simply select the libraries you would like Seerr to scan. Seerr will periodically check the selected libraries for available content to update the media status that is displayed to users. If you do not see your Jellyfin library listed, verify your Jellyfin settings are correct and click the Sync Libraries button. ### Manual Library Scan Seerr will perform a full scan of your Jellyfin libraries once every 24 hours (recently added items are fetched more frequently). If this is your first time configuring Jellyfin, a one-time full manual library scan is recommended! ### Jellyfin Settings This section is where you configure the connection to your Jellyfin server. #### Internal URL The internal URL is the URL that Seerr will use to communicate with your Jellyfin server. This URL should be accessible from the machine running Seerr. In most cases, this will be the hostname or IP address of the machine running Jellyfin, followed by the port number Jellyfin is running on (usually 8096). :::note When running Seerr in a docker container with a bridged network (default), the container's network will be separate from the host network. Therefore, you cannot use `localhost` or `127.0.0.1` as the internal URL as it will resolve to the container itself. ::: :::tip If you are running Jellyfin in a docker container, you can put both Seerr and Jellyfin on the same docker network by using a custom [docker network](https://docs.docker.com/reference/cli/docker/network/). This will allow you to use the container name as the internal URL. ::: #### External URL The external URL is the URL that your users will use to access Jellyfin. This URL is used to generate links in `Play on Jellyfin` buttons, Jellyfin avatars and other places where users need to access Jellyfin directly. In most cases, the external URL will be different from the internal URL. This is especially true if you are connecting to Jellyfin using docker container names or local IP addresses. #### Forgot Password URL The forgot password URL is the URL that users will be directed to when they click the "Forgot Password" button on the login page. This URL should be accessible from the machine running Seerr. By default, this field is empty and the "Forgot Password" button on the login page will redirect to the Jellyfin internal URL with the path `/web/index.html#!/forgotpassword`. You can customize this URL to point to a custom password reset page if you have one. #### Hostname or IP Address If you have Seerr installed on the same network as Jellyfin, you can set this to the local IP address of your Jellyfin server. Otherwise, this should be set to a valid hostname (e.g., jellyfin.myawesomeserver.com). In most cases, this will be the hostname or IP address of the machine running Jellyfin. :::note When running Seerr in a docker container with a bridged network (default), the container's network will be separate from the host network. Therefore, you cannot use `localhost` or `127.0.0.1` as the internal URL as it will resolve to the container itself. ::: :::tip If you are running Jellyfin in a docker container, you can put both Seerr and Jellyfin on the same docker network by using a custom [docker network](https://docs.docker.com/reference/cli/docker/network/). This will allow you to use the container name as the internal URL. ::: #### Port This value should be set to the port that your Jellyfin server listens on. The default port that Jellyfin uses is 8096, but you may need to set this to 443 or some other value if your Jellyfin server is hosted on a VPS or a different machine and is behind a reverse proxy. #### Use SSL Enable this setting to connect to Jellyfin via HTTPS rather than HTTP. Note that self-signed certificates are **not** officially supported. #### External URL The external URL is the URL that your users will use to access Jellyfin. This URL is used to generate links in `Play on Jellyfin` buttons, Jellyfin avatars and other places where users need to access Jellyfin directly. In most cases, the external URL will be different from the internal URL. This is especially true if you are connecting to Jellyfin using docker container names or local IP addresses. #### Forgot Password URL The forgot password URL is the URL that users will be directed to when they click the "Forgot Password" button on the login page. This URL should be accessible from the machine running Seerr. By default, this field is empty and the "Forgot Password" button on the login page will redirect to the Jellyfin internal URL with the path `/web/index.html#!/forgotpassword`. You can customize this URL to point to a custom password reset page if you have one. :::info To set up Emby, make sure you log in using an account with administrative privileges. The email address can be any email address and is only used for notifications, password resets, and local sign-in. It is **not** tied to your Emby account. ::: ### Emby Libraries In this section, simply select the libraries you would like Seerr to scan. Seerr will periodically check the selected libraries for available content to update the media status that is displayed to users. If you do not see your Emby library listed, verify your Emby settings are correct and click the Sync Libraries button. ### Manual Library Scan Seerr will perform a full scan of your Emby libraries once every 24 hours (recently added items are fetched more frequently). If this is your first time configuring Emby, a one-time full manual library scan is recommended! ### Emby Settings This section is where you configure the connection to your Emby server. #### Internal URL The internal URL is the URL that Seerr will use to communicate with your Emby server. This URL should be accessible from the machine running Seerr. In most cases, this will be the hostname or IP address of the machine running Emby, followed by the port number Emby is running on (usually 8096). :::note When running Seerr in a docker container with a bridged network (default), the container's network will be separate from the host network. Therefore, you cannot use `localhost` or `127.0.0.1` as the internal URL as it will resolve to the container itself. ::: :::tip If you are running Emby in a docker container, you can put both Seerr and Emby on the same docker network by using a custom [docker network](https://docs.docker.com/reference/cli/docker/network/). This will allow you to use the container name as the internal URL. ::: #### External URL The external URL is the URL that your users will use to access Emby. This URL is used to generate links in `Play on Emby` buttons, Emby avatars and other places where users need to access Emby directly. In most cases, the external URL will be different from the internal URL. This is especially true if you are connecting to Emby using docker container names or local IP addresses. #### Forgot Password URL The forgot password URL is the URL that users will be directed to when they click the "Forgot Password" button on the login page. This URL should be accessible from the machine running Seerr. By default, this field is empty and the "Forgot Password" button on the login page will redirect to the Emby internal URL with the path `/web/index.html#!/forgotpassword.html`. You can customize this URL to point to a custom password reset page if you have one. #### Hostname or IP Address If you have Seerr installed on the same network as Emby, you can set this to the local IP address of your Emby server. Otherwise, this should be set to a valid hostname (e.g., jellyfin.myawesomeserver.com). In most cases, this will be the hostname or IP address of the machine running Emby. :::note When running Seerr in a docker container with a bridged network (default), the container's network will be separate from the host network. Therefore, you cannot use `localhost` or `127.0.0.1` as the internal URL as it will resolve to the container itself. ::: :::tip If you are running Emby in a docker container, you can put both Seerr and Emby on the same docker network by using a custom [docker network](https://docs.docker.com/reference/cli/docker/network/). This will allow you to use the container name as the internal URL. ::: #### Port This value should be set to the port that your Emby server listens on. The default port that Emby uses is 8096, but you may need to set this to 443 or some other value if your Emby server is hosted on a VPS or a different machine and is behind a reverse proxy. #### Use SSL Enable this setting to connect to Emby via HTTPS rather than HTTP. Note that self-signed certificates are **not** officially supported. #### External URL The external URL is the URL that your users will use to access Emby. This URL is used to generate links in `Play on Emby` buttons, Emby avatars and other places where users need to access Emby directly. In most cases, the external URL will be different from the internal URL. This is especially true if you are connecting to Emby using docker container names or local IP addresses. #### Forgot Password URL The forgot password URL is the URL that users will be directed to when they click the "Forgot Password" button on the login page. This URL should be accessible from the machine running Seerr. By default, this field is empty and the "Forgot Password" button on the login page will redirect to the Emby internal URL with the path `/web/index.html#!/startup/forgotpassword.html`. You can customize this URL to point to a custom password reset page if you have one. ### Plex Settings :::info To set up Plex, you can either enter your details manually or select a server retrieved from [plex.tv](https://plex.tv/). Press the button to the right of the "Server" dropdown to retrieve available servers. Depending on your setup/configuration, you may need to enter your Plex server details manually in order to establish a connection from Seerr. ::: #### Hostname or IP Address If you have Seerr installed on the same network as Plex, you can set this to the local IP address of your Plex server. Otherwise, this should be set to a valid hostname (e.g., `plex.myawesomeserver.com`). #### Port This value should be set to the port that your Plex server listens on. The default port that Plex uses is `32400`, but you may need to set this to `443` or some other value if your Plex server is hosted on a VPS or cloud provider. #### Use SSL Enable this setting to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported. #### Web App URL (optional) The **Play on Plex** buttons on media pages link to items on your Plex server. By default, these links use the [Plex Web App](https://support.plex.tv/articles/200288666-opening-plex-web-app/) hosted from plex.tv, but you can provide the URL to the web app on your Plex server and we'll use that instead! Note that you will need to enter the full path to the web app (e.g., `https://plex.myawesomeserver.com/web`). ### Plex Libraries In this section, simply select the libraries you would like Seerr to scan. Seerr will periodically check the selected libraries for available content to update the media status that is displayed to users. If you do not see your Plex libraries listed, verify your Plex settings are correct and click the **Sync Libraries** button. ### Manual Library Scan Seerr will perform a full scan of your Plex libraries once every 24 hours (recently added items are fetched more frequently). If this is your first time configuring Plex, a one-time full manual library scan is recommended! ================================================ FILE: docs/using-seerr/settings/notifications.mdx ================================================ --- title: Notifications description: Configure notifications for your users. sidebar_position: 5 --- # Notifications Please see the [Notifications](/using-seerr/notifications) page for more information. ================================================ FILE: docs/using-seerr/settings/services.md ================================================ --- title: Services description: Configure your default services. sidebar_position: 4 --- # Services :::info **If you keep separate copies of non-4K and 4K content in your media libraries, you will need to set up multiple Radarr/Sonarr instances and link each of them to Seerr.** Seerr checks these linked servers to determine whether or not media has already been requested or is available, so two servers of each type are required _if you keep separate non-4K and 4K copies of media._ **If you only maintain one copy of media, you can instead simply set up one server and set the "Quality Profile" setting on a per-request basis.** ::: ### Radarr/Sonarr Settings :::warning **Only v3 & V4 Radarr/Sonarr servers are supported!** If your Radarr/Sonarr server is still running v2, you will need to upgrade in order to add it to Seerr. :::: #### Default Server At least one server needs to be marked as "Default" in order for requests to be sent successfully to Radarr/Sonarr. If you have separate 4K Radarr/Sonarr servers, you need to designate default 4K servers _in addition to_ default non-4K servers. #### 4K Server Only select this option if you have separate non-4K and 4K servers. If you only have a single Radarr/Sonarr server, do _not_ check this box! #### Server Name Enter a friendly name for the Radarr/Sonarr server. #### Hostname or IP Address If you have Seerr installed on the same network as Radarr/Sonarr, you can set this to the local IP address of your Radarr/Sonarr server. Otherwise, this should be set to a valid hostname (e.g., `radarr.myawesomeserver.com`). #### Port This value should be set to the port that your Radarr/Sonarr server listens on. By default, Radarr uses port `7878` and Sonarr uses port `8989`, but you may need to set this to `443` or some other value if your Radarr/Sonarr server is hosted on a VPS or cloud provider. #### Use SSL Enable this setting to connect to Radarr/Sonarr via HTTPS rather than HTTP. Note that self-signed certificates are _not_ supported. #### API Key Enter your Radarr/Sonarr API key here. Do _not_ share these key publicly, as they can be used to gain administrator access to your Radarr/Sonarr servers! You can locate the required API keys in Radarr/Sonarr in **Settings → General → Security**. #### URL Base If you have configured a URL base for your Radarr/Sonarr server, you _must_ enter it here in order for Jellyeerr to connect to those services! You can verify whether or not you have a URL base configured in your Radarr/Sonarr server at **Settings → General → Host**. (Note that a restart of your Radarr/Sonarr server is required if you modify this setting!) #### Profiles, Root Folder, Minimum Availability Select the default settings you would like to use for all new requests. Note that all of these options are required, and that requests will fail if any of these are not configured! #### External URL (optional) If the hostname or IP address you configured above is not accessible outside your network, you can set a different URL here. This "external" URL is used to add clickable links to your Radarr/Sonarr servers on media detail pages. #### Enable Scan (optional) Enable this setting if you would like to scan your Radarr/Sonarr server for existing media/request status. It is recommended that you enable this setting, so that users cannot submit requests for media which has already been requested or is already available. #### Enable Automatic Search (optional) Enable this setting to have Radarr/Sonarr to automatically search for media upon approval of a request. ================================================ FILE: docs/using-seerr/settings/users.md ================================================ --- title: User Settings description: Configure global and default user settings. sidebar_position: 2 --- # Users ## Enable Local Sign-In When enabled, users who have configured passwords will be allowed to sign in using their email address. When disabled, your mediaserver OAuth becomes the only sign-in option, and any "local users" you have created will not be able to sign in to Seerr. This setting is **enabled** by default. ## Enable Jellyfin/Emby/Plex Sign-In When enabled, users will be able to sign in to Seerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts. When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Seerr. This setting is **enabled** by default. ## Enable New Jellyfin/Emby/Plex Sign-In When enabled, users with access to your media server will be able to sign in to Seerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in. This setting is **enabled** by default. ## Global Movie Request Limit & Global Series Request Limit Select the request limits you would like granted to users. Unless an override is configured, users are granted these global request limits. Note that users with the **Manage Users** permission are exempt from request limits, since that permission also grants the ability to submit requests on behalf of other users. ## Default Permissions Select the permissions you would like assigned to new users to have by default upon account creation. If [Enable New Jellyfin/Emby/Plex Sign-In](#enable-new-jellyfinembyplex-sign-in) is enabled, any user with access to your media server will be able to sign in to Seerr, and they will be granted the permissions you select here upon first sign-in. This setting only affects new users, and has no impact on existing users. In order to modify permissions for existing users, you will need to edit the users. ================================================ FILE: docs/using-seerr/users/_category_.json ================================================ { "label": "Users", "position": 2, "link": { "type": "generated-index", "title": "Users", "description": "Configure your Seerr users" } } ================================================ FILE: docs/using-seerr/users/adding-users.mdx ================================================ --- title: Adding Users description: Add users to your Seerr instance. sidebar_position: 2 --- # Adding Users There are currently two methods to add users to Seerr: importing Mediaserver users and creating "local users." All new users are created with the [default permissions](/using-seerr/settings/users#default-permissions) defined in **Settings → Users**. ### Importing Mediaserver Users import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; Clicking the **Import Jellyfin Users** button on the **User List** page will fetch the list of users with access to the Jellyfin server and add them to Seerr automatically. Importing Jellyfin users is not required, however. Any user with access to the Jellyfin server can log in to Seerr even if they have not been imported, and will be assigned the configured [default permissions](/using-seerr/settings/users#default-permissions) upon their first login. :::tip To disable new Jellyfin sign-ins, navigate to **Settings → Users** and uncheck the [**Enable New Jellyfin Sign-In**](/using-seerr/settings/users#enable-new-jellyfinembyplex-sign-in) box. ::: Clicking the **Import Emby Users** button on the **User List** page will fetch the list of users with access to the Emby server and add them to Seerr automatically. Importing Emby users is not required, however. Any user with access to the Emby server can log in to Seerr even if they have not been imported, and will be assigned the configured [default permissions](/using-seerr/settings/users#default-permissions) upon their first login. :::tip To disable new Emby sign-ins, navigate to **Settings → Users** and uncheck the [**Enable New Emby Sign-In**](/using-seerr/settings/users#enable-new-jellyfinembyplex-sign-in) box. ::: Clicking the **Import Plex Users** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Seerr automatically. Importing Plex users is not required, however. Any user with access to the Plex server can log in to Seerr even if they have not been imported, and will be assigned the configured [default permissions](/using-seerr/settings/users#default-permissions) upon their first login. :::tip To disable new Plex sign-ins, navigate to **Settings → Users** and uncheck the [**Enable New Plex Sign-In**](/using-seerr/settings/users#enable-new-jellyfinembyplex-sign-in) box. ::: ### Creating Local Users If you would like to grant Seerr access to a user who doesn't have their own Plex account and/or access to the Plex server, you can manually add them by clicking the **Create Local User** button. #### Email Address Enter a valid email address at which the user can receive messages pertaining to their account and other notifications. The email address currently cannot be modified after the account is created. #### Automatically Generate Password If an [application URL](/using-seerr/settings/general#application-url) is set and [email notifications](/using-seerr/notifications/email) have been configured and enabled, Seerr can automatically generate a password for the new user. #### Password If you would prefer to manually configure a password, enter a password here that is a minimum of 8 characters. ================================================ FILE: docs/using-seerr/users/deleting-users.md ================================================ --- title: Deleting Users description: Delete users from Seerr. sidebar_position: 4 --- # Deleting Users When users are deleted, all of their data and request history is also cleared from the database. ================================================ FILE: docs/using-seerr/users/editing-users.md ================================================ --- title: Editing Users description: Edit user settings and permissions. sidebar_position: 3 --- # Editing Users From the **User List**, you can click the **Edit** button to modify a particular user's settings. You can also click the check boxes and click the **Bulk Edit** button to set user permissions for multiple users at once. ## General ### Display Name You can optionally set a "friendly name" for any user. This name will be used in lieu of their media server (Jellyfin/Emby/Plex) username (for users imported from the media server) or their email address (for manually-created local users). ### Email :::note This field is read-only for users imported from Plex. ::: You can optionally set a proper email address for any user. This email address will be used for notifications, local sign-in and password resets. By default, users imported from Jellyfin/Emby will use their media server username as their email address. :::warning You cannot leave this field blank. ::: ### Display Language Users can override the [global display language](/using-seerr/settings/general#display-language) to use Seerr in their preferred language. ### Discover Region & Discover Language Users can override the [global filter settings](/using-seerr/settings/general#discover-region-discover-language--streaming-region) to suit their own preferences. ### Movie Request Limit & Series Request Limit You can override the default settings and assign different request limits for specific users by checking the **Enable Override** box and selecting the desired request limit and time period. Unless an override is configured, users are granted the global request limits. Note that users with the **Manage Users** permission are exempt from request limits, since that permission also grants the ability to submit requests on behalf of other users. Users are also unable to modify their own request limits. ## Password All "local users" are assigned passwords upon creation, but users imported from Plex can also optionally configure passwords to enable sign-in using their email address. Passwords must be a minimum of 8 characters long. ## Notifications Users can configure their personal notification settings here. Please see [Notifications](/using-seerr/notifications/) for details on configuring and enabling notifications. ## Permissions Users cannot modify their own permissions. Users with the **Manage Users** permission can manage permissions of other users, except those of users with the **Admin** permission. ================================================ FILE: docs/using-seerr/users/owner.md ================================================ --- title: Owner Account description: Your owner account is the primary account for managing Seerr. sidebar_position: 1 --- # Owner Account The user account created during Seerr setup is the "Owner" account, which cannot be deleted or modified by other users. This account's credentials are used to authenticate with your media server and configure Seerr settings. :::note In case of Jellyfin/Emby, the owner account is also used for API access to your media server. This account should have a valid authentication token for your media server. ::: :::tip If your authentication token is ever invalidated or changed, you can refresh it by re-authenticating with your media server. ::: ================================================ FILE: eslint.config.mts ================================================ import js from '@eslint/js'; import nextPlugin from '@next/eslint-plugin-next'; import prettier from 'eslint-config-prettier'; import formatjs from 'eslint-plugin-formatjs'; import jsxA11y from 'eslint-plugin-jsx-a11y'; import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths'; import reactPlugin from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import { defineConfig, type Config } from 'eslint/config'; import globals from 'globals'; import tseslint from 'typescript-eslint'; type Plugin = NonNullable[string]; export default defineConfig( // Global ignores { ignores: ['node_modules/**', '.next/**'], }, js.configs.recommended, tseslint.configs.recommended, jsxA11y.flatConfigs.recommended, { languageOptions: { ecmaVersion: 2023, sourceType: 'module', parserOptions: { ecmaFeatures: { jsx: true }, }, globals: { ...globals.browser, ...globals.node, ...globals.jest, }, }, settings: { react: { pragma: 'React', version: '18.3', }, }, plugins: { react: reactPlugin, 'react-hooks': reactHooks as Plugin, formatjs, 'no-relative-import-paths': noRelativeImportPaths, '@next/next': nextPlugin, }, rules: { ...nextPlugin.configs.recommended.rules, // TypeScript '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/array-type': ['error', { default: 'array' }], '@typescript-eslint/consistent-type-imports': [ 'error', { prefer: 'type-imports' }, ], // React 'react/prop-types': 'off', 'react/self-closing-comp': 'error', // jsx-a11y 'jsx-a11y/no-noninteractive-tabindex': 'off', 'jsx-a11y/anchor-is-valid': 'off', 'jsx-a11y/no-onchange': 'off', // React Hooks 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', // General 'arrow-parens': 'off', 'no-console': 'warn', 'no-unused-vars': 'off', // Plugins 'formatjs/no-offset': 'error', 'no-relative-import-paths/no-relative-import-paths': [ 'error', { allowSameFolder: true }, ], }, }, prettier, { linterOptions: { reportUnusedDisableDirectives: true, }, } ); ================================================ FILE: gen-docs/.gitignore ================================================ # Dependencies /node_modules # Production /build # Generated files .docusaurus .cache-loader # Misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: gen-docs/README.md ================================================ # Seerr Documentation Seerr docs is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. Seerr docs will be available at [docs.seerr.dev](https://docs.seerr.dev). ### Installation ``` $ pnpm install ``` ### Local Development ``` $ pnpm start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. ### Build ``` $ pnpm build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. ================================================ FILE: gen-docs/babel.config.js ================================================ module.exports = { presets: [require.resolve('@docusaurus/core/lib/babel/preset')], }; ================================================ FILE: gen-docs/blog/2025-09-29-introducing-seerr-blog.md ================================================ --- title: Welcome to the Seerr Blog description: The official Seerr blog for release notes, technical updates, and community news. slug: welcome authors: [fallenbagel, gauthier-th] tags: [announcement, seerr, blog] image: https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/gen-docs/static/img/logo_full.svg hide_table_of_contents: false --- We are pleased to introduce the official Seerr blog. This space will serve as the central place for: - Release announcements - Updates on new features and improvements - Technical articles, such as details on our [**DNS caching package**](https://github.com/seerr/dns-caching) and other enhancements - Community-related news Our goal is to keep the community informed and provide deeper insights into the ongoing development of Seerr. Thank you for being part of the Seerr project. More updates will follow soon. ================================================ FILE: gen-docs/blog/2026-02-10/seerr-release.md ================================================ --- title: "Seerr Release: Unifying Overseerr and Jellyseerr" description: "Overseerr and Jellyseerr are merging into a unified project: Seerr" slug: seerr-release authors: [seerr-team] image: https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/gen-docs/static/img/logo_full.svg hide_table_of_contents: false --- We're excited to announce a major update: the Jellyseerr and Overseerr teams are officially merging into a single team called **Seerr**. This unification marks an important step forward as we bring our efforts together under one banner. For users, this means one shared codebase combining all existing Overseerr functionalities with the latest Jellyseerr features, along with Jellyfin and Emby support, allowing us to deliver updates more efficiently and keep the project moving forward. Please check how to migrate to Seerr in our [migration guide](https://docs.seerr.dev/migration-guide) and stay tuned for more updates on the project! ## What's new in Seerr for Overseerr users Seerr brings several features that were previously available in Jellyseerr but missing from Overseerr. These additions improve flexibility, performance, and overall control for admins and power users: * **Alternative media solution:** Added support for Jellyfin and Emby as alternatives to Plex. Only one integration can be used at a time. * **PostgreSQL support**: In addition to SQLite, you can now opt in to using a PostgreSQL database. * **Blocklist for movies, series, and tags**: Allows permitted users to hide movies, series, or tags from regular users. * **Override rules**: Adjust default request settings based on conditions such as user, tag, or other criteria. * **TVDB metadata**: Option to use TheTVDB metadata for series (as in Sonarr) instead of TMDB. * **DNS caching**: Reduces lookup times and external requests, especially useful when using systems like Pi-Hole/Adguard Home. * **Helm chart included**: Enables easier installation and maintenance in Kubernetes environments. * **ntfy.sh notifications**: Support for sending notifications via ntfy.sh. * **Disable special seasons:** Adds a setting to prevent special seasons from being shown or requested. * **New languages**: Turkish and Basque ## What's new since the previous Jellyseerr release This release also brings several important improvements and long-requested features, including **TheTVDB metadata support**, **DNS caching**, and **dynamic webhook placeholders**, along with a few quality-of-life improvements for developers and users alike. ### PNPM v10 Upgrade We're updating Seerr to **PNPM v10** to keep up-to-date development tools. If you are building Seerr from source or if you contribute to Seerr, you'll need to **update your local PNPM installation** before working on the project. This doesn't concern you if you're using Docker. To update, run the following command: `pnpm self-update` After updating, verify your version with: `pnpm -v` You should see version **10.x.x**. ### TVDB Metadata Provider (Experimental) We're excited to introduce support for **TheTVDB** as a new metadata provider! Previously, Seerr relied solely on **TMDB** for movie and TV show information, which sometimes led to discrepancies in season and episode numbering when working with **Sonarr**, since Sonarr uses **TheTVDB** as its metadata source. With this new integration, Seerr can now use **the same data source as Sonarr** for series and anime, ensuring consistent and accurate season and episode information across both platforms. You can try this new experimental feature in the new “Metadata Providers” tab of the settings page: ![Metadata Providers](./metadata-providers.png) ### DNS Caching (Experimental) By default, Node.js doesn't cache any DNS requests. Our DNS cache manager addresses the problems caused by extremely high DNS query rates, particularly for large Jellyfin libraries as each HTTP request was also resulting in another DNS request. Therefore, by caching these DNS lookups, **Seerr will now reduce stress on DNS servers** and avoid rate-limiting or blocks encountered with services like **Pi-Hole**/**Adguard Home**. We will post another blog post soon on all the issues we encountered with DNS caching in Node.js. You can enable this by checking the “DNS Cache” setting in the network tabs of the Seerr settings: ![DNS Cache](./dns-cache.png) ### AniDB for Jellyfin Libraries This new version also brings additional metadata to Jellyfin-managed collections. When there's no provider ID from TMDB or TVDB, Seerr will automatically **fall back on AniDB**, expanding coverage for lesser-known or region-specific anime. ### Dynamic Placeholders in Webhook URLs Webhook notifications are now more powerful and adaptable with **dynamic placeholder support in webhook URLs**. This allows Seerr to automatically replace placeholders in the webhook URL with real values at runtime. For example, you can include the requester's username directly in your webhook URL to better integrate with third-party services or user-specific endpoints. This feature can be enabled from the **Notifications** settings page, where available placeholders are listed for reference. It's currently marked as **experimental**, and we welcome community feedback to help refine and expand support for additional placeholders in future releases. ### Optional Images in Notifications Another small feature: **images in notifications are now optional** (but still enabled by default). Previous versions always included images in notifications, which could lead to broken links or failed requests if images were missing or unavailable. ### Security improvement Some outdated dependencies have been updated (some work is still in progress). Helm charts and containers are now cryptographically signed and can be verified and enforced client-side. Containers now run as rootless. Workflows have been completely reworked to minimize third-party actions. Permissions have been strengthened, and actions are now pinned to specific hashes for better traceability. The release process has been updated to remove many outdated and plugin dependencies, replacing them with more standard industry solutions. :::important ## Note for PostgreSQL users (optional) If you're migrating Postgres from version 17 to 18 in Docker, note that the data mount point has changed. Instead of using `/var/lib/postgresql/data`, the correct mount path is now `/var/lib/postgresql`. This update of the mount point is required to ensure the container functions correctly after the upgrade. ::: ## Conclusion Seerr is built and maintained by dedicated volunteer contributors, whose skills and commitment make it all possible. Many thanks to everyone who contributed to this version: * [0xsysr3ll](https://github.com/0xSysR3ll) * [ale183](https://github.com/ale183) * [Brandon Cohen](https://github.com/OwsleyJr) * [Disparate2761](https://github.com/Disparate2761) * [fallenbagel](https://github.com/fallenbagel) * [Gauthier](https://github.com/gauthier-th) * [Gauvain](https://github.com/Gauvino) * [Georgy](https://github.com/tarasverq) * [Ishan Jain](https://github.com/ishanjain28) * [James Kruger](https://github.com/theGunner295) * [Joe Harrison](https://github.com/sudo-kraken) * [J. Winters-Brown](https://github.com/ofgrenudo) * [Ludovic Ortega](https://github.com/M0NsTeRRR) * [RolliePollie18](https://github.com/RolliePollie18) * [Ryan Cohen](https://github.com/sct) * [salty](https://github.com/saltydk) * [samohtxotom](https://github.com/samohtxotom) * [Sergii Bogomolov](https://github.com/sbogomolov) * [Someone](https://github.com/InterN0te) * [TacoCake](https://github.com/TacoCake) * [Terry Sposato](https://github.com/tsposato) * [TheCatLady](https://github.com/TheCatLady) * [Thibaut Noah](https://github.com/tirrorex) * [THOMAS B](https://github.com/TOomaAh) Keep an eye on our blog for in-depth looks at our work and upcoming releases! ================================================ FILE: gen-docs/blog/2026-02-28-seerr-security-fix-release.md ================================================ --- title: "Seerr v3.1.0: Critical Security Release" description: "Seerr v3.1.0 addresses three CVEs, including a high-priority vulnerability affecting Plex-configured instances. Upgrade immediately." slug: seerr-3-1-0-security-release authors: [seerr-team] image: https://raw.githubusercontent.com/seerr-team/seerr/refs/heads/develop/gen-docs/static/img/logo_full.svg hide_table_of_contents: false --- We are releasing **Seerr v3.1.0**, a security-focused update that addresses three CVEs, including a high-priority vulnerability affecting instances configured with Plex Media Server. **We strongly recommend upgrading as soon as possible.** This release also includes a number of bug fixes and marks the end of our post-merger feature freeze. New features will be resuming in future updates. ## Security Vulnerabilities This release patches three newly identified CVEs. If you are running a Plex-configured instance of Seerr, **one of these vulnerabilities is high priority and poses a significant risk**, please upgrade immediately. ### [CVE-2026-27707](https://github.com/seerr-team/seerr/security/advisories/GHSA-rc4w-7m3r-c2f7) — Unauthenticated Account Registration via Jellyfin Endpoint (High) On instances configured to use Plex as the media server, an unauthenticated attacker could register an account by abusing the Jellyfin authentication endpoint. This could allow unauthorized users to gain access to your Seerr instance without valid Plex credentials. ### [CVE-2026-27793](https://github.com/seerr-team/seerr/security/advisories/GHSA-f7xw-jcqr-57hp) — Broken Object-Level Authorization in User Profile Endpoint (Medium) A broken object-level authorization vulnerability in the user profile endpoint could allow an authenticated user to access another user's profile data, including third-party notification credentials such as webhook URLs, Telegram tokens, and similar sensitive configuration. ### [CVE-2026-27792](https://github.com/seerr-team/seerr/security/advisories/GHSA-gx3h-3jg5-q65f) — Missing Authentication on Push Subscription Endpoints (Medium) The push subscription endpoints lacked proper authentication checks, allowing unauthenticated requests to interact with subscription management functionality. --- Please review the full security advisories linked above for technical details, impact assessment, and mitigation steps. ## Bug Fixes Alongside the security patches, this release ships a number of bug fixes: - ***(helm)*** Add `"v"` as prefix for `appVersion` tag - ***(jellyfin-scanner)*** Include unmatched seasons in processable seasons - ***(link-account)*** Fix error-message override - ***(plex-scanner)*** Add TVDb to TMDB fallback in Plex scanner - ***(radarr)*** Trigger search for existing monitored movies without files - ***(servarr)*** Increase default API timeout from 5000ms to 10000ms - ***(sonarr)*** Use configured metadata provider for season filtering - ***(watch-data)*** Use sentinel values to avoid invalid SQL syntax - ***(watchlist-sync)*** Correct permission typo for TV auto requests - Preserve blocklist on media deletion & optimise watchlist-sync ## New Contributors Many thanks to those making their first contribution to Seerr in this release: * [@caillou](https://github.com/caillou) * [@Kenshin9977](https://github.com/Kenshin9977) * [@MagicLegend](https://github.com/MagicLegend) * [@wiiaam](https://github.com/wiiaam) * [@mjonkus](https://github.com/mjonkus) * [@nova-api](https://github.com/nova-api) * [@mreid-tt](https://github.com/mreid-tt) * [@DataBitz](https://github.com/DataBitz) * [@Hyperion2220](https://github.com/Hyperion2220) * [@blassley](https://github.com/blassley) * [@JanKleine](https://github.com/JanKleine) * [@koiralasandesh](https://github.com/koiralasandesh) ## What's Next Now that the post-merger feature freeze has ended, the team is resuming active feature development. Stay tuned to our blog for upcoming releases and in-depth looks at what we're building next. In the meantime, please upgrade to **v3.1.0** right away, especially if you are using a Plex Media Server configuration. See our [migration guide](https://docs.seerr.dev/migration-guide) if you need help upgrading from Overseerr/Jellyseerr. ================================================ FILE: gen-docs/blog/authors.yml ================================================ fallenbagel: name: Fallenbagel page: true title: Developer & Maintainer of Seerr description: Core Maintainer & Developer of Seerr | Full-Stack Software Engineer | MSc Software Engineering Student. url: https://github.com/fallenbagel image_url: https://github.com/fallenbagel.png email: hello@fallenbagel.com socials: github: fallenbagel gauthier-th: name: Gauthier page: true title: Developer & Maintainer of Seerr description: Core Maintainer & Developer of Seerr | PhD Student in AI at ICB, Dijon url: https://gauthierth.fr image_url: https://github.com/gauthier-th.png email: mail@gauthierth.fr socials: github: gauthier-th seerr-team: name: Seerr Team title: The team behind Seerr, formerly known as the Jellyseerr and Overseerr teams. url: https://seerr.dev image_url: https://github.com/seerr-team.png socials: github: seerr-team ================================================ FILE: gen-docs/docusaurus.config.ts ================================================ import type * as Preset from '@docusaurus/preset-classic'; import type { Config } from '@docusaurus/types'; import { themes as prismThemes } from 'prism-react-renderer'; const config: Config = { title: 'Seerr', tagline: 'One Stop Solution for all your media request needs', favicon: 'img/favicon.ico', url: 'https://docs.seerr.dev', baseUrl: '/', trailingSlash: false, organizationName: 'seerr-team', projectName: 'seerr', deploymentBranch: 'gh-pages', onBrokenLinks: 'throw', markdown: { hooks: { onBrokenMarkdownLinks: 'warn', }, }, i18n: { defaultLocale: 'en', locales: ['en'], }, presets: [ [ 'classic', { docs: { sidebarPath: './sidebars.ts', routeBasePath: '/', path: '../docs', editUrl: 'https://github.com/seerr-team/seerr/edit/develop/docs/', }, pages: false, theme: { customCss: './src/css/custom.css', }, } satisfies Preset.Options, ], ], themes: [ [ '@easyops-cn/docusaurus-search-local', /** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */ { hashed: true, indexBlog: false, docsDir: '../docs', docsRouteBasePath: '/', explicitSearchResultPath: true, }, ], ], themeConfig: { colorMode: { defaultMode: 'dark', disableSwitch: true, respectPrefersColorScheme: false, }, navbar: { logo: { alt: 'Seerr', src: 'img/logo_full.svg', }, items: [ { to: 'blog', label: 'Blog', position: 'right', }, { href: 'https://discord.gg/seerr', label: 'Discord', position: 'right', }, { href: 'https://github.com/seerr-team/seerr', label: 'GitHub', position: 'right', }, ], }, footer: { style: 'dark', links: [ { title: 'Docs', items: [ { label: 'Documentation', to: '/', }, ], }, { title: 'Project', items: [ { label: 'Blog', to: '/blog', }, { label: 'GitHub', href: 'https://github.com/seerr-team/seerr', }, ], }, { title: 'Community', items: [ { label: 'Discord', href: 'https://discord.gg/seerr', }, { label: 'Github Discussions', href: 'https://github.com/seerr-team/seerr/discussions', }, ], }, ], copyright: `Copyright © ${new Date().getFullYear()} Seerr. Built with Docusaurus.`, }, prism: { theme: prismThemes.shadesOfPurple, darkTheme: prismThemes.shadesOfPurple, additionalLanguages: [ 'bash', 'powershell', 'yaml', 'nix', 'nginx', 'batch', ], }, } satisfies Preset.ThemeConfig, }; export default config; ================================================ FILE: gen-docs/package.json ================================================ { "name": "gen-docs", "version": "0.0.0", "private": true, "packageManager": "pnpm@10.17.1", "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc" }, "dependencies": { "@docusaurus/core": "3.9.1", "@docusaurus/preset-classic": "3.9.1", "@easyops-cn/docusaurus-search-local": "^0.52.1", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "prism-react-renderer": "^2.3.0", "react": "^18.0.0", "react-dom": "^18.0.0", "tailwindcss": "^3.4.4" }, "devDependencies": { "@docusaurus/module-type-aliases": "3.9.1", "@docusaurus/tsconfig": "3.9.1", "@docusaurus/types": "3.9.1", "typescript": "~5.2.2" }, "browserslist": { "production": [ ">0.5%", "not dead", "not op_mini all" ], "development": [ "last 3 chrome version", "last 3 firefox version", "last 5 safari version" ] }, "engines": { "node": ">=22.0" } } ================================================ FILE: gen-docs/sidebars.ts ================================================ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; /** * Creating a sidebar enables you to: - create an ordered group of docs - render a sidebar for each doc of that group - provide next/previous navigation The sidebars can be generated from the filesystem, or explicitly defined here. Create as many sidebars as you want. */ const sidebars: SidebarsConfig = { // By default, Docusaurus generates a sidebar from the docs folder structure documentationSidebar: [{ type: 'autogenerated', dirName: '.' }], // But you can create a sidebar manually /* tutorialSidebar: [ 'intro', 'hello', { type: 'category', label: 'Tutorial', items: ['tutorial-basics/create-a-document'], }, ], */ }; export default sidebars; ================================================ FILE: gen-docs/src/components/SeerrVersion/index.tsx ================================================ import { useEffect, useState } from 'react'; export const SeerrVersion = () => { const [version, setVersion] = useState('0.0.0'); useEffect(() => { async function fetchVersion() { try { const response = await fetch( 'https://raw.githubusercontent.com/seerr-team/seerr/main/package.json' ); const data = await response.json(); setVersion(data.version); console.log(data.version); } catch (error) { console.error('Failed to fetch version', error); setVersion('Error fetching version'); } } fetchVersion(); }, []); return version; }; export const NixpkgVersion = () => { const [versions, setVersions] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchVersion = async () => { try { const unstableUrl = 'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-unstable/pkgs/by-name/je/jellyseerr/package.nix'; const stableUrl = 'https://raw.githubusercontent.com/NixOS/nixpkgs/refs/heads/nixos-25.05/pkgs/by-name/je/jellyseerr/package.nix'; const [unstableResponse, stableResponse] = await Promise.all([ fetch(unstableUrl), fetch(stableUrl), ]); const unstableData = await unstableResponse.text(); const stableData = await stableResponse.text(); const versionRegex = /version\s*=\s*"([^"]+)"/; const unstableMatch = unstableData.match(versionRegex); const stableMatch = stableData.match(versionRegex); const unstableVersion = unstableMatch && unstableMatch[1] ? unstableMatch[1] : '0.0.0'; const stableVersion = stableMatch && stableMatch[1] ? stableMatch[1] : '0.0.0'; setVersions({ unstable: unstableVersion, stable: stableVersion }); setLoading(false); } catch (err) { setError(err.message); setLoading(false); } }; fetchVersion(); }, []); if (loading) { return 'Loading...'; } if (error) { return { error }; } return versions; }; ================================================ FILE: gen-docs/src/css/custom.css ================================================ /** * Any CSS included here will be global. The classic template * bundles Infima by default. Infima is a CSS framework designed to * work well for content-centric websites. */ /* You can override the default Infima variables here. */ /* :root { --ifm-color-scheme: dark; --ifm-background-color: #151d2c; --ifm-navbar-background-color: #151d2c; --ifm-color-content: #fff; --ifm-color-content-secondary: #888f9b; --ifm-color-primary: var(--ifm-color-content); --ifm-hover-overlay: #374151; --ifm-menu-color-background-active: #793ae8; --ifm-color-primary-dark: #1f2b7f; --ifm-color-primary-darker: #16206b; --ifm-color-primary-darkest: #0d1456; --ifm-color-primary-light: #9066e3; --ifm-color-primary-lighter: #a37ff0; --ifm-color-primary-lightest: #b8a3f9; --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); } */ /* For readability concerns, you should choose a lighter palette in dark mode. */ [data-theme='dark'] { --ifm-color-scheme: dark; --ifm-background-color: #151d2c !important; --ifm-navbar-background-color: #151d2c; --ifm-color-content: #fff; --ifm-color-content-secondary: #888f9b; --ifm-color-primary: var(--ifm-color-content); --ifm-hover-overlay: #374151; /* --ifm-menu-color-background-active: #793ae8; */ /* --ifm-menu-color-background-active: linear-gradient( 90deg, #793ae8 0%, #ff8c00 100% ); */ --ifm-color-primary-dark: #1f2b7f; --ifm-color-primary-darker: #16206b; --ifm-color-primary-darkest: #0d1456; --ifm-color-primary-light: #9066e3; --ifm-color-primary-lighter: #a37ff0; --ifm-color-primary-lightest: #b8a3f9; --ifm-code-font-size: 95%; --search-local-modal-background: #121a29; /* --search-local-highlight-color: var(--ifm-hover-overlay); */ --search-local-highlight-color: #6366f1; --search-local-hit-color: #fff; --search-local-hit-background: #2d3748; --search-local-hit-active-color: var(--ifm-color-primary); --ifm-input-border-color: #ccc; /* Default border color */ --ifm-input-border-focus-color: red; /* Border color when focused */ --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); --ifm-navbar-search-input-icon: url('data:image/svg+xml;utf8,'); } .table-of-contents__link--active, a:not( .card, .menu__link, .menu__link--sublist, .menu__link--sublist-item, .table-of-contents__link ) { /* color: #793ae8; */ color: #6366f1; } .card { background-color: rgb(31 41 55/0.9); } .theme-admonition a { color: #fff; } .menu__link--active, .menu__list-item-collapsible--active { background: linear-gradient(0deg, #8238e9 0%, #5d42e6 100%); } /* .tabs__item--active { background: linear-gradient(0deg, #8238e9 0%, #5d42e6 100%); } */ .tabs__item { border-bottom: 1px solid #fff; border-bottom-left-radius: 0; border-bottom-right-radius: 0; padding: 0.5rem 1rem; } .tabs__item--active { /* background: rgba(255, 255, 255, 0.1); */ color: #a37ff0; border-bottom: 3px solid #a37ff0; } .footer { background: var(--ifm-navbar-background-color); border-top: 1px solid #2d3748; } ================================================ FILE: gen-docs/static/.nojekyll ================================================ ================================================ FILE: gen-docs/static/CNAME ================================================ docs.seerr.dev ================================================ FILE: gen-docs/tailwind.config.js ================================================ module.exports = { content: ['./src/components/**/*.{ts,tsx}'], theme: { extend: {}, }, plugins: [], }; ================================================ FILE: gen-docs/tsconfig.json ================================================ { // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@docusaurus/tsconfig", "compilerOptions": { "baseUrl": "." } } ================================================ FILE: next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. ================================================ FILE: next.config.js ================================================ /** * @type {import('next').NextConfig} */ module.exports = { env: { commitTag: process.env.COMMIT_TAG || 'local', }, images: { remotePatterns: [ { hostname: 'gravatar.com' }, { hostname: 'image.tmdb.org' }, { hostname: 'artworks.thetvdb.com' }, { hostname: 'plex.tv' }, ], }, webpack(config) { config.module.rules.push({ test: /\.svg$/, issuer: /\.(js|ts)x?$/, use: ['@svgr/webpack'], }); return config; }, experimental: { scrollRestoration: true, largePageDataBytes: 512 * 1000, }, }; ================================================ FILE: package.json ================================================ { "name": "seerr", "version": "0.1.0", "private": true, "packageManager": "pnpm@10.24.0", "scripts": { "preinstall": "npx only-allow pnpm", "postinstall": "next telemetry disable", "dev": "nodemon -e ts --watch server --watch seerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts", "build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json", "build:next": "next build", "build": "pnpm build:next && pnpm build:server", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", "lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix", "test": "node server/test/index.mts", "start": "NODE_ENV=production node dist/index.js", "i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts", "migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts", "migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts", "migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts", "format": "prettier --log-level warn --write --cache .", "format:check": "prettier --check --cache .", "typecheck": "pnpm typecheck:server && pnpm typecheck:client", "typecheck:server": "tsc --project server/tsconfig.json --noEmit", "typecheck:client": "tsc --noEmit", "prepare": "node bin/prepare.js", "cypress:open": "cypress open", "cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts", "cypress:build": "pnpm build && pnpm cypress:prepare" }, "repository": { "type": "git", "url": "https://github.com/seerr-team/seerr.git" }, "license": "MIT", "dependencies": { "@dr.pogodin/csurf": "^1.16.6", "@fontsource-variable/inter": "^5.2.8", "@formatjs/intl-displaynames": "6.8.13", "@formatjs/intl-locale": "3.1.1", "@formatjs/intl-pluralrules": "5.4.6", "@formatjs/intl-utils": "3.8.4", "@headlessui/react": "1.7.12", "@heroicons/react": "2.2.0", "@seerr-team/react-tailwindcss-datepicker": "^1.3.4", "@supercharge/request-ip": "1.2.0", "@svgr/webpack": "8.1.0", "@tanem/react-nprogress": "5.0.56", "@types/ua-parser-js": "^0.7.36", "@types/wink-jaro-distance": "^2.0.2", "ace-builds": "1.43.4", "axios": "1.13.3", "axios-rate-limit": "1.4.0", "bcrypt": "6.0.0", "bowser": "2.13.1", "connect-typeorm": "1.1.4", "cookie-parser": "1.4.7", "copy-to-clipboard": "3.3.3", "country-flag-icons": "1.6.4", "cronstrue": "2.23.0", "date-fns": "2.29.3", "dns-caching": "^0.2.7", "email-templates": "12.0.3", "express": "4.21.2", "express-openapi-validator": "4.13.8", "express-rate-limit": "6.7.0", "express-session": "1.18.2", "formik": "^2.4.9", "gravatar-url": "3.1.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "lodash": "4.17.23", "mime": "3", "next": "^14.2.35", "node-cache": "5.1.2", "node-gyp": "9.3.1", "node-schedule": "2.1.1", "nodemailer": "7.0.12", "openpgp": "6.3.0", "pg": "8.17.2", "pug": "3.0.3", "react": "^18.3.1", "react-ace": "10.1.0", "react-animate-height": "2.1.2", "react-aria": "3.44.0", "react-dom": "^18.3.1", "react-intersection-observer": "9.4.3", "react-intl": "^6.6.8", "react-markdown": "8.0.5", "react-popper-tooltip": "4.4.2", "react-select": "5.10.2", "react-spring": "9.7.1", "react-toast-notifications": "2.5.1", "react-transition-group": "^4.4.5", "react-truncate-markup": "5.1.2", "react-use-clipboard": "1.0.9", "reflect-metadata": "0.1.13", "secure-random-password": "0.2.3", "semver": "7.7.3", "sharp": "^0.33.4", "sqlite3": "5.1.7", "swagger-ui-express": "4.6.2", "swr": "2.3.8", "tailwind-merge": "^2.6.0", "typeorm": "0.3.28", "ua-parser-js": "^1.0.35", "undici": "^7.18.2", "validator": "^13.15.23", "web-push": "3.6.7", "wink-jaro-distance": "^2.0.0", "winston": "3.19.0", "winston-daily-rotate-file": "4.7.1", "xml2js": "0.5.0", "yamljs": "0.3.0", "yup": "0.32.11", "zod": "4.3.6" }, "devDependencies": { "@commitlint/cli": "17.4.4", "@commitlint/config-conventional": "17.4.4", "@eslint/js": "9.39.3", "@next/eslint-plugin-next": "^16.1.6", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.16", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", "@types/country-flag-icons": "1.2.2", "@types/csurf": "1.11.5", "@types/email-templates": "10.0.4", "@types/eslint-plugin-jsx-a11y": "^6.10.1", "@types/express": "4.17.17", "@types/express-session": "1.18.2", "@types/lodash": "4.17.21", "@types/mime": "3", "@types/node": "22.10.5", "@types/node-schedule": "2.1.8", "@types/nodemailer": "7", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/react-transition-group": "4.4.12", "@types/secure-random-password": "0.2.1", "@types/semver": "7.7.1", "@types/supertest": "^6.0.3", "@types/swagger-ui-express": "4.1.8", "@types/validator": "^13.15.10", "@types/web-push": "3.6.4", "@types/xml2js": "0.4.14", "@types/yamljs": "0.2.31", "@types/yup": "0.29.14", "autoprefixer": "^10.4.23", "baseline-browser-mapping": "^2.8.32", "commander": "^14.0.3", "commitizen": "4.3.1", "copyfiles": "2.4.1", "cy-mobile-commands": "0.3.0", "cypress": "14.5.4", "cz-conventional-changelog": "3.3.0", "eslint": "9.39.3", "eslint-config-prettier": "10.1.8", "eslint-plugin-formatjs": "6.2.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-no-relative-import-paths": "1.6.1", "eslint-plugin-prettier": "4.2.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.0.1", "globals": "^17.3.0", "husky": "8.0.3", "jiti": "^2.6.1", "lint-staged": "13.1.2", "nodemon": "3.1.11", "postcss": "^8.5.6", "prettier": "3.8.1", "prettier-plugin-organize-imports": "4.3.0", "prettier-plugin-tailwindcss": "0.6.14", "supertest": "^7.2.2", "tailwindcss": "3.4.19", "ts-node": "10.9.2", "tsc-alias": "1.8.16", "tsconfig-paths": "4.2.0", "typescript": "5.4.5", "typescript-eslint": "^8.56.1" }, "engines": { "node": "^22.0.0", "pnpm": "^10.0.0" }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } }, "lint-staged": { "**/*.{ts,tsx,js}": [ "prettier --write", "eslint" ], "**/*.{json,md,css}": [ "prettier --write" ] }, "commitlint": { "extends": [ "@commitlint/config-conventional" ] }, "pnpm": { "onlyBuiltDependencies": [ "@swc/core", "bcrypt", "cypress", "sharp", "sqlite3" ], "overrides": { "sqlite3>node-gyp": "8.4.1", "@types/express-session": "1.18.2" } } } ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: public/offline.html ================================================ You are offline

You are offline

================================================ FILE: public/robots.txt ================================================ User-agent: * Disallow: / ================================================ FILE: public/site.webmanifest ================================================ { "name": "Seerr", "short_name": "Seerr", "start_url": "./", "icons": [ { "src": "./android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "./android-chrome-192x192_maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "./android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "./android-chrome-512x512_maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "theme_color": "#1f2937", "background_color": "#1f2937", "display": "standalone", "shortcuts": [ { "name": "Discover", "url": "./", "icons": [ { "src": "./sparkles-icon-192x192.png", "sizes": "192x192", "type": "image/png" } ] }, { "name": "Requests", "url": "./requests", "icons": [ { "src": "./clock-icon-192x192.png", "sizes": "192x192", "type": "image/png" } ] }, { "name": "Profile", "url": "./profile", "icons": [ { "src": "./user-icon-192x192.png", "sizes": "192x192", "type": "image/png" } ] }, { "name": "Settings", "url": "./profile/settings", "icons": [ { "src": "./cog-icon-192x192.png", "sizes": "192x192", "type": "image/png" } ] } ] } ================================================ FILE: public/sw.js ================================================ /* eslint-disable no-undef */ // Incrementing OFFLINE_VERSION will kick off the install event and force // previously cached resources to be updated from the network. // This variable is intentionally declared and unused. // eslint-disable-next-line @typescript-eslint/no-unused-vars const OFFLINE_VERSION = 5; const CACHE_NAME = 'offline'; // Customize this with a different URL if needed. const OFFLINE_URL = '/offline.html'; self.addEventListener('install', (event) => { event.waitUntil( (async () => { const cache = await caches.open(CACHE_NAME); // Setting {cache: 'reload'} in the new request will ensure that the // response isn't fulfilled from the HTTP cache; i.e., it will be from // the network. await cache.add(new Request(OFFLINE_URL, { cache: 'reload' })); })() ); // Force the waiting service worker to become the active service worker. self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil( (async () => { // Enable navigation preload if it's supported. // See https://developers.google.com/web/updates/2017/02/navigation-preload if ('navigationPreload' in self.registration) { await self.registration.navigationPreload.enable(); } })() ); // Tell the active service worker to take control of the page immediately. clients.claim(); }); self.addEventListener('fetch', (event) => { // We only want to call event.respondWith() if this is a navigation request // for an HTML page. if (event.request.mode === 'navigate') { event.respondWith( (async () => { try { // First, try to use the navigation preload response if it's supported. const preloadResponse = await event.preloadResponse; if (preloadResponse) { return preloadResponse; } // Always try the network first. const networkResponse = await fetch(event.request); return networkResponse; } catch (error) { // catch is only triggered if an exception is thrown, which is likely // due to a network error. // If fetch() returns a valid HTTP response with a response code in // the 4xx or 5xx range, the catch() will NOT be called. // eslint-disable-next-line no-console console.log('Fetch failed; returning offline page instead.', error); const cache = await caches.open(CACHE_NAME); const cachedResponse = await cache.match(OFFLINE_URL); return cachedResponse; } })() ); } }); self.addEventListener('push', (event) => { const payload = event.data ? event.data.json() : {}; const options = { body: payload.message, badge: 'badge-128x128.png', icon: payload.image ? payload.image : 'android-chrome-192x192.png', vibrate: [100, 50, 100], data: { dateOfArrival: Date.now(), primaryKey: '2', actionUrl: payload.actionUrl, requestId: payload.requestId, }, actions: [], }; if (payload.actionUrl) { options.actions.push({ action: 'view', title: payload.actionUrlTitle ?? 'View', }); } if (payload.notificationType === 'MEDIA_PENDING') { options.actions.push( { action: 'approve', title: 'Approve', }, { action: 'decline', title: 'Decline', } ); } // Set the badge with the amount of pending requests // Only update the badge if the payload confirms they are the admin if ( (payload.notificationType === 'MEDIA_APPROVED' || payload.notificationType === 'MEDIA_DECLINED') && payload.isAdmin ) { if ('setAppBadge' in navigator) { navigator.setAppBadge(payload.pendingRequestsCount); } return; } if (payload.notificationType === 'MEDIA_PENDING') { if ('setAppBadge' in navigator) { navigator.setAppBadge(payload.pendingRequestsCount); } } event.waitUntil(self.registration.showNotification(payload.subject, options)); }); self.addEventListener( 'notificationclick', (event) => { const notificationData = event.notification.data; event.notification.close(); if (event.action === 'approve') { fetch(`/api/v1/request/${notificationData.requestId}/approve`, { method: 'POST', }); } else if (event.action === 'decline') { fetch(`/api/v1/request/${notificationData.requestId}/decline`, { method: 'POST', }); } if (notificationData.actionUrl) { clients.openWindow(notificationData.actionUrl); } }, false ); ================================================ FILE: seerr-api.yml ================================================ openapi: '3.0.2' info: title: 'Seerr API' version: '1.0.0' description: | This is the documentation for the Seerr API backend. Two primary authentication methods are supported: - **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie. - **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Seerr. tags: - name: public description: Public API endpoints requiring no authentication. - name: settings description: Endpoints related to Seerr's settings and configuration. - name: auth description: Endpoints related to logging in or out, and the currently authenticated user. - name: users description: Endpoints related to user management. - name: search description: Endpoints related to search and discovery. - name: request description: Endpoints related to request management. - name: movies description: Endpoints related to retrieving movies and their details. - name: tv description: Endpoints related to retrieving TV series and their details. - name: other description: Endpoints related to other TMDB data - name: person description: Endpoints related to retrieving person details. - name: media description: Endpoints related to media management. - name: collection description: Endpoints related to retrieving collection details. - name: service description: Endpoints related to getting service (Radarr/Sonarr) details. - name: watchlist description: Collection of media to watch later - name: blocklist description: Blocklisted media from discovery page. servers: - url: '{server}/api/v1' variables: server: default: http://localhost:5055 components: schemas: Blocklist: type: object properties: tmdbId: type: number example: 1 title: type: string media: $ref: '#/components/schemas/MediaInfo' userId: type: number example: 1 Watchlist: type: object properties: id: type: integer example: 1 readOnly: true tmdbId: type: number example: 1 ratingKey: type: string type: type: string title: type: string media: $ref: '#/components/schemas/MediaInfo' createdAt: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true updatedAt: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true requestedBy: $ref: '#/components/schemas/User' User: type: object properties: id: type: integer example: 1 readOnly: true email: type: string example: 'hey@itsme.com' readOnly: true username: type: string plexUsername: type: string readOnly: true plexToken: type: string readOnly: true jellyfinAuthToken: type: string readOnly: true userType: type: integer example: 1 readOnly: true permissions: type: number example: 0 avatar: type: string readOnly: true createdAt: type: string example: '2020-09-02T05:02:23.000Z' readOnly: true updatedAt: type: string example: '2020-09-02T05:02:23.000Z' readOnly: true requestCount: type: number example: 5 readOnly: true required: - id - email - createdAt - updatedAt UserSettings: type: object properties: username: type: string nullable: true example: 'Mr User' email: type: string example: 'user@example.com' discordId: type: string nullable: true example: '123456789' locale: type: string nullable: true example: 'en' discoverRegion: type: string nullable: true example: 'US' streamingRegion: type: string nullable: true example: 'US' originalLanguage: type: string nullable: true example: 'en' movieQuotaLimit: type: number nullable: true description: 'Maximum number of movie requests allowed' example: 10 movieQuotaDays: type: number nullable: true description: 'Time period in days for movie quota' example: 30 tvQuotaLimit: type: number nullable: true description: 'Maximum number of TV requests allowed' example: 5 tvQuotaDays: type: number nullable: true description: 'Time period in days for TV quota' example: 14 globalMovieQuotaDays: type: number nullable: true description: 'Global movie quota days setting' example: 30 globalMovieQuotaLimit: type: number nullable: true description: 'Global movie quota limit setting' example: 10 globalTvQuotaLimit: type: number nullable: true description: 'Global TV quota limit setting' example: 5 globalTvQuotaDays: type: number nullable: true description: 'Global TV quota days setting' example: 14 watchlistSyncMovies: type: boolean nullable: true description: 'Enable watchlist sync for movies' example: true watchlistSyncTv: type: boolean nullable: true description: 'Enable watchlist sync for TV' example: false MainSettings: type: object properties: apiKey: type: string readOnly: true appLanguage: type: string example: en applicationTitle: type: string example: Seerr applicationUrl: type: string example: https://os.example.com hideAvailable: type: boolean example: false partialRequestsEnabled: type: boolean example: false localLogin: type: boolean example: true mediaServerType: type: number example: 1 newPlexLogin: type: boolean example: true defaultPermissions: type: number example: 32 enableSpecialEpisodes: type: boolean example: false NetworkSettings: type: object properties: csrfProtection: type: boolean example: false forceIpv4First: type: boolean example: false trustProxy: type: boolean example: false proxy: type: object properties: enabled: type: boolean example: false hostname: type: string example: '' port: type: number example: 8080 useSsl: type: boolean example: false user: type: string example: '' password: type: string example: '' bypassFilter: type: string example: '' bypassLocalAddresses: type: boolean example: true dnsCache: type: object properties: enabled: type: boolean example: false forceMinTtl: type: number example: 0 forceMaxTtl: type: number example: -1 PlexLibrary: type: object properties: id: type: string name: type: string example: Movies enabled: type: boolean example: false required: - id - name - enabled PlexSettings: type: object properties: name: type: string example: 'Main Server' readOnly: true machineId: type: string example: '1234123412341234' readOnly: true ip: type: string example: '127.0.0.1' port: type: number example: 32400 useSsl: type: boolean nullable: true libraries: type: array readOnly: true items: $ref: '#/components/schemas/PlexLibrary' webAppUrl: type: string nullable: true example: 'https://app.plex.tv/desktop' required: - name - machineId - ip - port PlexConnection: type: object properties: protocol: type: string example: 'https' address: type: string example: '127.0.0.1' port: type: number example: 32400 uri: type: string example: 'https://127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct:32400' local: type: boolean example: true status: type: number example: 200 message: type: string example: 'OK' required: - protocol - address - port - uri - local PlexDevice: type: object properties: name: type: string example: 'My Plex Server' product: type: string example: 'Plex Media Server' productVersion: type: string example: '1.21' platform: type: string example: 'Linux' platformVersion: type: string example: 'default/linux/amd64/17.1/systemd' device: type: string example: 'PC' clientIdentifier: type: string example: '85a943ce-a0cc-4d2a-a4ec-f74f06e40feb' createdAt: type: string example: '2021-01-01T00:00:00.000Z' lastSeenAt: type: string example: '2021-01-01T00:00:00.000Z' provides: type: array items: type: string example: 'server' owned: type: boolean example: true ownerID: type: string example: '12345' home: type: boolean example: true sourceTitle: type: string example: 'xyzabc' accessToken: type: string example: 'supersecretaccesstoken' publicAddress: type: string example: '127.0.0.1' httpsRequired: type: boolean example: true synced: type: boolean example: true relay: type: boolean example: true dnsRebindingProtection: type: boolean example: false natLoopbackSupported: type: boolean example: false publicAddressMatches: type: boolean example: false presence: type: boolean example: true connection: type: array items: $ref: '#/components/schemas/PlexConnection' required: - name - product - productVersion - platform - device - clientIdentifier - createdAt - lastSeenAt - provides - owned - connection JellyfinLibrary: type: object properties: id: type: string name: type: string example: Movies enabled: type: boolean example: false required: - id - name - enabled JellyfinSettings: type: object properties: name: type: string example: 'Main Server' readOnly: true hostname: type: string example: 'http://my.jellyfin.host' externalHostname: type: string example: 'http://my.jellyfin.host' jellyfinForgotPasswordUrl: type: string example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html' adminUser: type: string example: 'admin' adminPass: type: string example: 'mypassword' libraries: type: array readOnly: true items: $ref: '#/components/schemas/JellyfinLibrary' serverID: type: string readOnly: true MetadataSettings: type: object properties: settings: type: object properties: tv: type: string enum: [tvdb, tmdb] example: 'tvdb' anime: type: string enum: [tvdb, tmdb] example: 'tvdb' TautulliSettings: type: object properties: hostname: type: string nullable: true example: 'tautulli.example.com' port: type: number nullable: true example: 8181 useSsl: type: boolean nullable: true apiKey: type: string nullable: true externalUrl: type: string nullable: true RadarrSettings: type: object properties: id: type: number example: 0 readOnly: true name: type: string example: 'Radarr Main' hostname: type: string example: '127.0.0.1' port: type: number example: 7878 apiKey: type: string example: 'exampleapikey' useSsl: type: boolean example: false baseUrl: type: string activeProfileId: type: number example: 1 activeProfileName: type: string example: 720p/1080p activeDirectory: type: string example: '/movies' is4k: type: boolean example: false minimumAvailability: type: string example: 'In Cinema' isDefault: type: boolean example: false externalUrl: type: string example: http://radarr.example.com syncEnabled: type: boolean example: false preventSearch: type: boolean example: false required: - name - hostname - port - apiKey - useSsl - activeProfileId - activeProfileName - activeDirectory - is4k - minimumAvailability - isDefault SonarrSettings: type: object properties: id: type: number example: 0 readOnly: true name: type: string example: 'Sonarr Main' hostname: type: string example: '127.0.0.1' port: type: number example: 8989 apiKey: type: string example: 'exampleapikey' useSsl: type: boolean example: false baseUrl: type: string activeProfileId: type: number example: 1 activeProfileName: type: string example: 720p/1080p activeDirectory: type: string example: '/tv/' activeLanguageProfileId: type: number example: 1 activeAnimeProfileId: type: number nullable: true activeAnimeLanguageProfileId: type: number nullable: true activeAnimeProfileName: type: string example: 720p/1080p nullable: true activeAnimeDirectory: type: string nullable: true is4k: type: boolean example: false enableSeasonFolders: type: boolean example: false isDefault: type: boolean example: false externalUrl: type: string example: http://radarr.example.com syncEnabled: type: boolean example: false preventSearch: type: boolean example: false required: - name - hostname - port - apiKey - useSsl - activeProfileId - activeProfileName - activeDirectory - is4k - enableSeasonFolders - isDefault ServarrTag: type: object properties: id: type: number example: 1 label: type: string example: A Label PublicSettings: type: object properties: initialized: type: boolean example: false MovieResult: type: object required: - id - mediaType - title properties: id: type: number example: 1234 mediaType: type: string popularity: type: number example: 10 posterPath: type: string backdropPath: type: string voteCount: type: number voteAverage: type: number genreIds: type: array items: type: number overview: type: string example: 'Overview of the movie' originalLanguage: type: string example: 'en' title: type: string example: Movie Title originalTitle: type: string example: Original Movie Title releaseDate: type: string adult: type: boolean example: false video: type: boolean example: false mediaInfo: $ref: '#/components/schemas/MediaInfo' TvResult: type: object properties: id: type: number example: 1234 mediaType: type: string popularity: type: number example: 10 posterPath: type: string backdropPath: type: string voteCount: type: number voteAverage: type: number genreIds: type: array items: type: number overview: type: string example: 'Overview of the movie' originalLanguage: type: string example: 'en' name: type: string example: TV Show Name originalName: type: string example: Original TV Show Name originCountry: type: array items: type: string firstAirDate: type: string mediaInfo: $ref: '#/components/schemas/MediaInfo' PersonResult: type: object properties: id: type: number example: 12345 profilePath: type: string adult: type: boolean example: false mediaType: type: string default: 'person' knownFor: type: array items: oneOf: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' Genre: type: object properties: id: type: number example: 1 name: type: string example: Adventure Company: type: object properties: id: type: number example: 1 logo_path: type: string nullable: true name: type: string ProductionCompany: type: object properties: id: type: number example: 1 logoPath: type: string nullable: true originCountry: type: string name: type: string Network: type: object properties: id: type: number example: 1 logoPath: type: string nullable: true originCountry: type: string name: type: string RelatedVideo: type: object properties: url: type: string example: https://www.youtube.com/watch?v=9qhL2_UxXM0/ key: type: string example: 9qhL2_UxXM0 name: type: string example: Trailer for some movie (1978) size: type: number example: 1080 type: type: string example: Trailer enum: - Clip - Teaser - Trailer - Featurette - Opening Credits - Behind the Scenes - Bloopers site: type: string enum: - 'YouTube' MovieDetails: type: object properties: id: type: number example: 123 readOnly: true imdbId: type: string example: 'tt123' adult: type: boolean backdropPath: type: string posterPath: type: string budget: type: number example: 1000000 genres: type: array items: $ref: '#/components/schemas/Genre' homepage: type: string relatedVideos: type: array items: $ref: '#/components/schemas/RelatedVideo' originalLanguage: type: string originalTitle: type: string overview: type: string popularity: type: number productionCompanies: type: array items: $ref: '#/components/schemas/ProductionCompany' productionCountries: type: array items: type: object properties: iso_3166_1: type: string name: type: string releaseDate: type: string releases: type: object properties: results: type: array items: type: object properties: iso_3166_1: type: string example: 'US' rating: type: string nullable: true release_dates: type: array items: type: object properties: certification: type: string example: 'PG-13' iso_639_1: type: string nullable: true note: type: string nullable: true example: 'Blu ray' release_date: type: string example: '2017-07-12T00:00:00.000Z' type: type: number example: 1 revenue: type: number nullable: true runtime: type: number spokenLanguages: type: array items: $ref: '#/components/schemas/SpokenLanguage' status: type: string tagline: type: string title: type: string video: type: boolean voteAverage: type: number voteCount: type: number credits: type: object properties: cast: type: array items: $ref: '#/components/schemas/Cast' crew: type: array items: $ref: '#/components/schemas/Crew' collection: type: object properties: id: type: number example: 1 name: type: string example: A collection posterPath: type: string backdropPath: type: string externalIds: $ref: '#/components/schemas/ExternalIds' mediaInfo: $ref: '#/components/schemas/MediaInfo' watchProviders: type: array items: $ref: '#/components/schemas/WatchProviders' Episode: type: object properties: id: type: number name: type: string airDate: type: string nullable: true episodeNumber: type: number overview: type: string productionCode: type: string seasonNumber: type: number showId: type: number stillPath: type: string nullable: true voteAverage: type: number voteCount: type: number Season: type: object properties: id: type: number airDate: type: string nullable: true episodeCount: type: number name: type: string overview: type: string posterPath: type: string seasonNumber: type: number episodes: type: array items: $ref: '#/components/schemas/Episode' TvDetails: type: object properties: id: type: number example: 123 backdropPath: type: string posterPath: type: string contentRatings: type: object properties: results: type: array items: type: object properties: iso_3166_1: type: string example: 'US' rating: type: string example: 'TV-14' createdBy: type: array items: type: object properties: id: type: number name: type: string gender: type: number profilePath: type: string nullable: true episodeRunTime: type: array items: type: number firstAirDate: type: string genres: type: array items: $ref: '#/components/schemas/Genre' homepage: type: string inProduction: type: boolean languages: type: array items: type: string lastAirDate: type: string lastEpisodeToAir: $ref: '#/components/schemas/Episode' name: type: string nextEpisodeToAir: $ref: '#/components/schemas/Episode' networks: type: array items: $ref: '#/components/schemas/ProductionCompany' numberOfEpisodes: type: number numberOfSeason: type: number originCountry: type: array items: type: string originalLanguage: type: string originalName: type: string overview: type: string popularity: type: number productionCompanies: type: array items: $ref: '#/components/schemas/ProductionCompany' productionCountries: type: array items: type: object properties: iso_3166_1: type: string name: type: string spokenLanguages: type: array items: $ref: '#/components/schemas/SpokenLanguage' seasons: type: array items: $ref: '#/components/schemas/Season' status: type: string tagline: type: string type: type: string voteAverage: type: number voteCount: type: number credits: type: object properties: cast: type: array items: $ref: '#/components/schemas/Cast' crew: type: array items: $ref: '#/components/schemas/Crew' externalIds: $ref: '#/components/schemas/ExternalIds' keywords: type: array items: $ref: '#/components/schemas/Keyword' mediaInfo: $ref: '#/components/schemas/MediaInfo' watchProviders: type: array items: $ref: '#/components/schemas/WatchProviders' MediaRequest: type: object properties: id: type: number example: 123 readOnly: true status: type: number example: 0 description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED readOnly: true media: $ref: '#/components/schemas/MediaInfo' createdAt: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true updatedAt: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true requestedBy: $ref: '#/components/schemas/User' modifiedBy: anyOf: - $ref: '#/components/schemas/User' - type: string nullable: true is4k: type: boolean example: false serverId: type: number profileId: type: number rootFolder: type: string required: - id - status MediaInfo: type: object properties: id: type: number readOnly: true tmdbId: type: number readOnly: true tvdbId: type: number readOnly: true nullable: true status: type: number example: 0 description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` requests: type: array readOnly: true items: $ref: '#/components/schemas/MediaRequest' createdAt: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true updatedAt: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true Cast: type: object properties: id: type: number example: 123 castId: type: number example: 1 character: type: string example: Some Character Name creditId: type: string gender: type: number name: type: string example: Some Persons Name order: type: number profilePath: type: string nullable: true Crew: type: object properties: id: type: number example: 123 creditId: type: string gender: type: number name: type: string example: Some Persons Name job: type: string department: type: string profilePath: type: string nullable: true ExternalIds: type: object properties: facebookId: type: string nullable: true freebaseId: type: string nullable: true freebaseMid: type: string nullable: true imdbId: type: string nullable: true instagramId: type: string nullable: true tvdbId: type: number nullable: true tvrageId: type: number nullable: true twitterId: type: string nullable: true ServiceProfile: type: object properties: id: type: number example: 1 name: type: string example: 720p/1080p PageInfo: type: object properties: page: type: number example: 1 pages: type: number example: 10 results: type: number example: 100 DiscordSettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: botUsername: type: string botAvatarUrl: type: string webhookUrl: type: string webhookRoleId: type: string enableMentions: type: boolean SlackSettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: webhookUrl: type: string WebPushSettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 WebhookSettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: webhookUrl: type: string authHeader: type: string jsonPayload: type: string supportVariables: type: boolean example: false TelegramSettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: botUsername: type: string botAPI: type: string chatId: type: string messageThreadId: type: string sendSilently: type: boolean PushbulletSettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: accessToken: type: string channelTag: type: string nullable: true PushoverSettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: accessToken: type: string userToken: type: string sound: type: string GotifySettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: url: type: string token: type: string NtfySettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: url: type: string topic: type: string authMethodUsernamePassword: type: boolean username: type: string password: type: string authMethodToken: type: boolean token: type: string NotificationEmailSettings: type: object properties: enabled: type: boolean example: false types: type: number example: 2 options: type: object properties: emailFrom: type: string example: no-reply@example.com senderName: type: string example: Seerr smtpHost: type: string example: 127.0.0.1 smtpPort: type: number example: 465 secure: type: boolean example: false ignoreTls: type: boolean example: false requireTls: type: boolean example: false authUser: type: string nullable: true authPass: type: string nullable: true allowSelfSigned: type: boolean example: false Job: type: object properties: id: type: string example: job-name type: type: string enum: [process, command] interval: type: string enum: [short, long, fixed] name: type: string example: A Job Name nextExecutionTime: type: string example: '2020-09-02T05:02:23.000Z' running: type: boolean example: false PersonDetails: type: object properties: id: type: number example: 1 name: type: string deathday: type: string knownForDepartment: type: string alsoKnownAs: type: array items: type: string gender: type: string biography: type: string popularity: type: string placeOfBirth: type: string profilePath: type: string adult: type: boolean imdbId: type: string homepage: type: string CreditCast: type: object properties: id: type: number example: 1 originalLanguage: type: string episodeCount: type: number overview: type: string originCountry: type: array items: type: string originalName: type: string voteCount: type: number name: type: string mediaType: type: string popularity: type: number creditId: type: string backdropPath: type: string firstAirDate: type: string voteAverage: type: number genreIds: type: array items: type: number posterPath: type: string originalTitle: type: string video: type: boolean title: type: string adult: type: boolean releaseDate: type: string character: type: string mediaInfo: $ref: '#/components/schemas/MediaInfo' CreditCrew: type: object properties: id: type: number example: 1 originalLanguage: type: string episodeCount: type: number overview: type: string originCountry: type: array items: type: string originalName: type: string voteCount: type: number name: type: string mediaType: type: string popularity: type: number creditId: type: string backdropPath: type: string firstAirDate: type: string voteAverage: type: number genreIds: type: array items: type: number posterPath: type: string originalTitle: type: string video: type: boolean title: type: string adult: type: boolean releaseDate: type: string department: type: string job: type: string mediaInfo: $ref: '#/components/schemas/MediaInfo' Keyword: type: object properties: id: type: number example: 1 name: type: string example: 'anime' SpokenLanguage: type: object properties: englishName: type: string example: 'English' nullable: true iso_639_1: type: string example: 'en' name: type: string example: 'English' Collection: type: object properties: id: type: number example: 123 name: type: string example: A Movie Collection overview: type: string example: Overview of collection posterPath: type: string backdropPath: type: string parts: type: array items: $ref: '#/components/schemas/MovieResult' SonarrSeries: type: object properties: title: type: string example: COVID-25 sortTitle: type: string example: covid 25 seasonCount: type: number example: 1 status: type: string example: upcoming overview: type: string example: The thread is picked up again by Marianne Schmidt which ... network: type: string example: CBS airTime: type: string example: 02:15 images: type: array items: type: object properties: coverType: type: string example: banner url: type: string example: /sonarr/MediaCoverProxy/6467f05d9872726ad08cbf920e5fee4bf69198682260acab8eab5d3c2c958e92/5c8f116c6aa5c.jpg remotePoster: type: string example: https://artworks.thetvdb.com/banners/posters/5c8f116129983.jpg seasons: type: array items: type: object properties: seasonNumber: type: number example: 1 monitored: type: boolean example: true year: type: number example: 2015 path: type: string profileId: type: number languageProfileId: type: number seasonFolder: type: boolean monitored: type: boolean useSceneNumbering: type: boolean runtime: type: number tvdbId: type: number example: 12345 tvRageId: type: number tvMazeId: type: number firstAired: type: string lastInfoSync: type: string nullable: true seriesType: type: string cleanTitle: type: string imdbId: type: string titleSlug: type: string certification: type: string genres: type: array items: type: string tags: type: array items: type: string added: type: string ratings: type: array items: type: object properties: votes: type: number value: type: number qualityProfileId: type: number id: type: number nullable: true rootFolderPath: type: string nullable: true addOptions: type: array items: type: object properties: ignoreEpisodesWithFiles: type: boolean nullable: true ignoreEpisodesWithoutFiles: type: boolean nullable: true searchForMissingEpisodes: type: boolean nullable: true UserSettingsNotifications: type: object properties: notificationTypes: $ref: '#/components/schemas/NotificationAgentTypes' emailEnabled: type: boolean pgpKey: type: string nullable: true discordEnabled: type: boolean discordEnabledTypes: type: number nullable: true discordId: type: string nullable: true pushbulletAccessToken: type: string nullable: true pushoverApplicationToken: type: string nullable: true pushoverUserKey: type: string nullable: true pushoverSound: type: string nullable: true telegramEnabled: type: boolean telegramBotUsername: type: string nullable: true telegramChatId: type: string nullable: true telegramMessageThreadId: type: string nullable: true telegramSendSilently: type: boolean nullable: true NotificationAgentTypes: type: object properties: discord: type: number email: type: number pushbullet: type: number pushover: type: number slack: type: number telegram: type: number webhook: type: number webpush: type: number WatchProviders: type: array items: type: object properties: iso_3166_1: type: string link: type: string buy: type: array items: $ref: '#/components/schemas/WatchProviderDetails' flatrate: items: $ref: '#/components/schemas/WatchProviderDetails' WatchProviderDetails: type: object properties: displayPriority: type: number logoPath: type: string id: type: number name: type: string Issue: type: object properties: id: type: number example: 1 issueType: type: number example: 1 media: $ref: '#/components/schemas/MediaInfo' createdBy: $ref: '#/components/schemas/User' modifiedBy: $ref: '#/components/schemas/User' comments: type: array items: $ref: '#/components/schemas/IssueComment' IssueComment: type: object properties: id: type: number example: 1 user: $ref: '#/components/schemas/User' message: type: string example: A comment DiscoverSlider: type: object properties: id: type: number example: 1 type: type: number example: 1 title: type: string nullable: true isBuiltIn: type: boolean enabled: type: boolean data: type: string example: '1234' nullable: true required: - type - enabled - title - data WatchProviderRegion: type: object properties: iso_3166_1: type: string english_name: type: string native_name: type: string OverrideRule: type: object properties: id: type: string Certification: type: object properties: certification: type: string example: 'PG-13' meaning: type: string example: 'Some material may be inappropriate for children under 13.' nullable: true order: type: number example: 3 nullable: true required: - certification CertificationResponse: type: object properties: certifications: type: object additionalProperties: type: array items: $ref: '#/components/schemas/Certification' example: certifications: US: - certification: 'G' meaning: 'All ages admitted' order: 1 - certification: 'PG' meaning: 'Some material may not be suitable for children under 10.' order: 2 securitySchemes: cookieAuth: type: apiKey name: connect.sid in: cookie apiKey: type: apiKey in: header name: X-Api-Key paths: /status: get: summary: Get Seerr status description: Returns the current Seerr status in a JSON object. security: [] tags: - public responses: '200': description: Returned status content: application/json: schema: type: object properties: version: type: string example: 1.0.0 commitTag: type: string updateAvailable: type: boolean commitsBehind: type: number restartRequired: type: boolean /status/appdata: get: summary: Get application data volume status description: For Docker installs, returns whether or not the volume mount was configured properly. Always returns true for non-Docker installs. security: [] tags: - public responses: '200': description: Application data volume status and path content: application/json: schema: type: object properties: appData: type: boolean example: true appDataPath: type: string example: /app/config appDataPermissions: type: boolean example: true /settings/main: get: summary: Get main settings description: Retrieves all main settings in a JSON object. tags: - settings responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/MainSettings' post: summary: Update main settings description: Updates main settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MainSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/MainSettings' /settings/network: get: summary: Get network settings description: Retrieves all network settings in a JSON object. tags: - settings responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/MainSettings' post: summary: Update network settings description: Updates network settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/NetworkSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/NetworkSettings' /settings/main/regenerate: post: summary: Get main settings with newly-generated API key description: Returns main settings in a JSON object, using the new API key. tags: - settings responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/MainSettings' /settings/jellyfin: get: summary: Get Jellyfin settings description: Retrieves current Jellyfin settings. tags: - settings responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/JellyfinSettings' post: summary: Update Jellyfin settings description: Updates Jellyfin settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/JellyfinSettings' responses: '200': description: 'Values were successfully updated' content: application/json: schema: $ref: '#/components/schemas/JellyfinSettings' /settings/jellyfin/library: get: summary: Get Jellyfin libraries description: Returns a list of Jellyfin libraries in a JSON array. tags: - settings parameters: - in: query name: sync description: Syncs the current libraries with the current Jellyfin server schema: type: string nullable: true - in: query name: enable explode: false allowReserved: true description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! schema: type: string nullable: true responses: '200': description: 'Jellyfin libraries returned' content: application/json: schema: type: array items: $ref: '#/components/schemas/JellyfinLibrary' /settings/jellyfin/users: get: summary: Get Jellyfin Users description: Returns a list of Jellyfin Users in a JSON array. tags: - settings - users responses: '200': description: Jellyfin users returned content: application/json: schema: type: array items: type: object properties: username: type: string id: type: string thumb: type: string email: type: string /settings/jellyfin/sync: get: summary: Get status of full Jellyfin library sync description: Returns sync progress in a JSON array. tags: - settings responses: '200': description: Status of Jellyfin sync content: application/json: schema: type: object properties: running: type: boolean example: false progress: type: number example: 0 total: type: number example: 100 currentLibrary: $ref: '#/components/schemas/JellyfinLibrary' libraries: type: array items: $ref: '#/components/schemas/JellyfinLibrary' post: summary: Start full Jellyfin library sync description: Runs a full Jellyfin library sync and returns the progress in a JSON array. tags: - settings requestBody: content: application/json: schema: type: object properties: cancel: type: boolean example: false start: type: boolean example: false responses: '200': description: Status of Jellyfin sync content: application/json: schema: type: object properties: running: type: boolean example: false progress: type: number example: 0 total: type: number example: 100 currentLibrary: $ref: '#/components/schemas/JellyfinLibrary' libraries: type: array items: $ref: '#/components/schemas/JellyfinLibrary' /settings/plex: get: summary: Get Plex settings description: Retrieves current Plex settings. tags: - settings responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/PlexSettings' post: summary: Update Plex settings description: Updates Plex settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PlexSettings' responses: '200': description: 'Values were successfully updated' content: application/json: schema: $ref: '#/components/schemas/PlexSettings' /settings/plex/library: get: summary: Get Plex libraries description: Returns a list of Plex libraries in a JSON array. tags: - settings parameters: - in: query name: sync description: Syncs the current libraries with the current Plex server schema: type: string nullable: true - in: query name: enable explode: false allowReserved: true description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! schema: type: string nullable: true responses: '200': description: 'Plex libraries returned' content: application/json: schema: type: array items: $ref: '#/components/schemas/PlexLibrary' /settings/plex/sync: get: summary: Get status of full Plex library scan description: Returns scan progress in a JSON array. tags: - settings responses: '200': description: Status of Plex scan content: application/json: schema: type: object properties: running: type: boolean example: false progress: type: number example: 0 total: type: number example: 100 currentLibrary: $ref: '#/components/schemas/PlexLibrary' libraries: type: array items: $ref: '#/components/schemas/PlexLibrary' post: summary: Start full Plex library scan description: Runs a full Plex library scan and returns the progress in a JSON array. tags: - settings requestBody: content: application/json: schema: type: object properties: cancel: type: boolean example: false start: type: boolean example: false responses: '200': description: Status of Plex scan content: application/json: schema: type: object properties: running: type: boolean example: false progress: type: number example: 0 total: type: number example: 100 currentLibrary: $ref: '#/components/schemas/PlexLibrary' libraries: type: array items: $ref: '#/components/schemas/PlexLibrary' /settings/plex/devices/servers: get: summary: Gets the user's available Plex servers description: Returns a list of available Plex servers and their connectivity state tags: - settings responses: '200': description: OK content: application/json: schema: type: array items: $ref: '#/components/schemas/PlexDevice' /settings/plex/users: get: summary: Get Plex users description: | Returns a list of Plex users in a JSON array. Requires the `MANAGE_USERS` permission. tags: - settings - users responses: '200': description: Plex users content: application/json: schema: type: array items: type: object properties: id: type: string title: type: string username: type: string email: type: string thumb: type: string /settings/metadatas: get: summary: Get Metadata settings description: Retrieves current Metadata settings. tags: - settings responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/MetadataSettings' put: summary: Update Metadata settings description: Updates Metadata settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MetadataSettings' responses: '200': description: 'Values were successfully updated' content: application/json: schema: $ref: '#/components/schemas/MetadataSettings' /settings/metadatas/test: post: summary: Test Provider configuration description: Tests if the TVDB configuration is valid. Returns a list of available languages on success. tags: - settings requestBody: required: true content: application/json: schema: type: object properties: tmdb: type: boolean example: true tvdb: type: boolean example: true responses: '200': description: Succesfully connected to TVDB content: application/json: schema: type: object properties: message: type: string example: 'Successfully connected to TVDB' /settings/tautulli: get: summary: Get Tautulli settings description: Retrieves current Tautulli settings. tags: - settings responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/TautulliSettings' post: summary: Update Tautulli settings description: Updates Tautulli settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TautulliSettings' responses: '200': description: 'Values were successfully updated' content: application/json: schema: $ref: '#/components/schemas/TautulliSettings' /settings/radarr: get: summary: Get Radarr settings description: Returns all Radarr settings in a JSON array. tags: - settings responses: '200': description: 'Values were returned' content: application/json: schema: type: array items: $ref: '#/components/schemas/RadarrSettings' post: summary: Create Radarr instance description: Creates a new Radarr instance from the request body. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RadarrSettings' responses: '201': description: 'New Radarr instance created' content: application/json: schema: $ref: '#/components/schemas/RadarrSettings' /settings/radarr/test: post: summary: Test Radarr configuration description: Tests if the Radarr configuration is valid. Returns profiles and root folders on success. tags: - settings requestBody: required: true content: application/json: schema: type: object properties: hostname: type: string example: '127.0.0.1' port: type: number example: 7878 apiKey: type: string example: yourapikey useSsl: type: boolean example: false baseUrl: type: string required: - hostname - port - apiKey - useSsl responses: '200': description: Succesfully connected to Radarr instance content: application/json: schema: type: object properties: profiles: type: array items: $ref: '#/components/schemas/ServiceProfile' /settings/radarr/{radarrId}: put: summary: Update Radarr instance description: Updates an existing Radarr instance with the provided values. tags: - settings parameters: - in: path name: radarrId required: true schema: type: integer description: Radarr instance ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RadarrSettings' responses: '200': description: 'Radarr instance updated' content: application/json: schema: $ref: '#/components/schemas/RadarrSettings' delete: summary: Delete Radarr instance description: Deletes an existing Radarr instance based on the radarrId parameter. tags: - settings parameters: - in: path name: radarrId required: true schema: type: integer description: Radarr instance ID responses: '200': description: 'Radarr instance updated' content: application/json: schema: $ref: '#/components/schemas/RadarrSettings' /settings/radarr/{radarrId}/profiles: get: summary: Get available Radarr profiles description: Returns a list of profiles available on the Radarr server instance in a JSON array. tags: - settings parameters: - in: path name: radarrId required: true schema: type: integer description: Radarr instance ID responses: '200': description: Returned list of profiles content: application/json: schema: type: array items: $ref: '#/components/schemas/ServiceProfile' /settings/sonarr: get: summary: Get Sonarr settings description: Returns all Sonarr settings in a JSON array. tags: - settings responses: '200': description: 'Values were returned' content: application/json: schema: type: array items: $ref: '#/components/schemas/SonarrSettings' post: summary: Create Sonarr instance description: Creates a new Sonarr instance from the request body. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SonarrSettings' responses: '201': description: 'New Sonarr instance created' content: application/json: schema: $ref: '#/components/schemas/SonarrSettings' /settings/sonarr/test: post: summary: Test Sonarr configuration description: Tests if the Sonarr configuration is valid. Returns profiles and root folders on success. tags: - settings requestBody: required: true content: application/json: schema: type: object properties: hostname: type: string example: '127.0.0.1' port: type: number example: 8989 apiKey: type: string example: yourapikey useSsl: type: boolean example: false baseUrl: type: string required: - hostname - port - apiKey - useSsl responses: '200': description: Succesfully connected to Sonarr instance content: application/json: schema: type: object properties: profiles: type: array items: $ref: '#/components/schemas/ServiceProfile' /settings/sonarr/{sonarrId}: put: summary: Update Sonarr instance description: Updates an existing Sonarr instance with the provided values. tags: - settings parameters: - in: path name: sonarrId required: true schema: type: integer description: Sonarr instance ID requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SonarrSettings' responses: '200': description: 'Sonarr instance updated' content: application/json: schema: $ref: '#/components/schemas/SonarrSettings' delete: summary: Delete Sonarr instance description: Deletes an existing Sonarr instance based on the sonarrId parameter. tags: - settings parameters: - in: path name: sonarrId required: true schema: type: integer description: Sonarr instance ID responses: '200': description: 'Sonarr instance updated' content: application/json: schema: $ref: '#/components/schemas/SonarrSettings' /settings/public: get: summary: Get public settings security: [] description: Returns settings that are not protected or sensitive. Mainly used to determine if the application has been configured for the first time. tags: - settings responses: '200': description: Public settings returned content: application/json: schema: $ref: '#/components/schemas/PublicSettings' /settings/initialize: post: summary: Initialize application description: Sets the app as initialized, allowing the user to navigate to pages other than the setup page. tags: - settings responses: '200': description: Public settings returned content: application/json: schema: $ref: '#/components/schemas/PublicSettings' /settings/jobs: get: summary: Get scheduled jobs description: Returns list of all scheduled jobs and details about their next execution time in a JSON array. tags: - settings responses: '200': description: Scheduled jobs returned content: application/json: schema: type: array items: $ref: '#/components/schemas/Job' /settings/jobs/{jobId}/run: post: summary: Invoke a specific job description: Invokes a specific job to run. Will return the new job status in JSON format. tags: - settings parameters: - in: path name: jobId required: true schema: type: string responses: '200': description: Invoked job returned content: application/json: schema: $ref: '#/components/schemas/Job' /settings/jobs/{jobId}/cancel: post: summary: Cancel a specific job description: Cancels a specific job. Will return the new job status in JSON format. tags: - settings parameters: - in: path name: jobId required: true schema: type: string responses: '200': description: Canceled job returned content: application/json: schema: $ref: '#/components/schemas/Job' /settings/jobs/{jobId}/schedule: post: summary: Modify job schedule description: Re-registers the job with the schedule specified. Will return the job in JSON format. tags: - settings parameters: - in: path name: jobId required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: schedule: type: string example: '0 */5 * * * *' responses: '200': description: Rescheduled job content: application/json: schema: $ref: '#/components/schemas/Job' /settings/cache: get: summary: Get a list of active caches description: Retrieves a list of all active caches and their current stats. tags: - settings responses: '200': description: Caches returned content: application/json: schema: type: object properties: imageCache: type: object properties: tmdb: type: object properties: size: type: number example: 123456 imageCount: type: number example: 123 avatar: type: object properties: size: type: number example: 123456 imageCount: type: number example: 123 dnsCache: type: object properties: stats: type: object properties: size: type: number example: 1 maxSize: type: number example: 500 hits: type: number example: 19 misses: type: number example: 1 failures: type: number example: 0 ipv4Fallbacks: type: number example: 0 hitRate: type: number example: 0.95 entries: type: array additionalProperties: type: object properties: addresses: type: object properties: ipv4: type: number example: 1 ipv6: type: number example: 1 activeAddress: type: string example: 127.0.0.1 family: type: number example: 4 age: type: number example: 10 ttl: type: number example: 10 networkErrors: type: number example: 0 hits: type: number example: 1 misses: type: number example: 1 apiCaches: type: array items: type: object properties: id: type: string example: cache-id name: type: string example: cache name stats: type: object properties: hits: type: number misses: type: number keys: type: number ksize: type: number vsize: type: number /settings/cache/{cacheId}/flush: post: summary: Flush a specific cache description: Flushes all data from the cache ID provided tags: - settings parameters: - in: path name: cacheId required: true schema: type: string responses: '204': description: 'Flushed cache' /settings/cache/dns/{dnsEntry}/flush: post: summary: Flush a specific DNS cache entry description: Flushes a specific DNS cache entry tags: - settings parameters: - in: path name: dnsEntry required: true schema: type: string responses: '204': description: 'Flushed dns cache' /settings/logs: get: summary: Returns logs description: Returns list of all log items and details tags: - settings parameters: - in: query name: take schema: type: number nullable: true example: 25 - in: query name: skip schema: type: number nullable: true example: 0 - in: query name: filter schema: type: string nullable: true enum: [debug, info, warn, error] default: debug - in: query name: search schema: type: string nullable: true example: plex responses: '200': description: Server log returned content: application/json: schema: type: array items: type: object properties: label: type: string example: server level: type: string example: info message: type: string example: Server ready on port 5055 timestamp: type: string example: '2020-12-15T16:20:00.069Z' /settings/notifications/email: get: summary: Get email notification settings description: Returns current email notification settings in a JSON object. tags: - settings responses: '200': description: Returned email settings content: application/json: schema: $ref: '#/components/schemas/NotificationEmailSettings' post: summary: Update email notification settings description: Updates email notification settings with provided values tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/NotificationEmailSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/NotificationEmailSettings' /settings/notifications/email/test: post: summary: Test email settings description: Sends a test notification to the email agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/NotificationEmailSettings' responses: '204': description: Test notification attempted /settings/notifications/discord: get: summary: Get Discord notification settings description: Returns current Discord notification settings in a JSON object. tags: - settings responses: '200': description: Returned Discord settings content: application/json: schema: $ref: '#/components/schemas/DiscordSettings' post: summary: Update Discord notification settings description: Updates Discord notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DiscordSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/DiscordSettings' /settings/notifications/discord/test: post: summary: Test Discord settings description: Sends a test notification to the Discord agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DiscordSettings' responses: '204': description: Test notification attempted /settings/notifications/pushbullet: get: summary: Get Pushbullet notification settings description: Returns current Pushbullet notification settings in a JSON object. tags: - settings responses: '200': description: Returned Pushbullet settings content: application/json: schema: $ref: '#/components/schemas/PushbulletSettings' post: summary: Update Pushbullet notification settings description: Update Pushbullet notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PushbulletSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/PushbulletSettings' /settings/notifications/pushbullet/test: post: summary: Test Pushbullet settings description: Sends a test notification to the Pushbullet agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PushbulletSettings' responses: '204': description: Test notification attempted /settings/notifications/pushover: get: summary: Get Pushover notification settings description: Returns current Pushover notification settings in a JSON object. tags: - settings responses: '200': description: Returned Pushover settings content: application/json: schema: $ref: '#/components/schemas/PushoverSettings' post: summary: Update Pushover notification settings description: Update Pushover notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PushoverSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/PushoverSettings' /settings/notifications/pushover/test: post: summary: Test Pushover settings description: Sends a test notification to the Pushover agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PushoverSettings' responses: '204': description: Test notification attempted /settings/notifications/pushover/sounds: get: summary: Get Pushover sounds description: Returns valid Pushover sound options in a JSON array. tags: - settings parameters: - in: query name: token required: true schema: type: string nullable: false responses: '200': description: Returned Pushover settings content: application/json: schema: type: array items: type: object properties: name: type: string description: type: string /settings/notifications/gotify: get: summary: Get Gotify notification settings description: Returns current Gotify notification settings in a JSON object. tags: - settings responses: '200': description: Returned Gotify settings content: application/json: schema: $ref: '#/components/schemas/GotifySettings' post: summary: Update Gotify notification settings description: Update Gotify notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GotifySettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/GotifySettings' /settings/notifications/gotify/test: post: summary: Test Gotify settings description: Sends a test notification to the Gotify agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GotifySettings' responses: '204': description: Test notification attempted /settings/notifications/ntfy: get: summary: Get ntfy.sh notification settings description: Returns current ntfy.sh notification settings in a JSON object. tags: - settings responses: '200': description: Returned ntfy.sh settings content: application/json: schema: $ref: '#/components/schemas/NtfySettings' post: summary: Update ntfy.sh notification settings description: Update ntfy.sh notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/NtfySettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/NtfySettings' /settings/notifications/ntfy/test: post: summary: Test ntfy.sh settings description: Sends a test notification to the ntfy.sh agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/NtfySettings' responses: '204': description: Test notification attempted /settings/notifications/slack: get: summary: Get Slack notification settings description: Returns current Slack notification settings in a JSON object. tags: - settings responses: '200': description: Returned slack settings content: application/json: schema: $ref: '#/components/schemas/SlackSettings' post: summary: Update Slack notification settings description: Updates Slack notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SlackSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/SlackSettings' /settings/notifications/slack/test: post: summary: Test Slack settings description: Sends a test notification to the Slack agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SlackSettings' responses: '204': description: Test notification attempted /settings/notifications/telegram: get: summary: Get Telegram notification settings description: Returns current Telegram notification settings in a JSON object. tags: - settings responses: '200': description: Returned Telegram settings content: application/json: schema: $ref: '#/components/schemas/TelegramSettings' post: summary: Update Telegram notification settings description: Update Telegram notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TelegramSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/TelegramSettings' /settings/notifications/telegram/test: post: summary: Test Telegram settings description: Sends a test notification to the Telegram agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TelegramSettings' responses: '204': description: Test notification attempted /settings/notifications/webpush: get: summary: Get Web Push notification settings description: Returns current Web Push notification settings in a JSON object. tags: - settings responses: '200': description: Returned web push settings content: application/json: schema: $ref: '#/components/schemas/WebPushSettings' post: summary: Update Web Push notification settings description: Updates Web Push notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WebPushSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/WebPushSettings' /settings/notifications/webpush/test: post: summary: Test Web Push settings description: Sends a test notification to the Web Push agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WebPushSettings' responses: '204': description: Test notification attempted /settings/notifications/webhook: get: summary: Get webhook notification settings description: Returns current webhook notification settings in a JSON object. tags: - settings responses: '200': description: Returned webhook settings content: application/json: schema: $ref: '#/components/schemas/WebhookSettings' post: summary: Update webhook notification settings description: Updates webhook notification settings with the provided values. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WebhookSettings' responses: '200': description: 'Values were sucessfully updated' content: application/json: schema: $ref: '#/components/schemas/WebhookSettings' /settings/notifications/webhook/test: post: summary: Test webhook settings description: Sends a test notification to the webhook agent. tags: - settings requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/WebhookSettings' responses: '204': description: Test notification attempted /settings/discover: get: summary: Get all discover sliders description: Returns all discovery sliders. Built-in and custom made. tags: - settings responses: '200': description: Returned all discovery sliders content: application/json: schema: type: array items: $ref: '#/components/schemas/DiscoverSlider' post: summary: Batch update all sliders. description: | Batch update all sliders at once. Should also be used for creation. Will only update sliders provided and will not delete any sliders not present in the request. If a slider is missing a required field, it will be ignored. Requires the `ADMIN` permission. tags: - settings requestBody: required: true content: application/json: schema: type: array items: $ref: '#/components/schemas/DiscoverSlider' responses: '200': description: Returned all newly updated discovery sliders content: application/json: schema: type: array items: $ref: '#/components/schemas/DiscoverSlider' /settings/discover/{sliderId}: put: summary: Update a single slider description: | Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission. tags: - settings parameters: - in: path name: sliderId required: true schema: type: number requestBody: required: true content: application/json: schema: type: object properties: title: type: string example: 'Slider Title' type: type: number example: 1 data: type: string example: '1' responses: '200': description: Returns newly added discovery slider content: application/json: schema: $ref: '#/components/schemas/DiscoverSlider' delete: summary: Delete slider by ID description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission. tags: - settings parameters: - in: path name: sliderId required: true schema: type: number responses: '200': description: Slider successfully deleted content: application/json: schema: $ref: '#/components/schemas/DiscoverSlider' /settings/discover/add: post: summary: Add a new slider description: | Add a single slider and return the newly created slider. Requires the `ADMIN` permission. tags: - settings requestBody: required: true content: application/json: schema: type: object properties: title: type: string example: 'New Slider' type: type: number example: 1 data: type: string example: '1' responses: '200': description: Returns newly added discovery slider content: application/json: schema: $ref: '#/components/schemas/DiscoverSlider' /settings/discover/reset: get: summary: Reset all discover sliders description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission. tags: - settings responses: '204': description: All sliders reset to defaults /settings/about: get: summary: Get server stats description: Returns current server stats in a JSON object. tags: - settings responses: '200': description: Returned about settings content: application/json: schema: type: object properties: version: type: string example: '1.0.0' totalRequests: type: number example: 100 totalMediaItems: type: number example: 100 tz: type: string nullable: true example: Asia/Tokyo appDataPath: type: string example: /app/config /auth/me: get: summary: Get logged-in user description: Returns the currently logged-in user. tags: - auth - users responses: '200': description: Object containing the logged-in user in JSON content: application/json: schema: $ref: '#/components/schemas/User' /auth/plex: post: summary: Sign in using a Plex token description: Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions. security: [] tags: - auth responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/User' requestBody: required: true content: application/json: schema: type: object properties: authToken: type: string required: - authToken /auth/jellyfin: post: summary: Sign in using a Jellyfin username and password description: Takes the user's username and password to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the Jellyfin server, they will also have an account created, but without any permissions. security: [] tags: - auth responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/User' requestBody: required: true content: application/json: schema: type: object properties: username: type: string password: type: string hostname: type: string email: type: string serverType: type: number required: - username - password /auth/local: post: summary: Sign in using a local account description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests. security: [] tags: - auth responses: '200': description: OK content: application/json: schema: $ref: '#/components/schemas/User' requestBody: required: true content: application/json: schema: type: object properties: email: type: string password: type: string required: - email - password /auth/logout: post: summary: Sign out and clear session cookie description: Completely clear the session cookie and associated values, effectively signing the user out. tags: - auth responses: '200': description: OK content: application/json: schema: type: object properties: status: type: string example: 'ok' /auth/reset-password: post: summary: Send a reset password email description: Sends a reset password email to the email if the user exists security: [] tags: - users responses: '200': description: OK content: application/json: schema: type: object properties: status: type: string example: 'ok' requestBody: required: true content: application/json: schema: type: object properties: email: type: string required: - email /auth/reset-password/{guid}: post: summary: Reset the password for a user description: Resets the password for a user if the given guid is connected to a user security: [] tags: - users parameters: - in: path name: guid required: true schema: type: string example: '9afef5a7-ec89-4d5f-9397-261e96970b50' responses: '200': description: OK content: application/json: schema: type: object properties: status: type: string example: 'ok' requestBody: required: true content: application/json: schema: type: object properties: password: type: string required: - password /user: get: summary: Get all users description: Returns all users in a JSON object. tags: - users parameters: - in: query name: take schema: type: number nullable: true example: 20 - in: query name: skip schema: type: number nullable: true example: 0 - in: query name: sort schema: type: string enum: [created, updated, requests, displayname] default: created - in: query name: q required: false schema: type: string - in: query name: includeIds required: false schema: type: string responses: '200': description: A JSON array of all users content: application/json: schema: type: object properties: pageInfo: $ref: '#/components/schemas/PageInfo' results: type: array items: $ref: '#/components/schemas/User' post: summary: Create new user description: | Creates a new user. Requires the `MANAGE_USERS` permission. tags: - users requestBody: required: true content: application/json: schema: type: object properties: email: type: string example: 'hey@itsme.com' username: type: string permissions: type: number responses: '201': description: The created user content: application/json: schema: $ref: '#/components/schemas/User' put: summary: Update batch of users description: | Update users with given IDs with provided values in request `body.settings`. You cannot update users' Plex tokens through this request. Requires the `MANAGE_USERS` permission. tags: - users requestBody: required: true content: application/json: schema: type: object properties: ids: type: array items: type: integer permissions: type: integer responses: '200': description: Successfully updated user details content: application/json: schema: type: array items: $ref: '#/components/schemas/User' /user/import-from-plex: post: summary: Import all users from Plex description: | Fetches and imports users from the Plex server. If a list of Plex IDs is provided in the request body, only the specified users will be imported. Otherwise, all users will be imported. Requires the `MANAGE_USERS` permission. tags: - users requestBody: required: false content: application/json: schema: type: object properties: plexIds: type: array items: type: string responses: '201': description: A list of the newly created users content: application/json: schema: type: array items: $ref: '#/components/schemas/User' /user/import-from-jellyfin: post: summary: Import all users from Jellyfin description: | Fetches and imports users from the Jellyfin server. Requires the `MANAGE_USERS` permission. tags: - users requestBody: required: false content: application/json: schema: type: object properties: jellyfinUserIds: type: array items: type: string responses: '201': description: A list of the newly created users content: application/json: schema: type: array items: $ref: '#/components/schemas/User' /user/registerPushSubscription: post: summary: Register a web push /user/registerPushSubscription description: Registers a web push subscription for the logged-in user tags: - users requestBody: required: true content: application/json: schema: type: object properties: endpoint: type: string auth: type: string p256dh: type: string userAgent: type: string required: - endpoint - auth - p256dh responses: '204': description: Successfully registered push subscription /user/{userId}/pushSubscriptions: get: summary: Get all web push notification settings for a user description: | Returns all web push notification settings for a user in a JSON object. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: User web push notification settings in JSON content: application/json: schema: type: object properties: endpoint: type: string p256dh: type: string auth: type: string userAgent: type: string /user/{userId}/pushSubscription/{endpoint}: get: summary: Get web push notification settings for a user description: | Returns web push notification settings for a user in a JSON object. tags: - users parameters: - in: path name: userId required: true schema: type: number - in: path name: endpoint required: true schema: type: string responses: '200': description: User web push notification settings in JSON content: application/json: schema: type: object properties: endpoint: type: string p256dh: type: string auth: type: string userAgent: type: string delete: summary: Delete user push subscription by key description: Deletes the user push subscription with the provided key. tags: - users parameters: - in: path name: userId required: true schema: type: number - in: path name: endpoint required: true schema: type: string responses: '204': description: Successfully removed user push subscription /user/{userId}: get: summary: Get user by ID description: | Retrieves user details in a JSON object. Requires the `MANAGE_USERS` permission. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: Users details in JSON content: application/json: schema: $ref: '#/components/schemas/User' put: summary: Update a user by user ID description: | Update a user with the provided values. You cannot update a user's Plex token through this request. Requires the `MANAGE_USERS` permission. tags: - users parameters: - in: path name: userId required: true schema: type: number requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/User' responses: '200': description: Successfully updated user details content: application/json: schema: $ref: '#/components/schemas/User' delete: summary: Delete user by ID description: Deletes the user with the provided userId. Requires the `MANAGE_USERS` permission. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: User successfully deleted content: application/json: schema: $ref: '#/components/schemas/User' /user/{userId}/requests: get: summary: Get requests for a specific user description: | Retrieves a user's requests in a JSON object. tags: - users parameters: - in: path name: userId required: true schema: type: number - in: query name: take schema: type: number nullable: true example: 20 - in: query name: skip schema: type: number nullable: true example: 0 responses: '200': description: User's requests returned content: application/json: schema: type: object properties: pageInfo: $ref: '#/components/schemas/PageInfo' results: type: array items: $ref: '#/components/schemas/MediaRequest' /user/{userId}/quota: get: summary: Get quotas for a specific user description: | Returns quota details for a user in a JSON object. Requires `MANAGE_USERS` permission if viewing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: User quota details in JSON content: application/json: schema: type: object properties: movie: type: object properties: days: type: number example: 7 limit: type: number example: 10 used: type: number example: 6 remaining: type: number example: 4 restricted: type: boolean example: false tv: type: object properties: days: type: number example: 7 limit: type: number example: 10 used: type: number example: 6 remaining: type: number example: 4 restricted: type: boolean example: false /blocklist: get: summary: Returns blocklisted items description: Returns list of all blocklisted media tags: - blocklist parameters: - in: query name: take schema: type: number nullable: true example: 25 - in: query name: skip schema: type: number nullable: true example: 0 - in: query name: search schema: type: string nullable: true example: dune - in: query name: filter schema: type: string enum: [all, manual, blocklistedTags] default: manual responses: '200': description: Blocklisted items returned content: application/json: schema: type: object properties: pageInfo: $ref: '#/components/schemas/PageInfo' results: type: array items: type: object properties: user: $ref: '#/components/schemas/User' createdAt: type: string example: 2024-04-21T01:55:44.000Z id: type: number example: 1 mediaType: type: string example: movie title: type: string example: Dune tmdbId: type: number example: 438631 post: summary: Add media to blocklist tags: - blocklist requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/Blocklist' responses: '201': description: Item succesfully blocklisted '412': description: Item has already been blocklisted /blocklist/{tmdbId}: get: summary: Get media from blocklist tags: - blocklist parameters: - in: path name: tmdbId description: tmdbId ID required: true example: '1' schema: type: string - in: query name: mediaType required: true schema: type: string enum: - movie - tv responses: '200': description: Blocklist details in JSON delete: summary: Remove media from blocklist tags: - blocklist parameters: - in: path name: tmdbId description: tmdbId ID required: true example: '1' schema: type: string - in: query name: mediaType required: true schema: type: string enum: - movie - tv responses: '204': description: Succesfully removed media item /blacklist: get: summary: Returns blocklisted items description: | **DEPRECATED**: Use `/blocklist` instead. This endpoint will be deprecated soon. deprecated: true tags: - blocklist parameters: - in: query name: take schema: type: number nullable: true example: 25 - in: query name: skip schema: type: number nullable: true example: 0 - in: query name: search schema: type: string nullable: true example: dune - in: query name: filter schema: type: string enum: [all, manual, blocklistedTags] default: manual responses: '200': description: Blocklisted items returned content: application/json: schema: type: object properties: pageInfo: $ref: '#/components/schemas/PageInfo' results: type: array items: type: object properties: user: $ref: '#/components/schemas/User' createdAt: type: string example: 2024-04-21T01:55:44.000Z id: type: number example: 1 mediaType: type: string example: movie title: type: string example: Dune tmdbId: type: number example: 438631 post: summary: Add media to blocklist description: | **DEPRECATED**: Use `/blocklist` instead. This endpoint will be deprecated soon. deprecated: true tags: - blocklist requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/Blocklist' responses: '201': description: Item succesfully blocklisted '412': description: Item has already been blocklisted /blacklist/{tmdbId}: get: summary: Get media from blocklist description: | **DEPRECATED**: Use `/blocklist/{tmdbId}` instead. This endpoint will be deprecated soon. deprecated: true tags: - blocklist parameters: - in: path name: tmdbId description: tmdbId ID required: true example: '1' schema: type: string - in: query name: mediaType required: true schema: type: string enum: - movie - tv responses: '200': description: Blocklist details in JSON delete: summary: Remove media from blocklist description: | **DEPRECATED**: Use `/blocklist/{tmdbId}` instead. This endpoint will be deprecated soon. deprecated: true tags: - blocklist parameters: - in: path name: tmdbId description: tmdbId ID required: true example: '1' schema: type: string - in: query name: mediaType required: true schema: type: string enum: - movie - tv responses: '204': description: Succesfully removed media item /watchlist: post: summary: Add media to watchlist tags: - watchlist requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/Watchlist' responses: '200': description: Watchlist data returned content: application/json: schema: $ref: '#/components/schemas/Watchlist' /watchlist/{tmdbId}: delete: summary: Delete watchlist item description: Removes a watchlist item. tags: - watchlist parameters: - in: path name: tmdbId description: tmdbId ID required: true example: '1' schema: type: string - in: query name: mediaType required: true schema: type: string enum: - movie - tv responses: '204': description: Succesfully removed watchlist item /user/{userId}/watchlist: get: summary: Get the Plex watchlist for a specific user description: | Retrieves a user's Plex Watchlist in a JSON object. tags: - users - watchlist parameters: - in: path name: userId required: true schema: type: number - in: query name: page schema: type: number example: 1 default: 1 responses: '200': description: Watchlist data returned content: application/json: schema: type: object properties: page: type: number totalPages: type: number totalResults: type: number results: type: array items: type: object properties: tmdbId: type: number example: 1 ratingKey: type: string type: type: string title: type: string /user/{userId}/settings/main: get: summary: Get general settings for a user description: Returns general settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: User general settings returned content: application/json: schema: $ref: '#/components/schemas/UserSettings' post: summary: Update general settings for a user description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UserSettings' responses: '200': description: Updated user general settings returned content: application/json: schema: $ref: '#/components/schemas/UserSettings' /user/{userId}/settings/password: get: summary: Get password page informatiom description: Returns important data for the password page to function correctly. Requires `MANAGE_USERS` permission if viewing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: User password page information returned content: application/json: schema: type: object properties: hasPassword: type: boolean example: true post: summary: Update password for a user description: Updates a user's password. Requires `MANAGE_USERS` permission if editing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number requestBody: required: true content: application/json: schema: type: object properties: currentPassword: type: string nullable: true newPassword: type: string required: - newPassword responses: '204': description: User password updated /user/{userId}/settings/linked-accounts/plex: post: summary: Link the provided Plex account to the current user description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. tags: - users parameters: - in: path name: userId required: true schema: type: number requestBody: required: true content: application/json: schema: type: object properties: authToken: type: string required: - authToken responses: '204': description: Linking account succeeded '403': description: Invalid credentials '422': description: Account already linked to a user delete: summary: Remove the linked Plex account for a user description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '204': description: Unlinking account succeeded '400': description: Unlink request invalid '404': description: User does not exist /user/{userId}/settings/linked-accounts/jellyfin: post: summary: Link the provided Jellyfin account to the current user description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. tags: - users parameters: - in: path name: userId required: true schema: type: number requestBody: required: true content: application/json: schema: type: object properties: username: type: string example: 'Mr User' password: type: string example: 'supersecret' responses: '204': description: Linking account succeeded '403': description: Invalid credentials '422': description: Account already linked to a user delete: summary: Remove the linked Jellyfin account for a user description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '204': description: Unlinking account succeeded '400': description: Unlink request invalid '404': description: User does not exist /user/{userId}/settings/notifications: get: summary: Get notification settings for a user description: Returns notification settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: User notification settings returned content: application/json: schema: $ref: '#/components/schemas/UserSettingsNotifications' post: summary: Update notification settings for a user description: Updates and returns notification settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UserSettingsNotifications' responses: '200': description: Updated user notification settings returned content: application/json: schema: $ref: '#/components/schemas/UserSettingsNotifications' /user/{userId}/settings/permissions: get: summary: Get permission settings for a user description: Returns permission settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: User permission settings returned content: application/json: schema: type: object properties: permissions: type: number example: 2 post: summary: Update permission settings for a user description: Updates and returns permission settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. tags: - users parameters: - in: path name: userId required: true schema: type: number requestBody: required: true content: application/json: schema: type: object properties: permissions: type: number required: - permissions responses: '200': description: Updated user general settings returned content: application/json: schema: type: object properties: permissions: type: number example: 2 /user/{userId}/watch_data: get: summary: Get watch data description: | Returns play count, play duration, and recently watched media. Requires the `ADMIN` permission to fetch results for other users. tags: - users parameters: - in: path name: userId required: true schema: type: number responses: '200': description: Users content: application/json: schema: type: object properties: recentlyWatched: type: array items: $ref: '#/components/schemas/MediaInfo' playCount: type: number /search: get: summary: Search for movies, TV shows, or people description: Returns a list of movies, TV shows, or people a JSON object. tags: - search parameters: - in: query name: query required: true schema: type: string example: 'Mulan' - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: anyOf: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/PersonResult' /search/keyword: get: summary: Search for keywords description: Returns a list of TMDB keywords matching the search query tags: - search parameters: - in: query name: query required: true schema: type: string example: 'christmas' - in: query name: page schema: type: number example: 1 default: 1 responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/Keyword' /search/company: get: summary: Search for companies description: Returns a list of TMDB companies matching the search query. (Will not return origin country) tags: - search parameters: - in: query name: query required: true schema: type: string example: 'Disney' - in: query name: page schema: type: number example: 1 default: 1 responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/Company' /discover/movies: get: summary: Discover movies description: Returns a list of movies in a JSON object. tags: - search parameters: - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en - in: query name: genre schema: type: string example: 18 - in: query name: studio schema: type: number example: 1 - in: query name: keywords schema: type: string example: 1,2 - in: query name: excludeKeywords schema: type: string example: 3,4 description: Comma-separated list of keyword IDs to exclude from results - in: query name: sortBy schema: type: string example: popularity.desc - in: query name: primaryReleaseDateGte schema: type: string example: 2022-01-01 - in: query name: primaryReleaseDateLte schema: type: string example: 2023-01-01 - in: query name: withRuntimeGte schema: type: number example: 60 - in: query name: withRuntimeLte schema: type: number example: 120 - in: query name: voteAverageGte schema: type: number example: 7 - in: query name: voteAverageLte schema: type: number example: 10 - in: query name: voteCountGte schema: type: number example: 7 - in: query name: voteCountLte schema: type: number example: 10 - in: query name: watchRegion schema: type: string example: US - in: query name: watchProviders schema: type: string example: 8|9 - in: query name: certification schema: type: string example: PG-13 description: Exact certification to filter by (used when certificationMode is 'exact') - in: query name: certificationGte schema: type: string example: G description: Minimum certification to filter by (used when certificationMode is 'range') - in: query name: certificationLte schema: type: string example: PG-13 description: Maximum certification to filter by (used when certificationMode is 'range') - in: query name: certificationCountry schema: type: string example: US description: Country code for the certification system (e.g., US, GB, CA) - in: query name: certificationMode schema: type: string enum: [exact, range] example: exact description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/MovieResult' /discover/movies/genre/{genreId}: get: summary: Discover movies by genre description: Returns a list of movies based on the provided genre ID in a JSON object. tags: - search parameters: - in: path name: genreId required: true schema: type: string example: '1' - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 genre: $ref: '#/components/schemas/Genre' results: type: array items: $ref: '#/components/schemas/MovieResult' /discover/movies/language/{language}: get: summary: Discover movies by original language description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object. tags: - search parameters: - in: path name: language required: true schema: type: string example: en - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 language: $ref: '#/components/schemas/SpokenLanguage' results: type: array items: $ref: '#/components/schemas/MovieResult' /discover/movies/studio/{studioId}: get: summary: Discover movies by studio description: Returns a list of movies based on the provided studio ID in a JSON object. tags: - search parameters: - in: path name: studioId required: true schema: type: string example: '1' - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 studio: $ref: '#/components/schemas/ProductionCompany' results: type: array items: $ref: '#/components/schemas/MovieResult' /discover/movies/upcoming: get: summary: Upcoming movies description: Returns a list of movies in a JSON object. tags: - search parameters: - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/MovieResult' /discover/tv: get: summary: Discover TV shows description: Returns a list of TV shows in a JSON object. tags: - search parameters: - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en - in: query name: genre schema: type: string example: 18 - in: query name: network schema: type: number example: 1 - in: query name: keywords schema: type: string example: 1,2 - in: query name: excludeKeywords schema: type: string example: 3,4 description: Comma-separated list of keyword IDs to exclude from results - in: query name: sortBy schema: type: string example: popularity.desc - in: query name: firstAirDateGte schema: type: string example: 2022-01-01 - in: query name: firstAirDateLte schema: type: string example: 2023-01-01 - in: query name: withRuntimeGte schema: type: number example: 60 - in: query name: withRuntimeLte schema: type: number example: 120 - in: query name: voteAverageGte schema: type: number example: 7 - in: query name: voteAverageLte schema: type: number example: 10 - in: query name: voteCountGte schema: type: number example: 7 - in: query name: voteCountLte schema: type: number example: 10 - in: query name: watchRegion schema: type: string example: US - in: query name: watchProviders schema: type: string example: 8|9 - in: query name: status schema: type: string example: 3|4 - in: query name: certification schema: type: string example: TV-14 description: Exact certification to filter by (used when certificationMode is 'exact') - in: query name: certificationGte schema: type: string example: TV-PG description: Minimum certification to filter by (used when certificationMode is 'range') - in: query name: certificationLte schema: type: string example: TV-MA description: Maximum certification to filter by (used when certificationMode is 'range') - in: query name: certificationCountry schema: type: string example: US description: Country code for the certification system (e.g., US, GB, CA) - in: query name: certificationMode schema: type: string enum: [exact, range] example: exact description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/TvResult' /discover/tv/language/{language}: get: summary: Discover TV shows by original language description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object. tags: - search parameters: - in: path name: language required: true schema: type: string example: en - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 language: $ref: '#/components/schemas/SpokenLanguage' results: type: array items: $ref: '#/components/schemas/TvResult' /discover/tv/genre/{genreId}: get: summary: Discover TV shows by genre description: Returns a list of TV shows based on the provided genre ID in a JSON object. tags: - search parameters: - in: path name: genreId required: true schema: type: string example: '1' - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 genre: $ref: '#/components/schemas/Genre' results: type: array items: $ref: '#/components/schemas/TvResult' /discover/tv/network/{networkId}: get: summary: Discover TV shows by network description: Returns a list of TV shows based on the provided network ID in a JSON object. tags: - search parameters: - in: path name: networkId required: true schema: type: string example: '1' - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 network: $ref: '#/components/schemas/Network' results: type: array items: $ref: '#/components/schemas/TvResult' /discover/tv/upcoming: get: summary: Discover Upcoming TV shows description: Returns a list of upcoming TV shows in a JSON object. tags: - search parameters: - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/TvResult' /discover/trending: get: summary: Trending movies and TV description: Returns a list of movies and TV shows in a JSON object. tags: - search parameters: - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en - in: query name: mediaType schema: type: string enum: - all - movie - tv default: all - in: query name: timeWindow schema: type: string enum: - day - week default: day responses: '200': description: Results content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: anyOf: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/PersonResult' /discover/keyword/{keywordId}/movies: get: summary: Get movies from keyword description: Returns list of movies based on the provided keyword ID a JSON object. tags: - search parameters: - in: path name: keywordId required: true schema: type: number example: 207317 - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: List of movies content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/MovieResult' /discover/genreslider/movie: get: summary: Get genre slider data for movies description: Returns a list of genres with backdrops attached tags: - search parameters: - in: query name: language schema: type: string example: en responses: '200': description: Genre slider data returned content: application/json: schema: type: array items: type: object properties: id: type: number example: 1 backdrops: type: array items: type: string name: type: string example: Genre Name /discover/genreslider/tv: get: summary: Get genre slider data for TV series description: Returns a list of genres with backdrops attached tags: - search parameters: - in: query name: language schema: type: string example: en responses: '200': description: Genre slider data returned content: application/json: schema: type: array items: type: object properties: id: type: number example: 1 backdrops: type: array items: type: string name: type: string example: Genre Name /discover/watchlist: get: summary: Get the Plex watchlist. tags: - search parameters: - in: query name: page schema: type: number example: 1 default: 1 responses: '200': description: Watchlist data returned content: application/json: schema: type: object properties: page: type: number totalPages: type: number totalResults: type: number results: type: array items: type: object properties: tmdbId: type: number example: 1 ratingKey: type: string type: type: string title: type: string /request: get: summary: Get all requests description: | Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned. If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned. tags: - request parameters: - in: query name: take schema: type: number nullable: true example: 20 - in: query name: skip schema: type: number nullable: true example: 0 - in: query name: filter schema: type: string nullable: true enum: [ all, approved, available, pending, processing, unavailable, failed, deleted, completed, ] - in: query name: sort schema: type: string enum: [added, modified] default: added - in: query name: sortDirection schema: type: string enum: [asc, desc] nullable: true default: desc - in: query name: requestedBy schema: type: number nullable: true example: 1 - in: query name: mediaType schema: type: string enum: [movie, tv, all] nullable: true default: all responses: '200': description: Requests returned content: application/json: schema: type: object properties: pageInfo: $ref: '#/components/schemas/PageInfo' results: type: array items: $ref: '#/components/schemas/MediaRequest' post: summary: Create new request description: | Creates a new request with the provided media ID and type. The `REQUEST` permission is required. If the user has the `ADMIN` or `AUTO_APPROVE` permissions, their request will be auomatically approved. tags: - request requestBody: required: true content: application/json: schema: type: object properties: mediaType: type: string enum: [movie, tv] example: movie mediaId: type: number example: 123 tvdbId: type: number example: 123 seasons: oneOf: - type: array items: type: number minimum: 0 - type: string enum: [all] is4k: type: boolean example: false serverId: type: number profileId: type: number rootFolder: type: string languageProfileId: type: number userId: type: number nullable: true required: - mediaType - mediaId responses: '201': description: Succesfully created the request content: application/json: schema: $ref: '#/components/schemas/MediaRequest' /request/count: get: summary: Gets request counts description: | Returns the number of requests by status including pending, approved, available, and completed requests. tags: - request responses: '200': description: Request counts returned content: application/json: schema: type: object properties: total: type: number movie: type: number tv: type: number pending: type: number approved: type: number declined: type: number processing: type: number available: type: number completed: type: number /request/{requestId}: get: summary: Get MediaRequest description: Returns a specific MediaRequest in a JSON object. tags: - request parameters: - in: path name: requestId description: Request ID required: true example: '1' schema: type: string responses: '200': description: Succesfully returns request content: application/json: schema: $ref: '#/components/schemas/MediaRequest' put: summary: Update MediaRequest description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission. tags: - request parameters: - in: path name: requestId description: Request ID required: true example: '1' schema: type: string requestBody: required: true content: application/json: schema: type: object properties: mediaType: type: string enum: [movie, tv] seasons: type: array items: type: number minimum: 0 is4k: type: boolean example: false serverId: type: number profileId: type: number rootFolder: type: string languageProfileId: type: number userId: type: number nullable: true required: - mediaType responses: '200': description: Succesfully updated request content: application/json: schema: $ref: '#/components/schemas/MediaRequest' delete: summary: Delete request description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, any request can be removed. Otherwise, only pending requests can be removed. tags: - request parameters: - in: path name: requestId description: Request ID required: true example: '1' schema: type: string responses: '204': description: Succesfully removed request /request/{requestId}/retry: post: summary: Retry failed request description: | Retries a request by resending requests to Sonarr or Radarr. Requires the `MANAGE_REQUESTS` permission or `ADMIN`. tags: - request parameters: - in: path name: requestId description: Request ID required: true schema: type: string example: '1' responses: '200': description: Retry triggered content: application/json: schema: $ref: '#/components/schemas/MediaRequest' /request/{requestId}/{status}: post: summary: Update a request's status description: | Updates a request's status to approved or declined. Also returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission or `ADMIN`. tags: - request parameters: - in: path name: requestId description: Request ID required: true schema: type: string example: '1' - in: path name: status description: New status required: true schema: type: string enum: [approve, decline] responses: '200': description: Request status changed content: application/json: schema: $ref: '#/components/schemas/MediaRequest' /movie/{movieId}: get: summary: Get movie details description: Returns full movie details in a JSON object. tags: - movies parameters: - in: path name: movieId required: true schema: type: number example: 337401 - in: query name: language schema: type: string example: en responses: '200': description: Movie details content: application/json: schema: $ref: '#/components/schemas/MovieDetails' /movie/{movieId}/recommendations: get: summary: Get recommended movies description: Returns list of recommended movies based on provided movie ID in a JSON object. tags: - movies parameters: - in: path name: movieId required: true schema: type: number example: 337401 - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: List of movies content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/MovieResult' /movie/{movieId}/similar: get: summary: Get similar movies description: Returns list of similar movies based on the provided movieId in a JSON object. tags: - movies parameters: - in: path name: movieId required: true schema: type: number example: 337401 - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: List of movies content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/MovieResult' /movie/{movieId}/ratings: get: summary: Get movie ratings description: Returns ratings based on the provided movieId in a JSON object. tags: - movies parameters: - in: path name: movieId required: true schema: type: number example: 337401 responses: '200': description: Ratings returned content: application/json: schema: type: object properties: title: type: string example: Mulan year: type: number example: 2020 url: type: string example: 'http://www.rottentomatoes.com/m/mulan_2020/' criticsScore: type: number example: 85 criticsRating: type: string enum: ['Rotten', 'Fresh', 'Certified Fresh'] audienceScore: type: number example: 65 audienceRating: type: string enum: ['Spilled', 'Upright'] /movie/{movieId}/ratingscombined: get: summary: Get RT and IMDB movie ratings combined description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object. tags: - movies parameters: - in: path name: movieId required: true schema: type: number example: 337401 responses: '200': description: Ratings returned content: application/json: schema: type: object properties: rt: type: object properties: title: type: string example: Mulan year: type: number example: 2020 url: type: string example: 'http://www.rottentomatoes.com/m/mulan_2020/' criticsScore: type: number example: 85 criticsRating: type: string enum: ['Rotten', 'Fresh', 'Certified Fresh'] audienceScore: type: number example: 65 audienceRating: type: string enum: ['Spilled', 'Upright'] imdb: type: object properties: title: type: string example: I am Legend url: type: string example: 'https://www.imdb.com/title/tt0480249' criticsScore: type: number example: 6.5 /tv/{tvId}: get: summary: Get TV details description: Returns full TV details in a JSON object. tags: - tv parameters: - in: path name: tvId required: true schema: type: number example: 76479 - in: query name: language schema: type: string example: en responses: '200': description: TV details content: application/json: schema: $ref: '#/components/schemas/TvDetails' /tv/{tvId}/season/{seasonNumber}: get: summary: Get season details and episode list description: Returns season details with a list of episodes in a JSON object. tags: - tv parameters: - in: path name: tvId required: true schema: type: number example: 76479 - in: path name: seasonNumber required: true schema: type: number example: 123456 - in: query name: language schema: type: string example: en responses: '200': description: TV details content: application/json: schema: $ref: '#/components/schemas/Season' /tv/{tvId}/recommendations: get: summary: Get recommended TV series description: Returns list of recommended TV series based on the provided tvId in a JSON object. tags: - tv parameters: - in: path name: tvId required: true schema: type: number example: 76479 - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: List of TV series content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/TvResult' /tv/{tvId}/similar: get: summary: Get similar TV series description: Returns list of similar TV series based on the provided tvId in a JSON object. tags: - tv parameters: - in: path name: tvId required: true schema: type: number example: 76479 - in: query name: page schema: type: number example: 1 default: 1 - in: query name: language schema: type: string example: en responses: '200': description: List of TV series content: application/json: schema: type: object properties: page: type: number example: 1 totalPages: type: number example: 20 totalResults: type: number example: 200 results: type: array items: $ref: '#/components/schemas/TvResult' /tv/{tvId}/ratings: get: summary: Get TV ratings description: Returns ratings based on provided tvId in a JSON object. tags: - tv parameters: - in: path name: tvId required: true schema: type: number example: 76479 responses: '200': description: Ratings returned content: application/json: schema: type: object properties: title: type: string example: The Boys year: type: number example: 2019 url: type: string example: 'http://www.rottentomatoes.com/m/mulan_2020/' criticsScore: type: number example: 85 criticsRating: type: string enum: ['Rotten', 'Fresh'] /person/{personId}: get: summary: Get person details description: Returns person details based on provided personId in a JSON object. tags: - person parameters: - in: path name: personId required: true schema: type: number example: 287 - in: query name: language schema: type: string example: en responses: '200': description: Returned person content: application/json: schema: $ref: '#/components/schemas/PersonDetails' /person/{personId}/combined_credits: get: summary: Get combined credits description: Returns the person's combined credits based on the provided personId in a JSON object. tags: - person parameters: - in: path name: personId required: true schema: type: number example: 287 - in: query name: language schema: type: string example: en responses: '200': description: Returned combined credits content: application/json: schema: type: object properties: cast: type: array items: $ref: '#/components/schemas/CreditCast' crew: type: array items: $ref: '#/components/schemas/CreditCrew' id: type: number /media: get: summary: Get media description: Returns all media (can be filtered and limited) in a JSON object. tags: - media parameters: - in: query name: take schema: type: number nullable: true example: 20 - in: query name: skip schema: type: number nullable: true example: 0 - in: query name: filter schema: type: string nullable: true enum: [ all, available, partial, allavailable, processing, pending, deleted, ] - in: query name: sort schema: type: string enum: [added, modified, mediaAdded] default: added responses: '200': description: Returned media content: application/json: schema: type: object properties: pageInfo: $ref: '#/components/schemas/PageInfo' results: type: array items: $ref: '#/components/schemas/MediaInfo' /media/{mediaId}: delete: summary: Delete media item description: Removes a media item. The `MANAGE_REQUESTS` permission is required to perform this action. tags: - media parameters: - in: path name: mediaId description: Media ID required: true example: '1' schema: type: string responses: '204': description: Succesfully removed media item /media/{mediaId}/file: delete: summary: Delete media file description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action. tags: - media parameters: - in: path name: mediaId description: Media ID required: true example: '1' schema: type: string - in: query name: is4k description: Whether to remove from 4K service instance (true) or regular service instance (false) required: false example: false schema: type: boolean responses: '204': description: Successfully removed media item /media/{mediaId}/{status}: post: summary: Update media status description: Updates a media item's status and returns the media in JSON format tags: - media parameters: - in: path name: mediaId description: Media ID required: true example: '1' schema: type: string - in: path name: status description: New status required: true example: available schema: type: string enum: [available, partial, processing, pending, unknown, deleted] requestBody: content: application/json: schema: type: object properties: is4k: type: boolean example: false description: | When true, updates the 4K status field (status4k). When false or not provided, updates the regular status field (status). This applies to all status values (available, partial, processing, pending, unknown). responses: '200': description: Returned media content: application/json: schema: $ref: '#/components/schemas/MediaInfo' /media/{mediaId}/watch_data: get: summary: Get watch data description: | Returns play count, play duration, and users who have watched the media. Requires the `ADMIN` permission. tags: - media parameters: - in: path name: mediaId description: Media ID required: true example: '1' schema: type: string responses: '200': description: Users content: application/json: schema: type: object properties: data: type: object properties: playCount7Days: type: number playCount30Days: type: number playCount: type: number users: type: array items: $ref: '#/components/schemas/User' data4k: type: object properties: playCount7Days: type: number playCount30Days: type: number playCount: type: number users: type: array items: $ref: '#/components/schemas/User' /collection/{collectionId}: get: summary: Get collection details description: Returns full collection details in a JSON object. tags: - collection parameters: - in: path name: collectionId required: true schema: type: number example: 537982 - in: query name: language schema: type: string example: en responses: '200': description: Collection details content: application/json: schema: $ref: '#/components/schemas/Collection' /service/radarr: get: summary: Get non-sensitive Radarr server list description: Returns a list of Radarr server IDs and names in a JSON object. tags: - service responses: '200': description: Request successful content: application/json: schema: type: array items: $ref: '#/components/schemas/RadarrSettings' /service/radarr/{radarrId}: get: summary: Get Radarr server quality profiles and root folders description: Returns a Radarr server's quality profile and root folder details in a JSON object. tags: - service parameters: - in: path name: radarrId required: true schema: type: number example: 0 responses: '200': description: Request successful content: application/json: schema: type: object properties: server: $ref: '#/components/schemas/RadarrSettings' profiles: $ref: '#/components/schemas/ServiceProfile' /service/sonarr: get: summary: Get non-sensitive Sonarr server list description: Returns a list of Sonarr server IDs and names in a JSON object. tags: - service responses: '200': description: Request successful content: application/json: schema: type: array items: $ref: '#/components/schemas/SonarrSettings' /service/sonarr/{sonarrId}: get: summary: Get Sonarr server quality profiles and root folders description: Returns a Sonarr server's quality profile and root folder details in a JSON object. tags: - service parameters: - in: path name: sonarrId required: true schema: type: number example: 0 responses: '200': description: Request successful content: application/json: schema: type: object properties: server: $ref: '#/components/schemas/SonarrSettings' profiles: $ref: '#/components/schemas/ServiceProfile' /service/sonarr/lookup/{tmdbId}: get: summary: Get series from Sonarr description: Returns a list of series returned by searching for the name in Sonarr. tags: - service parameters: - in: path name: tmdbId required: true schema: type: number example: 0 responses: '200': description: Request successful content: application/json: schema: type: array items: $ref: '#/components/schemas/SonarrSeries' /regions: get: summary: Regions supported by TMDB description: Returns a list of regions in a JSON object. tags: - tmdb responses: '200': description: Results content: application/json: schema: type: array items: type: object properties: iso_3166_1: type: string example: US english_name: type: string example: United States of America /languages: get: summary: Languages supported by TMDB description: Returns a list of languages in a JSON object. tags: - tmdb responses: '200': description: Results content: application/json: schema: type: array items: type: object properties: iso_639_1: type: string example: en english_name: type: string example: English name: type: string example: English /studio/{studioId}: get: summary: Get movie studio details description: Returns movie studio details in a JSON object. tags: - tmdb parameters: - in: path name: studioId required: true schema: type: number example: 2 responses: '200': description: Movie studio details content: application/json: schema: $ref: '#/components/schemas/ProductionCompany' /network/{networkId}: get: summary: Get TV network details description: Returns TV network details in a JSON object. tags: - tmdb parameters: - in: path name: networkId required: true schema: type: number example: 1 responses: '200': description: TV network details content: application/json: schema: $ref: '#/components/schemas/ProductionCompany' /genres/movie: get: summary: Get list of official TMDB movie genres description: Returns a list of genres in a JSON array. tags: - tmdb parameters: - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: array items: type: object properties: id: type: number example: 10751 name: type: string example: Family /genres/tv: get: summary: Get list of official TMDB movie genres description: Returns a list of genres in a JSON array. tags: - tmdb parameters: - in: query name: language schema: type: string example: en responses: '200': description: Results content: application/json: schema: type: array items: type: object properties: id: type: number example: 18 name: type: string example: Drama /backdrops: get: summary: Get backdrops of trending items description: Returns a list of backdrop image paths in a JSON array. security: [] tags: - tmdb responses: '200': description: Results content: application/json: schema: type: array items: type: string /issue: get: summary: Get all issues description: | Returns a list of issues in JSON format. tags: - issue parameters: - in: query name: take schema: type: number nullable: true example: 20 - in: query name: skip schema: type: number nullable: true example: 0 - in: query name: sort schema: type: string enum: [added, modified] default: added - in: query name: filter schema: type: string enum: [all, open, resolved] default: open - in: query name: requestedBy schema: type: number nullable: true example: 1 responses: '200': description: Issues returned content: application/json: schema: type: object properties: pageInfo: $ref: '#/components/schemas/PageInfo' results: type: array items: $ref: '#/components/schemas/Issue' post: summary: Create new issue description: | Creates a new issue tags: - issue requestBody: required: true content: application/json: schema: type: object properties: issueType: type: number message: type: string mediaId: type: number responses: '201': description: Succesfully created the issue content: application/json: schema: $ref: '#/components/schemas/Issue' /issue/count: get: summary: Gets issue counts description: | Returns the number of open and closed issues, as well as the number of issues of each type. tags: - issue responses: '200': description: Issue counts returned content: application/json: schema: type: object properties: total: type: number video: type: number audio: type: number subtitles: type: number others: type: number open: type: number closed: type: number /issue/{issueId}: get: summary: Get issue description: | Returns a single issue in JSON format. tags: - issue parameters: - in: path name: issueId required: true schema: type: number example: 1 responses: '200': description: Issues returned content: application/json: schema: $ref: '#/components/schemas/Issue' delete: summary: Delete issue description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed. tags: - issue parameters: - in: path name: issueId description: Issue ID required: true example: '1' schema: type: string responses: '204': description: Succesfully removed issue /issue/{issueId}/comment: post: summary: Create a comment description: | Creates a comment and returns associated issue in JSON format. tags: - issue parameters: - in: path name: issueId required: true schema: type: number example: 1 requestBody: required: true content: application/json: schema: type: object properties: message: type: string required: - message responses: '200': description: Issue returned with new comment content: application/json: schema: $ref: '#/components/schemas/Issue' /issueComment/{commentId}: get: summary: Get issue comment description: | Returns a single issue comment in JSON format. tags: - issue parameters: - in: path name: commentId required: true schema: type: string example: 1 responses: '200': description: Comment returned content: application/json: schema: $ref: '#/components/schemas/IssueComment' put: summary: Update issue comment description: | Updates and returns a single issue comment in JSON format. tags: - issue parameters: - in: path name: commentId required: true schema: type: string example: 1 requestBody: required: true content: application/json: schema: type: object properties: message: type: string responses: '200': description: Comment updated content: application/json: schema: $ref: '#/components/schemas/IssueComment' delete: summary: Delete issue comment description: | Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action. tags: - issue parameters: - in: path name: commentId description: Issue Comment ID required: true example: '1' schema: type: string responses: '204': description: Succesfully removed issue comment /issue/{issueId}/{status}: post: summary: Update an issue's status description: | Updates an issue's status to approved or declined. Also returns the issue in a JSON object. Requires the `MANAGE_ISSUES` permission or `ADMIN`. tags: - issue parameters: - in: path name: issueId description: Issue ID required: true schema: type: string example: '1' - in: path name: status description: New status required: true schema: type: string enum: [open, resolved] responses: '200': description: Issue status changed content: application/json: schema: $ref: '#/components/schemas/Issue' /keyword/{keywordId}: get: summary: Get keyword description: | Returns a single keyword in JSON format. tags: - other parameters: - in: path name: keywordId required: true schema: type: number example: 1 responses: '200': description: Keyword returned (null if not found) content: application/json: schema: nullable: true $ref: '#/components/schemas/Keyword' '500': description: Internal server error content: application/json: schema: type: object properties: message: type: string example: 'Unable to retrieve keyword data.' /watchproviders/regions: get: summary: Get watch provider regions description: | Returns a list of all available watch provider regions. tags: - other responses: '200': description: Watch provider regions returned content: application/json: schema: type: array items: $ref: '#/components/schemas/WatchProviderRegion' /watchproviders/movies: get: summary: Get watch provider movies description: | Returns a list of all available watch providers for movies. tags: - other parameters: - in: query name: watchRegion required: true schema: type: string example: US responses: '200': description: Watch providers for movies returned content: application/json: schema: type: array items: $ref: '#/components/schemas/WatchProviderDetails' /watchproviders/tv: get: summary: Get watch provider series description: | Returns a list of all available watch providers for series. tags: - other parameters: - in: query name: watchRegion required: true schema: type: string example: US responses: '200': description: Watch providers for series returned content: application/json: schema: type: array items: $ref: '#/components/schemas/WatchProviderDetails' /certifications/movie: get: summary: Get movie certifications description: Returns list of movie certifications from TMDB. tags: - other security: - cookieAuth: [] - apiKey: [] responses: '200': description: Movie certifications returned content: application/json: schema: $ref: '#/components/schemas/CertificationResponse' '500': description: Unable to retrieve movie certifications content: application/json: schema: type: object properties: status: type: number example: 500 message: type: string example: Unable to retrieve movie certifications. /certifications/tv: get: summary: Get TV certifications description: Returns list of TV show certifications from TMDB. tags: - other security: - cookieAuth: [] - apiKey: [] responses: '200': description: TV certifications returned content: application/json: schema: $ref: '#/components/schemas/CertificationResponse' '500': description: Unable to retrieve TV certifications content: application/json: schema: type: object properties: status: type: number example: 500 message: type: string example: Unable to retrieve TV certifications. /overrideRule: get: summary: Get override rules description: Returns a list of all override rules with their conditions and settings tags: - overriderule responses: '200': description: Override rules returned content: application/json: schema: type: array items: $ref: '#/components/schemas/OverrideRule' post: summary: Create override rule description: Creates a new Override Rule from the request body. tags: - overriderule responses: '200': description: 'Values were successfully created' content: application/json: schema: type: array items: $ref: '#/components/schemas/OverrideRule' /overrideRule/{ruleId}: put: summary: Update override rule description: Updates an Override Rule from the request body. tags: - overriderule parameters: - in: path name: ruleId required: true schema: type: number responses: '200': description: 'Values were successfully updated' content: application/json: schema: type: array items: $ref: '#/components/schemas/OverrideRule' delete: summary: Delete override rule by ID description: Deletes the override rule with the provided ruleId. tags: - overriderule parameters: - in: path name: ruleId required: true schema: type: number responses: '200': description: Override rule successfully deleted content: application/json: schema: $ref: '#/components/schemas/OverrideRule' security: - cookieAuth: [] - apiKey: [] ================================================ FILE: server/api/animelist.ts ================================================ import logger from '@server/logger'; import axios from 'axios'; import fs, { promises as fsp } from 'fs'; import path from 'path'; import xml2js from 'xml2js'; const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds // originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml const MAPPING_URL = 'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml'; const LOCAL_PATH = process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/anime-list.xml` : path.join(__dirname, '../../config/anime-list.xml'); const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g); // Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDB IDs // https://github.com/Anime-Lists/anime-lists/ interface AnimeMapping { $: { anidbseason: string; tvdbseason: string; }; _: string; } interface Anime { $: { anidbid: number; tvdbid?: string; defaulttvdbseason?: string; tmdbid?: number; imdbid?: string; }; 'mapping-list'?: { mapping: AnimeMapping[]; }[]; } interface AnimeList { 'anime-list': { anime: Anime[]; }; } export interface AnidbItem { tvdbId?: number; tmdbId?: number; imdbId?: string; tvdbSeason?: number; } class AnimeListMapping { private syncing = false; private mapping: { [anidbId: number]: AnidbItem } = {}; // mapping file modification date when it was loaded private mappingModified: Date | null = null; // each episode in season 0 from TVDB can map to movie private specials: { [tvdbId: number]: { [episode: number]: AnidbItem } } = {}; public isLoaded = () => Object.keys(this.mapping).length !== 0; private loadFromFile = async () => { logger.info('Loading mapping file', { label: 'Anime-List Sync' }); try { const mappingStat = await fsp.stat(LOCAL_PATH); const file = await fsp.readFile(LOCAL_PATH); const xml = (await xml2js.parseStringPromise(file)) as AnimeList; this.mapping = {}; this.specials = {}; for (const anime of xml['anime-list'].anime) { // tvdbId can be nonnumber, like 'movie' string let tvdbId: number | undefined; if (anime.$.tvdbid && !isNaN(Number(anime.$.tvdbid))) { tvdbId = Number(anime.$.tvdbid); } else { tvdbId = undefined; } let imdbIds: (string | undefined)[]; if (anime.$.imdbid) { // if there are multiple imdb entries, then they map to different movies imdbIds = anime.$.imdbid.split(','); } else { // in case there is no imdbid, that's ok as there will be tmdbid imdbIds = [undefined]; } const tmdbId = anime.$.tmdbid ? Number(anime.$.tmdbid) : undefined; const anidbId = Number(anime.$.anidbid); this.mapping[anidbId] = { // for season 0 ignore tvdbid, because this must be movie/OVA tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId, tmdbId: tmdbId, imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping tvdbSeason: Number(anime.$.defaulttvdbseason), }; if (tvdbId) { const mappingList = anime['mapping-list']; if (mappingList && mappingList.length != 0) { let imdbIndex = 0; for (const mapping of mappingList[0].mapping) { const text = mapping._; if (text && mapping.$.tvdbseason === '0') { let matches; while ((matches = mappingRegexp.exec(text)) !== null) { const episode = Number(matches[1]); if (!this.specials[tvdbId]) { this.specials[tvdbId] = {}; } // map next available imdbid to episode in s0 const imdbId = imdbIndex > imdbIds.length ? undefined : imdbIds[imdbIndex]; if (tmdbId || imdbId) { this.specials[tvdbId][episode] = { tmdbId: tmdbId, imdbId: imdbId, }; imdbIndex++; } } } } } else { // some movies do not have mapping-list, so map episode 1,2,3,..to movies // movies must have imdbId or tmdbId const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined; if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') { if (!this.specials[tvdbId]) { this.specials[tvdbId] = {}; } // map each imdbid to episode in s0, episode index starts with 1 for (let idx = 0; idx < imdbIds.length; idx++) { this.specials[tvdbId][idx + 1] = { tmdbId: tmdbId, imdbId: imdbIds[idx], }; } } } } } this.mappingModified = mappingStat.mtime; logger.info( `Loaded ${ Object.keys(this.mapping).length } AniDB items from mapping file`, { label: 'Anime-List Sync' } ); } catch (e) { throw new Error(`Failed to load Anime-List mappings: ${e.message}`, { cause: e, }); } }; private downloadFile = async () => { logger.info('Downloading latest mapping file', { label: 'Anime-List Sync', }); try { const response = await axios.get(MAPPING_URL, { responseType: 'stream', }); await new Promise((resolve, reject) => { const writer = fs.createWriteStream(LOCAL_PATH); writer.on('finish', resolve); writer.on('error', reject); response.data.pipe(writer); }); } catch (e) { throw new Error(`Failed to download Anime-List mapping: ${e.message}`, { cause: e, }); } }; public sync = async () => { // make sure only one sync runs at a time if (this.syncing) { return; } this.syncing = true; try { // check if local file is not "expired" yet if (fs.existsSync(LOCAL_PATH)) { const now = new Date(); const stat = await fsp.stat(LOCAL_PATH); if (now.getTime() - stat.mtime.getTime() < UPDATE_INTERVAL_MSEC) { if (!this.isLoaded()) { // no need to download, but make sure file is loaded await this.loadFromFile(); } else if ( this.mappingModified && stat.mtime.getTime() > this.mappingModified.getTime() ) { // if file has been modified externally since last load, reload it await this.loadFromFile(); } return; } } await this.downloadFile(); await this.loadFromFile(); } finally { this.syncing = false; } }; public getFromAnidbId = (anidbId: number): AnidbItem | undefined => { return this.mapping[anidbId]; }; public getSpecialEpisode = ( tvdbId: number, episode: number ): AnidbItem | undefined => { const episodes = this.specials[tvdbId]; return episodes ? episodes[episode] : undefined; }; } const animeList = new AnimeListMapping(); export default animeList; ================================================ FILE: server/api/externalapi.ts ================================================ import { requestInterceptorFunction } from '@server/utils/customProxyAgent'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import axios from 'axios'; import rateLimit from 'axios-rate-limit'; import type NodeCache from 'node-cache'; // 5 minute default TTL (in seconds) const DEFAULT_TTL = 300; // 10 seconds default rolling buffer (in ms) const DEFAULT_ROLLING_BUFFER = 10000; export interface ExternalAPIOptions { nodeCache?: NodeCache; headers?: Record; timeout?: number; rateLimit?: { maxRPS: number; maxRequests: number; }; } class ExternalAPI { protected axios: AxiosInstance; private baseUrl: string; private cache?: NodeCache; constructor( baseUrl: string, params: Record, options: ExternalAPIOptions = {} ) { this.axios = axios.create({ baseURL: baseUrl, params, timeout: options.timeout, headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...options.headers, }, }); this.axios.interceptors.request.use(requestInterceptorFunction); if (options.rateLimit) { this.axios = rateLimit(this.axios, { maxRequests: options.rateLimit.maxRequests, maxRPS: options.rateLimit.maxRPS, }); } this.baseUrl = baseUrl; this.cache = options.nodeCache; } protected async get( endpoint: string, config?: AxiosRequestConfig, ttl?: number ): Promise { const cacheKey = this.serializeCacheKey(endpoint, { ...config?.params, headers: config?.headers, }); const cachedItem = this.cache?.get(cacheKey); if (cachedItem) { return cachedItem; } const response = await this.axios.get(endpoint, config); if (this.cache && ttl !== 0) { this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); } return response.data; } protected async post( endpoint: string, data?: Record, config?: AxiosRequestConfig, ttl?: number ): Promise { const cacheKey = this.serializeCacheKey(endpoint, { config: config?.params, ...(data ? { data } : {}), }); const cachedItem = this.cache?.get(cacheKey); if (cachedItem) { return cachedItem; } const response = await this.axios.post(endpoint, data, config); if (this.cache && ttl !== 0) { this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); } return response.data; } protected async getRolling( endpoint: string, config?: AxiosRequestConfig, ttl?: number ): Promise { const cacheKey = this.serializeCacheKey(endpoint, { ...config?.params, headers: config?.headers, }); const cachedItem = this.cache?.get(cacheKey); if (cachedItem) { const keyTtl = this.cache?.getTtl(cacheKey) ?? 0; // If the item has passed our rolling check, fetch again in background if ( keyTtl - (ttl ?? DEFAULT_TTL) * 1000 < Date.now() - DEFAULT_ROLLING_BUFFER ) { this.axios.get(endpoint, config).then((response) => { this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); }); } return cachedItem; } const response = await this.axios.get(endpoint, config); if (this.cache && ttl !== 0) { this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); } return response.data; } protected removeCache(endpoint: string, options?: Record) { const cacheKey = this.serializeCacheKey(endpoint, { ...options, }); this.cache?.del(cacheKey); } private serializeCacheKey( endpoint: string, options?: Record ) { if (!options) { return `${this.baseUrl}${endpoint}`; } return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`; } } export default ExternalAPI; ================================================ FILE: server/api/github.ts ================================================ import cacheManager from '@server/lib/cache'; import logger from '@server/logger'; import ExternalAPI from './externalapi'; interface GitHubRelease { url: string; assets_url: string; upload_url: string; html_url: string; id: number; node_id: string; tag_name: string; target_commitish: string; name: string; draft: boolean; prerelease: boolean; created_at: string; published_at: string; tarball_url: string; zipball_url: string; body: string; } interface GithubCommit { sha: string; node_id: string; commit: { author: { name: string; email: string; date: string; }; committer: { name: string; email: string; date: string; }; message: string; tree: { sha: string; url: string; }; url: string; comment_count: number; verification: { verified: boolean; reason: string; signature: string; payload: string; }; }; url: string; html_url: string; comments_url: string; parents: [ { sha: string; url: string; html_url: string; }, ]; } class GithubAPI extends ExternalAPI { constructor() { super( 'https://api.github.com', {}, { headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, nodeCache: cacheManager.getCache('github').data, } ); } public async getSeerrReleases({ take = 20, }: { take?: number; } = {}): Promise { try { const data = await this.get( '/repos/seerr-team/seerr/releases', { params: { per_page: take, }, } ); return data; } catch (e) { logger.warn( "Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Seerr can't check if it's on the latest version.", { label: 'GitHub API', errorMessage: e.message } ); return []; } } public async getSeerrCommits({ take = 20, branch = 'develop', }: { take?: number; branch?: string; } = {}): Promise { try { const data = await this.get( '/repos/seerr-team/seerr/commits', { params: { per_page: take, branch, }, } ); return data; } catch (e) { logger.warn( "Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Seerr can't check if it's on the latest version.", { label: 'GitHub API', errorMessage: e.message } ); return []; } } } export default GithubAPI; ================================================ FILE: server/api/jellyfin.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import ExternalAPI from '@server/api/externalapi'; import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType } from '@server/constants/server'; import availabilitySync from '@server/lib/availabilitySync'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; export interface JellyfinUserResponse { Name: string; ServerId: string; ServerName: string; Id: string; Configuration: { GroupedFolders: string[]; }; Policy: { IsAdministrator: boolean; }; PrimaryImageTag?: string; } export interface JellyfinDevice { Id: string; Name: string; LastUserName: string; AppName: string; AppVersion: string; LastUserId: string; DateLastActivity: string; Capabilities: Record; } export interface JellyfinDevicesResponse { Items: JellyfinDevice[]; TotalRecordCount: number; StartIndex: number; } export interface JellyfinLoginResponse { User: JellyfinUserResponse; AccessToken: string; } export interface JellyfinUserListResponse { users: JellyfinUserResponse[]; } interface JellyfinMediaFolder { Name: string; Id: string; Type: string; CollectionType: string; } export interface JellyfinLibrary { type: 'show' | 'movie'; key: string; title: string; agent: string; } export interface JellyfinLibraryItem { Name: string; Id: string; HasSubtitles: boolean; Type: 'Movie' | 'Episode' | 'Season' | 'Series'; LocationType: 'FileSystem' | 'Offline' | 'Remote' | 'Virtual'; SeriesName?: string; SeriesId?: string; SeasonId?: string; SeasonName?: string; IndexNumber?: number; IndexNumberEnd?: number; ParentIndexNumber?: number; MediaType: string; } export interface JellyfinMediaStream { Codec: string; Type: 'Video' | 'Audio' | 'Subtitle'; Height?: number; Width?: number; AverageFrameRate?: number; RealFrameRate?: number; Language?: string; DisplayTitle: string; } export interface JellyfinMediaSource { Protocol: string; Id: string; Path: string; Type: string; VideoType: string; MediaStreams: JellyfinMediaStream[]; } export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem { ProviderIds: { Tmdb?: string; TheMovieDb?: string; Imdb?: string; Tvdb?: string; AniDB?: string; }; MediaSources?: JellyfinMediaSource[]; Width?: number; Height?: number; IsHD?: boolean; DateCreated?: string; } type EpisodeReturn = T extends { includeMediaInfo: true } ? JellyfinLibraryItemExtended[] : JellyfinLibraryItem[]; export interface JellyfinItemsReponse { Items: JellyfinLibraryItemExtended[]; TotalRecordCount: number; StartIndex: number; } class JellyfinAPI extends ExternalAPI { private userId?: string; private mediaServerType: MediaServerType; constructor( jellyfinHost: string, authToken?: string | null, deviceId?: string | null ) { const settings = getSettings(); const safeDeviceId = deviceId && deviceId.length > 0 ? deviceId : Buffer.from('BOT_seerr').toString('base64'); let authHeaderVal: string; if (authToken) { authHeaderVal = `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}", Token="${authToken}"`; } else { authHeaderVal = `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${safeDeviceId}", Version="${getAppVersion()}"`; } super( jellyfinHost, {}, { headers: { Authorization: authHeaderVal, 'Content-Type': 'application/json', Accept: 'application/json', }, } ); this.mediaServerType = settings.main.mediaServerType; } public async login( Username?: string, Password?: string, ClientIP?: string ): Promise { const authenticate = async (useHeaders: boolean) => { const headers = useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {}; return this.post( '/Users/AuthenticateByName', { Username, Pw: Password, }, { headers } ); }; try { return await authenticate(true); } catch (e) { logger.debug('Failed to authenticate with headers', { label: 'Jellyfin API', error: e.response?.statusText, ip: ClientIP, }); if (!e.response?.status) { throw new ApiError(404, ApiErrorCode.InvalidUrl); } if (e.response?.status === 401) { throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials); } } try { return await authenticate(false); } catch (e) { if (e.response?.status === 401) { throw new ApiError(e.response?.status, ApiErrorCode.InvalidCredentials); } logger.error( `Something went wrong while authenticating with the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status, ip: ClientIP, } ); throw new ApiError(e.response?.status, ApiErrorCode.Unknown); } } public setUserId(userId: string): void { this.userId = userId; return; } public async getSystemInfo(): Promise { try { const systemInfoResponse = await this.get('/System/Info'); return systemInfoResponse; } catch (e) { throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } public async getServerName(): Promise { try { const serverResponse = await this.get( '/System/Info/Public' ); return serverResponse.ServerName; } catch (e) { logger.error( `Something went wrong while getting the server name from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.Unknown); } } public async getUsers(): Promise { try { const userReponse = await this.get(`/Users`); return { users: userReponse }; } catch (e) { logger.error( `Something went wrong while getting the account from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } public async getUser(): Promise { try { const userReponse = await this.get( `/Users/${this.userId ?? 'Me'}` ); return userReponse; } catch (e) { logger.error( `Something went wrong while getting the account from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } public async getLibraries(): Promise { try { const mediaFolderResponse = await this.get(`/Library/MediaFolders`); return this.mapLibraries(mediaFolderResponse.Items); } catch { // fallback to user views to get libraries // this only and maybe/depending on factors affects LDAP users try { const mediaFolderResponse = await this.get( `/Users/${this.userId ?? 'Me'}/Views` ); return this.mapLibraries(mediaFolderResponse.Items); } catch (e) { logger.error( `Something went wrong while getting libraries from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status, } ); return []; } } } private mapLibraries(mediaFolders: JellyfinMediaFolder[]): JellyfinLibrary[] { const excludedTypes = [ 'music', 'books', 'musicvideos', 'homevideos', 'boxsets', ]; return mediaFolders .filter((Item: JellyfinMediaFolder) => { return ( Item.Type === 'CollectionFolder' && !excludedTypes.includes(Item.CollectionType) ); }) .map((Item: JellyfinMediaFolder) => { return { key: Item.Id, title: Item.Name, type: Item.CollectionType === 'movies' ? 'movie' : 'show', agent: 'jellyfin', }; }); } public async getLibraryContents(id: string): Promise { try { const libraryItemsResponse = await this.get( `/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false` ); return libraryItemsResponse.Items.filter( (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' ); } catch (e) { logger.error( `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e?.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } public async getRecentlyAdded(id: string): Promise { try { const endpoint = this.mediaServerType === MediaServerType.JELLYFIN ? `/Items/Latest` : `/Users/${this.userId}/Items/Latest`; const itemResponse = await this.get( `${endpoint}?Limit=12&ParentId=${id}${ this.mediaServerType === MediaServerType.JELLYFIN ? `&userId=${this.userId ?? 'Me'}` : '' }` ); return itemResponse; } catch (e) { logger.error( `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } public async getItemData( id: string ): Promise { try { const itemResponse = await this.get(`/Items`, { params: { ids: id, fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated', }, }); return itemResponse.Items?.[0]; } catch (e) { if (availabilitySync.running) { if (e.response?.status === 500) { return undefined; } } logger.error( `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } public async getSeasons(seriesID: string): Promise { try { const seasonResponse = await this.get(`/Shows/${seriesID}/Seasons`); return seasonResponse.Items; } catch (e) { logger.error( `Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } public async getEpisodes< T extends { includeMediaInfo?: boolean } | undefined = undefined, >( seriesID: string, seasonID: string, options?: T ): Promise> { try { const episodeResponse = await this.get( `/Shows/${seriesID}/Episodes`, { params: { seasonId: seasonID, ...(options?.includeMediaInfo && { fields: 'MediaSources' }), }, } ); return episodeResponse.Items.filter( (item: JellyfinLibraryItem) => item.LocationType !== 'Virtual' ); } catch (e) { logger.error( `Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } public async createApiToken(appName: string): Promise { try { await this.post(`/Auth/Keys?App=${appName}`); const apiKeys = await this.get(`/Auth/Keys`); return apiKeys.Items.reverse().find( (item: any) => item.AppName === appName ).AccessToken; } catch (e) { logger.error( `Something went wrong while creating an API key from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API', error: e.response?.status } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); } } } export default JellyfinAPI; ================================================ FILE: server/api/metadata.ts ================================================ import type { TvShowProvider } from '@server/api/provider'; import TheMovieDb from '@server/api/themoviedb'; import Tvdb from '@server/api/tvdb'; import { getSettings, MetadataProviderType } from '@server/lib/settings'; import logger from '@server/logger'; export const getMetadataProvider = async ( mediaType: 'movie' | 'tv' | 'anime' ): Promise => { try { const settings = await getSettings(); if (mediaType == 'movie') { return new TheMovieDb(); } if ( mediaType == 'tv' && settings.metadataSettings.tv == MetadataProviderType.TVDB ) { return await Tvdb.getInstance(); } if ( mediaType == 'anime' && settings.metadataSettings.anime == MetadataProviderType.TVDB ) { return await Tvdb.getInstance(); } return new TheMovieDb(); } catch (e) { logger.error('Failed to get metadata provider', { label: 'Metadata', message: e.message, }); return new TheMovieDb(); } }; ================================================ FILE: server/api/plexapi.ts ================================================ import ExternalAPI from '@server/api/externalapi'; import type { Library, PlexSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; interface PlexStatusResponse { MediaContainer: { machineIdentifier: string; friendlyName: string; }; } export interface PlexLibraryItem { ratingKey: string; parentRatingKey?: string; grandparentRatingKey?: string; title: string; guid: string; parentGuid?: string; grandparentGuid?: string; addedAt: number; updatedAt: number; Guid?: { id: string; }[]; type: 'movie' | 'show' | 'season' | 'episode'; Media: Media[]; } interface PlexLibraryResponse { MediaContainer: { totalSize: number; Metadata: PlexLibraryItem[]; }; } export interface PlexLibrary { type: 'show' | 'movie'; key: string; title: string; agent: string; } interface PlexLibrariesResponse { MediaContainer: { Directory: PlexLibrary[]; }; } export interface PlexMetadata { ratingKey: string; parentRatingKey?: string; guid: string; type: 'movie' | 'show' | 'season'; title: string; Guid: { id: string; }[]; Children?: { size: 12; Metadata: PlexMetadata[]; }; index: number; parentIndex?: number; leafCount: number; viewedLeafCount: number; addedAt: number; updatedAt: number; Media: Media[]; } interface Media { id: number; duration: number; bitrate: number; width: number; height: number; aspectRatio: number; audioChannels: number; audioCodec: string; videoCodec: string; videoResolution: string; container: string; videoFrameRate: string; videoProfile: string; } interface PlexMetadataResponse { MediaContainer: { Metadata: PlexMetadata[]; }; } class PlexAPI extends ExternalAPI { constructor({ plexToken, plexSettings, timeout, }: { plexToken?: string | null; plexSettings?: PlexSettings; timeout?: number; }) { const settings = getSettings(); const settingsPlex = plexSettings ?? settings.plex; const protocol = settingsPlex.useSsl ? 'https' : 'http'; const baseUrl = `${protocol}://${settingsPlex.ip}:${settingsPlex.port}`; super( baseUrl, {}, { timeout, headers: { 'X-Plex-Token': plexToken ?? '', 'X-Plex-Client-Identifier': settings.clientId, 'X-Plex-Product': 'Seerr', 'X-Plex-Device-Name': 'Seerr', 'X-Plex-Platform': 'Seerr', }, } ); } public async getStatus(): Promise { return await this.get('/'); } public async getLibraries(): Promise { const response = await this.get('/library/sections'); return response.MediaContainer.Directory; } public async syncLibraries(): Promise { const settings = getSettings(); try { const libraries = await this.getLibraries(); const newLibraries: Library[] = libraries // Remove libraries that are not movie or show .filter( (library) => library.type === 'movie' || library.type === 'show' ) // Remove libraries that do not have a metadata agent set (usually personal video libraries) .filter((library) => library.agent !== 'com.plexapp.agents.none') .map((library) => { const existing = settings.plex.libraries.find( (l) => l.id === library.key && l.name === library.title ); return { id: library.key, name: library.title, enabled: existing?.enabled ?? false, type: library.type, lastScan: existing?.lastScan, }; }); settings.plex.libraries = newLibraries; } catch (e) { logger.error('Failed to fetch Plex libraries', { label: 'Plex API', message: e.message, }); settings.plex.libraries = []; } await settings.save(); } public async getLibraryContents( id: string, { offset = 0, size = 50 }: { offset?: number; size?: number } = {} ): Promise<{ totalSize: number; items: PlexLibraryItem[] }> { const response = await this.get( `/library/sections/${id}/all?includeGuids=1`, { headers: { 'X-Plex-Container-Start': `${offset}`, 'X-Plex-Container-Size': `${size}`, }, } ); return { totalSize: response.MediaContainer.totalSize, items: response.MediaContainer.Metadata ?? [], }; } public async getMetadata( key: string, options: { includeChildren?: boolean } = {} ): Promise { const response = await this.get( `/library/metadata/${key}${ options.includeChildren ? '?includeChildren=1' : '' }` ); return response.MediaContainer.Metadata[0]; } public async getChildrenMetadata(key: string): Promise { const response = await this.get( `/library/metadata/${key}/children` ); return response.MediaContainer.Metadata; } public async getRecentlyAdded( id: string, options: { addedAt: number } = { addedAt: Date.now() - 1000 * 60 * 60, }, mediaType: 'movie' | 'show' ): Promise { const response = await this.get( `/library/sections/${id}/all?type=${ mediaType === 'show' ? '4' : '1' }&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`, { headers: { 'X-Plex-Container-Start': '0', 'X-Plex-Container-Size': '500', }, } ); return response.MediaContainer.Metadata; } } export default PlexAPI; ================================================ FILE: server/api/plextv.ts ================================================ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { randomUUID } from 'node:crypto'; import xml2js from 'xml2js'; import ExternalAPI from './externalapi'; interface PlexAccountResponse { user: PlexUser; } interface PlexUser { id: number; uuid: string; email: string; joined_at: string; username: string; title: string; thumb: string; hasPassword: boolean; authToken: string; subscription: { active: boolean; status: string; plan: string; features: string[]; }; roles: { roles: string[]; }; entitlements: string[]; } interface ConnectionResponse { $: { protocol: string; address: string; port: string; uri: string; local: string; }; } interface DeviceResponse { $: { name: string; product: string; productVersion: string; platform: string; platformVersion: string; device: string; clientIdentifier: string; createdAt: string; lastSeenAt: string; provides: string; owned: string; accessToken?: string; publicAddress?: string; httpsRequired?: string; synced?: string; relay?: string; dnsRebindingProtection?: string; natLoopbackSupported?: string; publicAddressMatches?: string; presence?: string; ownerID?: string; home?: string; sourceTitle?: string; }; Connection: ConnectionResponse[]; } interface ServerResponse { $: { id: string; serverId: string; machineIdentifier: string; name: string; lastSeenAt: string; numLibraries: string; owned: string; }; } interface UsersResponse { MediaContainer: { User: { $: { id: string; title: string; username: string; email: string; thumb: string; }; Server: ServerResponse[]; }[]; }; } interface WatchlistResponse { MediaContainer: { totalSize: number; Metadata?: { ratingKey: string; }[]; }; } interface MetadataResponse { MediaContainer: { Metadata: { ratingKey: string; type: 'movie' | 'show'; title: string; Guid?: { id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; }[]; }[]; }; } export interface PlexWatchlistItem { ratingKey: string; tmdbId: number; tvdbId?: number; type: 'movie' | 'show'; title: string; } export interface PlexWatchlistCache { etag: string; response: WatchlistResponse; } class PlexTvAPI extends ExternalAPI { private authToken: string; constructor(authToken: string) { super( 'https://plex.tv', {}, { headers: { 'X-Plex-Token': authToken, 'Content-Type': 'application/json', Accept: 'application/json', }, nodeCache: cacheManager.getCache('plextv').data, } ); this.authToken = authToken; } public async getDevices(): Promise { try { const devicesResp = await this.axios.get( '/api/resources?includeHttps=1', { transformResponse: [], responseType: 'text', } ); const parsedXml = await xml2js.parseStringPromise( devicesResp.data as DeviceResponse ); return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ name: pxml.$.name, product: pxml.$.product, productVersion: pxml.$.productVersion, platform: pxml.$?.platform, platformVersion: pxml.$?.platformVersion, device: pxml.$?.device, clientIdentifier: pxml.$.clientIdentifier, createdAt: new Date(parseInt(pxml.$?.createdAt, 10) * 1000), lastSeenAt: new Date(parseInt(pxml.$?.lastSeenAt, 10) * 1000), provides: pxml.$.provides.split(','), owned: pxml.$.owned == '1' ? true : false, accessToken: pxml.$?.accessToken, publicAddress: pxml.$?.publicAddress, publicAddressMatches: pxml.$?.publicAddressMatches == '1' ? true : false, httpsRequired: pxml.$?.httpsRequired == '1' ? true : false, synced: pxml.$?.synced == '1' ? true : false, relay: pxml.$?.relay == '1' ? true : false, dnsRebindingProtection: pxml.$?.dnsRebindingProtection == '1' ? true : false, natLoopbackSupported: pxml.$?.natLoopbackSupported == '1' ? true : false, presence: pxml.$?.presence == '1' ? true : false, ownerID: pxml.$?.ownerID, home: pxml.$?.home == '1' ? true : false, sourceTitle: pxml.$?.sourceTitle, connection: pxml?.Connection?.map((conn: ConnectionResponse) => ({ protocol: conn.$.protocol, address: conn.$.address, port: parseInt(conn.$.port, 10), uri: conn.$.uri, local: conn.$.local == '1' ? true : false, })), })); } catch (e) { logger.error('Something went wrong getting the devices from plex.tv', { label: 'Plex.tv API', errorMessage: e.message, }); throw new Error('Invalid auth token', { cause: e }); } } public async getUser(): Promise { try { const account = await this.axios.get( '/users/account.json' ); return account.data.user; } catch (e) { logger.error( `Something went wrong while getting the account from plex.tv: ${e.message}`, { label: 'Plex.tv API' } ); throw new Error('Invalid auth token', { cause: e }); } } public async checkUserAccess(userId: number): Promise { const settings = getSettings(); try { if (!settings.plex.machineId) { throw new Error('Plex is not configured!'); } const usersResponse = await this.getUsers(); const users = usersResponse.MediaContainer.User; const user = users.find((u) => parseInt(u.$.id) === userId); if (!user) { throw new Error( "This user does not exist on the main Plex account's shared list" ); } return !!user.Server?.find( (server) => server.$.machineIdentifier === settings.plex.machineId ); } catch (e) { logger.error(`Error checking user access: ${e.message}`); return false; } } public async getUsers(): Promise { const response = await this.axios.get('/api/users', { transformResponse: [], responseType: 'text', }); const parsedXml = (await xml2js.parseStringPromise( response.data )) as UsersResponse; return parsedXml; } public async getWatchlist({ offset = 0, size = 20, }: { offset?: number; size?: number } = {}): Promise<{ offset: number; size: number; totalSize: number; items: PlexWatchlistItem[]; }> { try { const watchlistCache = cacheManager.getCache('plexwatchlist'); let cachedWatchlist = watchlistCache.data.get( this.authToken ); const response = await this.axios.get( '/library/sections/watchlist/all', { params: { 'X-Plex-Container-Start': offset, 'X-Plex-Container-Size': size, }, headers: { 'If-None-Match': cachedWatchlist?.etag, }, baseURL: 'https://discover.provider.plex.tv', validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error } ); // If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache. if (response.status >= 200 && response.status <= 299) { cachedWatchlist = { etag: response.headers.etag, response: response.data, }; watchlistCache.data.set( this.authToken, cachedWatchlist ); } const watchlistDetails = await Promise.all( (cachedWatchlist?.response.MediaContainer.Metadata ?? []).map( async (watchlistItem) => { let detailedResponse: MetadataResponse; try { detailedResponse = await this.getRolling( `/library/metadata/${watchlistItem.ratingKey}`, { baseURL: 'https://discover.provider.plex.tv', } ); } catch (e) { if (e.response?.status === 404) { logger.warn( `Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`, { label: 'Plex.TV Metadata API' } ); return null; } else { throw e; } } const metadata = detailedResponse.MediaContainer.Metadata[0]; const tmdbString = metadata.Guid?.find((guid) => guid.id.startsWith('tmdb') ); const tvdbString = metadata.Guid?.find((guid) => guid.id.startsWith('tvdb') ); return { ratingKey: metadata.ratingKey, // This should always be set? But I guess it also cannot be? // We will filter out the 0's afterwards tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, tvdbId: tvdbString ? Number(tvdbString.id.split('//')[1]) : undefined, title: metadata.title, type: metadata.type, }; } ) ); const filteredList = watchlistDetails.filter( (detail) => detail?.tmdbId ) as PlexWatchlistItem[]; return { offset, size, totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0, items: filteredList, }; } catch (e) { logger.error('Failed to retrieve watchlist items', { label: 'Plex.TV Metadata API', errorMessage: e.message, }); return { offset, size, totalSize: 0, items: [], }; } } public async pingToken() { try { const response = await this.axios.get('/api/v2/ping', { headers: { 'X-Plex-Client-Identifier': randomUUID(), }, }); if (!response?.data?.pong) { throw new Error('No pong response'); } } catch (e) { logger.error('Failed to ping token', { label: 'Plex Refresh Token', errorMessage: e.message, }); } } } export default PlexTvAPI; ================================================ FILE: server/api/provider.ts ================================================ import type { TmdbSeasonWithEpisodes, TmdbTvDetails, } from '@server/api/themoviedb/interfaces'; export interface TvShowProvider { getTvShow({ tvId, language, }: { tvId: number; language?: string; }): Promise; getTvSeason({ tvId, seasonNumber, language, }: { tvId: number; seasonNumber: number; language?: string; }): Promise; getShowByTvdbId({ tvdbId, language, }: { tvdbId: number; language?: string; }): Promise; } ================================================ FILE: server/api/pushover.ts ================================================ import ExternalAPI from './externalapi'; interface PushoverSoundsResponse { sounds: { [name: string]: string; }; status: number; request: string; } export interface PushoverSound { name: string; description: string; } export const mapSounds = (sounds: { [name: string]: string; }): PushoverSound[] => Object.entries(sounds).map( ([name, description]) => ({ name, description, }) as PushoverSound ); class PushoverAPI extends ExternalAPI { constructor() { super( 'https://api.pushover.net/1', {}, { headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, } ); } public async getSounds(appToken: string): Promise { try { const data = await this.get('/sounds.json', { params: { token: appToken, }, }); return mapSounds(data.sounds); } catch (e) { throw new Error(`[Pushover] Failed to retrieve sounds: ${e.message}`, { cause: e, }); } } } export default PushoverAPI; ================================================ FILE: server/api/rating/imdbRadarrProxy.ts ================================================ import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; type IMDBRadarrProxyResponse = IMDBMovie[]; interface IMDBMovie { ImdbId: string; Overview: string; Title: string; OriginalTitle: string; TitleSlug: string; Ratings: Rating[]; MovieRatings: MovieRatings; Runtime: number; Images: Image[]; Genres: string[]; Popularity: number; Premier: string; InCinema: string; PhysicalRelease: any; DigitalRelease: string; Year: number; AlternativeTitles: AlternativeTitle[]; Translations: Translation[]; Recommendations: Recommendation[]; Credits: Credits; Studio: string; YoutubeTrailerId: string; Certifications: Certification[]; Status: any; Collection: Collection; OriginalLanguage: string; Homepage: string; TmdbId: number; } interface Rating { Count: number; Value: number; Origin: string; Type: string; } interface MovieRatings { Tmdb: Tmdb; Imdb: Imdb; Metacritic: Metacritic; RottenTomatoes: RottenTomatoes; } interface Tmdb { Count: number; Value: number; Type: string; } interface Imdb { Count: number; Value: number; Type: string; } interface Metacritic { Count: number; Value: number; Type: string; } interface RottenTomatoes { Count: number; Value: number; Type: string; } interface Image { CoverType: string; Url: string; } interface AlternativeTitle { Title: string; Type: string; Language: string; } interface Translation { Title: string; Overview: string; Language: string; } interface Recommendation { TmdbId: number; Title: string; } interface Credits { Cast: Cast[]; Crew: Crew[]; } interface Cast { Name: string; Order: number; Character: string; TmdbId: number; CreditId: string; Images: Image2[]; } interface Image2 { CoverType: string; Url: string; } interface Crew { Name: string; Job: string; Department: string; TmdbId: number; CreditId: string; Images: Image3[]; } interface Image3 { CoverType: string; Url: string; } interface Certification { Country: string; Certification: string; } interface Collection { Name: string; Images: any; Overview: any; Translations: any; Parts: any; TmdbId: number; } export interface IMDBRating { title: string; url: string; criticsScore: number; criticsScoreCount: number; } /** * This is a best-effort API. The IMDB API is technically * private and getting access costs money/requires approval. * * Radarr hosts a public proxy that's in use by all Radarr instances. */ class IMDBRadarrProxy extends ExternalAPI { constructor() { super('https://api.radarr.video/v1', { headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, nodeCache: cacheManager.getCache('imdb').data, }); } /** * Ask the Radarr IMDB Proxy for the movie * * @param IMDBid Id of IMDB movie */ public async getMovieRatings(IMDBid: string): Promise { try { const data = await this.get( `/movie/imdb/${IMDBid}` ); if ( !data?.length || data[0].ImdbId !== IMDBid || !data[0].MovieRatings.Imdb ) { return null; } return { title: data[0].Title, url: `https://www.imdb.com/title/${data[0].ImdbId}`, criticsScore: data[0].MovieRatings.Imdb.Value, criticsScoreCount: data[0].MovieRatings.Imdb.Count, }; } catch (e) { throw new Error( `[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`, { cause: e } ); } } } export default IMDBRadarrProxy; ================================================ FILE: server/api/rating/rottentomatoes.ts ================================================ import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; import jaro from 'wink-jaro-distance'; interface RTAlgoliaSearchResponse { results: { hits: RTAlgoliaHit[]; index: 'content_rt' | 'people_rt'; }[]; } interface RTAlgoliaHit { emsId: string; emsVersionId: string; tmsId: string; type: string; title: string; titles?: string[]; description: string; releaseYear: number; rating: string; genres: string[]; updateDate: string; isEmsSearchable: boolean; rtId: number; vanity: string; aka?: string[]; posterImageUrl: string; rottenTomatoes?: { audienceScore: number; criticsIconUrl: string; wantToSeeCount: number; audienceIconUrl: string; scoreSentiment: string; certifiedFresh: boolean; criticsScore: number; }; } export interface RTRating { title: string; year: number; criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten'; criticsScore: number; audienceRating?: 'Upright' | 'Spilled'; audienceScore?: number; url: string; } // Tunables const INEXACT_TITLE_FACTOR = 0.25; const ALTERNATE_TITLE_FACTOR = 0.8; const PER_YEAR_PENALTY = 0.4; const MINIMUM_SCORE = 0.175; // Normalization for title comparisons. // Lowercase and strip non-alphanumeric (unicode-aware). const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, ''); // Title similarity. 1 if exact, quarter-jaro otherwise. const similarity = (a: string, b: string): number => a === b ? 1 : jaro(a, b).similarity * INEXACT_TITLE_FACTOR; // Gets the best similarity score between the searched title and all alternate // titles of the search result. Non-main titles are penalized. const t_score = ({ title, titles, aka }: RTAlgoliaHit, s: string): number => { const f = (t: string, i: number) => similarity(norm(t), norm(s)) * (i ? ALTERNATE_TITLE_FACTOR : 1); return Math.max(...[title].concat(aka || [], titles || []).map(f)); }; // Year difference to score: 0 -> 1.0, 1 -> 0.6, 2 -> 0.2, 3+ -> 0.0 const y_score = (r: RTAlgoliaHit, y?: number): number => y ? Math.max(0, 1 - Math.abs(r.releaseYear - y) * PER_YEAR_PENALTY) : 1; // Cut score in half if result has no ratings. const extra_score = (r: RTAlgoliaHit): number => (r.rottenTomatoes ? 1 : 0.5); // Score search result as product of all subscores const score = (r: RTAlgoliaHit, name: string, year?: number): number => t_score(r, name) * y_score(r, year) * extra_score(r); // Score each search result and return the highest scoring result, if any const best = (rs: RTAlgoliaHit[], name: string, year?: number): RTAlgoliaHit => rs .map((r) => ({ score: score(r, name, year), result: r })) .filter(({ score }) => score > MINIMUM_SCORE) .sort(({ score: a }, { score: b }) => b - a)[0]?.result; /** * This is a best-effort API. The Rotten Tomatoes API is technically * private and getting access costs money/requires approval. * * They do, however, have a "public" api that they use to request the * data on their own site. We use this to get ratings for movies/tv shows. * * Unfortunately, we need to do it by searching for the movie name, so it's * not always accurate. */ class RottenTomatoes extends ExternalAPI { constructor() { const settings = getSettings(); super( 'https://79frdp12pn-dsn.algolia.net/1/indexes/*', { 'x-algolia-agent': 'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)', 'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561', 'x-algolia-application-id': '79FRDP12PN', }, { headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'x-algolia-usertoken': settings.clientId, }, nodeCache: cacheManager.getCache('rt').data, } ); } /** * Search the RT algolia api for the movie title * * We compare the release date to make sure its the correct * match. But it's not guaranteed to have results. * * @param name Movie name * @param year Release Year */ public async getMovieRatings( name: string, year: number ): Promise { try { const filters = encodeURIComponent('isEmsSearchable=1 AND type:"movie"'); const data = await this.post('/queries', { requests: [ { indexName: 'content_rt', query: name.replace(/\bthe\b ?/gi, ''), params: `filters=${filters}&hitsPerPage=20`, }, ], }); const contentResults = data.results.find((r) => r.index === 'content_rt'); const movie = best(contentResults?.hits || [], name, year); if (!movie?.rottenTomatoes) return null; return { title: movie.title, url: `https://www.rottentomatoes.com/m/${movie.vanity}`, criticsRating: movie.rottenTomatoes.certifiedFresh ? 'Certified Fresh' : movie.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten', criticsScore: movie.rottenTomatoes.criticsScore, audienceRating: movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled', audienceScore: movie.rottenTomatoes.audienceScore, year: Number(movie.releaseYear), }; } catch (e) { throw new Error( `[RT API] Failed to retrieve movie ratings: ${e.message}`, { cause: e } ); } } public async getTVRatings( name: string, year?: number ): Promise { try { const filters = encodeURIComponent('isEmsSearchable=1 AND type:"tv"'); const data = await this.post('/queries', { requests: [ { indexName: 'content_rt', query: name, params: `filters=${filters}&hitsPerPage=20`, }, ], }); const contentResults = data.results.find((r) => r.index === 'content_rt'); const tvshow = best(contentResults?.hits || [], name, year); if (!tvshow?.rottenTomatoes) return null; return { title: tvshow.title, url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`, criticsRating: tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten', criticsScore: tvshow.rottenTomatoes.criticsScore, audienceRating: tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled', audienceScore: tvshow.rottenTomatoes.audienceScore, year: Number(tvshow.releaseYear), }; } catch (e) { throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`, { cause: e, }); } } } export default RottenTomatoes; ================================================ FILE: server/api/ratings.ts ================================================ import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy'; import { type RTRating } from '@server/api/rating/rottentomatoes'; export interface RatingResponse { rt?: RTRating; imdb?: IMDBRating; } ================================================ FILE: server/api/servarr/base.ts ================================================ import ExternalAPI from '@server/api/externalapi'; import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; import { getSettings, type DVRSettings } from '@server/lib/settings'; export interface SystemStatus { version: string; buildTime: Date; isDebug: boolean; isProduction: boolean; isAdmin: boolean; isUserInteractive: boolean; startupPath: string; appData: string; osName: string; osVersion: string; isNetCore: boolean; isMono: boolean; isLinux: boolean; isOsx: boolean; isWindows: boolean; isDocker: boolean; mode: string; branch: string; authentication: string; sqliteVersion: string; migrationVersion: number; urlBase: string; runtimeVersion: string; runtimeName: string; startTime: Date; packageUpdateMechanism: string; } export interface RootFolder { id: number; path: string; freeSpace: number; totalSpace: number; unmappedFolders: { name: string; path: string; }[]; } export interface QualityProfile { id: number; name: string; } interface QueueItem { size: number; title: string; sizeleft: number; timeleft: string; estimatedCompletionTime: string; status: string; trackedDownloadStatus: string; trackedDownloadState: string; downloadId: string; protocol: string; downloadClient: string; indexer: string; id: number; } export interface Tag { id: number; label: string; } interface QueueResponse { page: number; pageSize: number; sortKey: string; sortDirection: string; totalRecords: number; records: (QueueItem & QueueItemAppendT)[]; } class ServarrBase extends ExternalAPI { static buildUrl(settings: DVRSettings, path?: string): string { return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ settings.port }${settings.baseUrl ?? ''}${path}`; } protected apiName: string; constructor({ url, apiKey, cacheName, apiName, }: { url: string; apiKey: string; cacheName: AvailableCacheIds; apiName: string; }) { const timeout = getSettings().network.apiRequestTimeout; super( url, { apikey: apiKey, }, { nodeCache: cacheManager.getCache(cacheName).data, timeout, } ); this.apiName = apiName; } public getSystemStatus = async (): Promise => { try { const response = await this.axios.get('/system/status'); return response.data; } catch (e) { throw new Error( `[${this.apiName}] Failed to retrieve system status: ${e.message}`, { cause: e } ); } }; public getProfiles = async (): Promise => { try { const data = await this.getRolling( `/qualityProfile`, undefined, 3600 ); return data; } catch (e) { throw new Error( `[${this.apiName}] Failed to retrieve profiles: ${e.message}`, { cause: e } ); } }; public getRootFolders = async (): Promise => { try { const data = await this.getRolling( `/rootfolder`, undefined, 3600 ); return data; } catch (e) { throw new Error( `[${this.apiName}] Failed to retrieve root folders: ${e.message}`, { cause: e } ); } }; public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => { try { const response = await this.axios.get>( `/queue`, { params: { includeEpisode: true, }, } ); return response.data.records; } catch (e) { throw new Error( `[${this.apiName}] Failed to retrieve queue: ${e.message}`, { cause: e } ); } }; public getTags = async (): Promise => { try { const response = await this.axios.get(`/tag`); return response.data; } catch (e) { throw new Error( `[${this.apiName}] Failed to retrieve tags: ${e.message}`, { cause: e } ); } }; public createTag = async ({ label }: { label: string }): Promise => { try { const response = await this.axios.post(`/tag`, { label, }); return response.data; } catch (e) { throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`, { cause: e, }); } }; public renameTag = async ({ id, label, }: { id: number; label: string; }): Promise => { try { const response = await this.axios.put(`/tag/${id}`, { id, label, }); return response.data; } catch (e) { throw new Error(`[${this.apiName}] Failed to rename tag: ${e.message}`, { cause: e, }); } }; async refreshMonitoredDownloads(): Promise { await this.runCommand('RefreshMonitoredDownloads', {}); } protected async runCommand( commandName: string, options: Record ): Promise { try { await this.axios.post(`/command`, { name: commandName, ...options, }); } catch (e) { throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`, { cause: e, }); } } } export default ServarrBase; ================================================ FILE: server/api/servarr/radarr.ts ================================================ import logger from '@server/logger'; import ServarrBase from './base'; export interface RadarrMovieOptions { title: string; qualityProfileId: number; minimumAvailability: string; tags: number[]; profileId: number; year: number; rootFolderPath: string; tmdbId: number; monitored?: boolean; searchNow?: boolean; } export interface RadarrMovie { id: number; title: string; isAvailable: boolean; monitored: boolean; tmdbId: number; imdbId: string; titleSlug: string; folderName: string; path: string; profileId: number; qualityProfileId: number; added: string; hasFile: boolean; tags: number[]; movieFile?: { id: number; movieId: number; relativePath?: string; path?: string; size: number; dateAdded: string; sceneName?: string; releaseGroup?: string; edition?: string; indexerFlags?: number; mediaInfo: { id: number; audioBitrate: number; audioChannels: number; audioCodec?: string; audioLanguages?: string; audioStreamCount: number; videoBitDepth: number; videoBitrate: number; videoCodec?: string; videoFps: number; videoDynamicRange?: string; videoDynamicRangeType?: string; resolution?: string; runTime?: string; scanType?: string; subtitles?: string; }; originalFilePath?: string; qualityCutoffNotMet: boolean; }; } class RadarrAPI extends ServarrBase<{ movieId: number }> { constructor({ url, apiKey }: { url: string; apiKey: string }) { super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' }); } public getMovies = async (): Promise => { try { const response = await this.axios.get('/movie'); return response.data; } catch (e) { throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`, { cause: e, }); } }; public getMovie = async ({ id }: { id: number }): Promise => { try { const response = await this.axios.get(`/movie/${id}`); return response.data; } catch (e) { throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`, { cause: e, }); } }; public async getMovieByTmdbId(id: number): Promise { try { const response = await this.axios.get('/movie/lookup', { params: { term: `tmdb:${id}`, }, }); if (!response.data[0]) { throw new Error('Movie not found'); } return response.data[0]; } catch (e) { logger.error('Error retrieving movie by TMDB ID', { label: 'Radarr API', errorMessage: e.message, tmdbId: id, }); throw new Error('Movie not found', { cause: e }); } } public addMovie = async ( options: RadarrMovieOptions ): Promise => { try { const movie = await this.getMovieByTmdbId(options.tmdbId); if (movie.hasFile) { logger.info( 'Title already exists and is available. Skipping add and returning success', { label: 'Radarr', movie, } ); return movie; } // movie exists in Radarr but is neither downloaded nor monitored if (movie.id && !movie.monitored) { const response = await this.axios.put(`/movie`, { ...movie, title: options.title, qualityProfileId: options.qualityProfileId, profileId: options.profileId, titleSlug: options.tmdbId.toString(), minimumAvailability: options.minimumAvailability, tmdbId: options.tmdbId, year: options.year, tags: Array.from(new Set([...movie.tags, ...options.tags])), rootFolderPath: options.rootFolderPath, monitored: options.monitored, addOptions: { searchForMovie: options.searchNow, }, }); if (response.data.monitored) { logger.info( 'Found existing title in Radarr and set it to monitored.', { label: 'Radarr', movieId: response.data.id, movieTitle: response.data.title, } ); logger.debug('Radarr update details', { label: 'Radarr', movie: response.data, }); if (options.searchNow) { this.searchMovie(response.data.id); } return response.data; } else { logger.error('Failed to update existing movie in Radarr.', { label: 'Radarr', options, }); throw new Error('Failed to update existing movie in Radarr'); } } if (movie.id) { // Movie exists and is already monitored logger.info('Movie is already monitored in Radarr.', { label: 'Radarr', movieId: movie.id, movieTitle: movie.title, hasFile: movie.hasFile, }); // If searchNow is requested and movie doesn't have a file, trigger search if (options.searchNow && !movie.hasFile) { logger.info( 'Triggering search for existing monitored movie without file', { label: 'Radarr', movieId: movie.id, movieTitle: movie.title, } ); this.searchMovie(movie.id); } return movie; } const response = await this.axios.post(`/movie`, { title: options.title, qualityProfileId: options.qualityProfileId, profileId: options.profileId, titleSlug: options.tmdbId.toString(), minimumAvailability: options.minimumAvailability, tmdbId: options.tmdbId, year: options.year, rootFolderPath: options.rootFolderPath, monitored: options.monitored, tags: options.tags, addOptions: { searchForMovie: options.searchNow, }, }); if (response.data.id) { logger.info('Radarr accepted request', { label: 'Radarr' }); logger.debug('Radarr add details', { label: 'Radarr', movie: response.data, }); } else { logger.error('Failed to add movie to Radarr', { label: 'Radarr', options, }); throw new Error('Failed to add movie to Radarr'); } return response.data; } catch (e) { logger.error( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', { label: 'Radarr', errorMessage: e.message, options, response: e?.response?.data, } ); throw new Error('Failed to add movie to Radarr', { cause: e }); } }; public async searchMovie(movieId: number): Promise { logger.info('Executing movie search command', { label: 'Radarr API', movieId, }); try { await this.runCommand('MoviesSearch', { movieIds: [movieId] }); } catch (e) { logger.error( 'Something went wrong while executing Radarr movie search.', { label: 'Radarr API', errorMessage: e.message, movieId, } ); } } public removeMovie = async (movieId: number): Promise => { try { const { id, title } = await this.getMovieByTmdbId(movieId); await this.axios.delete(`/movie/${id}`, { params: { deleteFiles: true, addImportExclusion: false, }, }); logger.info(`[Radarr] Removed movie ${title}`); } catch (e) { throw new Error(`[Radarr] Failed to remove movie: ${e.message}`, { cause: e, }); } }; public clearCache = ({ tmdbId, externalId, }: { tmdbId?: number | null; externalId?: number | null; }) => { if (tmdbId) { this.removeCache('/movie/lookup', { term: `tmdb:${tmdbId}`, }); } if (externalId) { this.removeCache(`/movie/${externalId}`); } }; } export default RadarrAPI; ================================================ FILE: server/api/servarr/sonarr.ts ================================================ import logger from '@server/logger'; import ServarrBase from './base'; export interface SonarrSeason { seasonNumber: number; monitored: boolean; statistics?: { previousAiring?: string; episodeFileCount: number; episodeCount: number; totalEpisodeCount: number; sizeOnDisk: number; percentOfEpisodes: number; }; } interface EpisodeResult { seriesId: number; episodeFileId: number; seasonNumber: number; episodeNumber: number; title: string; airDate: string; airDateUtc: string; overview: string; hasFile: boolean; monitored: boolean; absoluteEpisodeNumber: number; unverifiedSceneNumbering: boolean; id: number; } export interface SonarrSeries { title: string; sortTitle: string; seasonCount: number; status: string; overview: string; network: string; airTime: string; images: { coverType: string; url: string; }[]; remotePoster: string; seasons: SonarrSeason[]; year: number; path: string; profileId: number; languageProfileId: number; seasonFolder: boolean; monitored: boolean; monitorNewItems: 'all' | 'none'; useSceneNumbering: boolean; runtime: number; tvdbId: number; tvRageId: number; tvMazeId: number; firstAired: string; lastInfoSync?: string; seriesType: 'standard' | 'daily' | 'anime'; cleanTitle: string; imdbId: string; titleSlug: string; certification: string; genres: string[]; tags: number[]; added: string; ratings: { votes: number; value: number; }; qualityProfileId: number; id?: number; rootFolderPath?: string; addOptions?: { ignoreEpisodesWithFiles?: boolean; ignoreEpisodesWithoutFiles?: boolean; searchForMissingEpisodes?: boolean; }; statistics: { seasonCount: number; episodeFileCount: number; episodeCount: number; totalEpisodeCount: number; sizeOnDisk: number; releaseGroups: string[]; percentOfEpisodes: number; }; } export interface AddSeriesOptions { tvdbid: number; title: string; profileId: number; languageProfileId?: number; seasons: number[]; seasonFolder: boolean; rootFolderPath: string; tags?: number[]; seriesType: SonarrSeries['seriesType']; monitored?: boolean; monitorNewItems?: SonarrSeries['monitorNewItems']; searchNow?: boolean; } export interface LanguageProfile { id: number; name: string; } class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number; episode: EpisodeResult; }> { constructor({ url, apiKey }: { url: string; apiKey: string }) { super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' }); } public async getSeries(): Promise { try { const response = await this.axios.get('/series'); return response.data; } catch (e) { throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`, { cause: e, }); } } public async getSeriesById(id: number): Promise { try { const response = await this.axios.get(`/series/${id}`); return response.data; } catch (e) { throw new Error( `[Sonarr] Failed to retrieve series by ID: ${e.message}`, { cause: e } ); } } public async getSeriesByTitle(title: string): Promise { try { const response = await this.axios.get('/series/lookup', { params: { term: title, }, }); if (!response.data[0]) { throw new Error('No series found'); } return response.data; } catch (e) { logger.error('Error retrieving series by series title', { label: 'Sonarr API', errorMessage: e.message, title, }); throw new Error('No series found', { cause: e }); } } public async getSeriesByTvdbId(id: number): Promise { try { const response = await this.axios.get('/series/lookup', { params: { term: `tvdb:${id}`, }, }); if (!response.data[0]) { throw new Error('Series not found'); } return response.data[0]; } catch (e) { logger.error('Error retrieving series by tvdb ID', { label: 'Sonarr API', errorMessage: e.message, tvdbId: id, }); throw new Error('Series not found', { cause: e }); } } public async addSeries(options: AddSeriesOptions): Promise { try { const series = await this.getSeriesByTvdbId(options.tvdbid); // If the series already exists, we will simply just update it if (series.id) { series.monitored = options.monitored ?? series.monitored; series.tags = options.tags ? Array.from(new Set([...series.tags, ...options.tags])) : series.tags; series.seasons = this.buildSeasonList(options.seasons, series.seasons); const newSeriesResponse = await this.axios.put( '/series', series ); if (newSeriesResponse.data.id) { logger.info('Updated existing series in Sonarr.', { label: 'Sonarr', seriesId: newSeriesResponse.data.id, seriesTitle: newSeriesResponse.data.title, }); logger.debug('Sonarr update details', { label: 'Sonarr', series: newSeriesResponse.data, }); try { const episodes = await this.getEpisodes(newSeriesResponse.data.id); const episodeIdsToMonitor = episodes .filter( (ep) => options.seasons.includes(ep.seasonNumber) && !ep.monitored ) .map((ep) => ep.id); if (episodeIdsToMonitor.length > 0) { logger.debug( 'Re-monitoring unmonitored episodes for requested seasons.', { label: 'Sonarr', seriesId: newSeriesResponse.data.id, episodeCount: episodeIdsToMonitor.length, } ); await this.monitorEpisodes(episodeIdsToMonitor); } } catch (e) { logger.warn('Failed to re-monitor episodes', { label: 'Sonarr', errorMessage: e.message, seriesId: newSeriesResponse.data.id, }); } if (options.searchNow) { this.searchSeries(newSeriesResponse.data.id); } return newSeriesResponse.data; } else { logger.error('Failed to update series in Sonarr', { label: 'Sonarr', options, }); throw new Error('Failed to update series in Sonarr'); } } const createdSeriesResponse = await this.axios.post( '/series', { tvdbId: options.tvdbid, title: options.title, qualityProfileId: options.profileId, languageProfileId: options.languageProfileId, seasons: this.buildSeasonList( options.seasons, series.seasons.map((season) => ({ seasonNumber: season.seasonNumber, // We force all seasons to false if its the first request monitored: false, })) ), tags: options.tags, seasonFolder: options.seasonFolder, monitored: options.monitored, monitorNewItems: options.monitorNewItems, rootFolderPath: options.rootFolderPath, seriesType: options.seriesType, addOptions: { ignoreEpisodesWithFiles: true, searchForMissingEpisodes: options.searchNow, }, } as Partial ); if (createdSeriesResponse.data.id) { logger.info('Sonarr accepted request', { label: 'Sonarr' }); logger.debug('Sonarr add details', { label: 'Sonarr', series: createdSeriesResponse.data, }); } else { logger.error('Failed to add series to Sonarr', { label: 'Sonarr', options, }); throw new Error('Failed to add series to Sonarr'); } return createdSeriesResponse.data; } catch (e) { logger.error('Something went wrong while adding a series to Sonarr.', { label: 'Sonarr API', errorMessage: e.message, options, response: e?.response?.data, }); throw new Error('Failed to add series', { cause: e }); } } public async getLanguageProfiles(): Promise { try { const data = await this.getRolling( '/languageprofile', undefined, 3600 ); return data; } catch (e) { logger.error( 'Something went wrong while retrieving Sonarr language profiles.', { label: 'Sonarr API', errorMessage: e.message, } ); throw new Error('Failed to get language profiles', { cause: e }); } } public async searchSeries(seriesId: number): Promise { logger.info('Executing series search command.', { label: 'Sonarr API', seriesId, }); try { await this.runCommand('MissingEpisodeSearch', { seriesId }); } catch (e) { logger.error( 'Something went wrong while executing Sonarr missing episode search.', { label: 'Sonarr API', errorMessage: e.message, seriesId, } ); } } public async getEpisodes(seriesId: number): Promise { try { const response = await this.axios.get('/episode', { params: { seriesId }, }); return response.data; } catch (e) { logger.error('Failed to retrieve episodes', { label: 'Sonarr API', errorMessage: e.message, seriesId, }); throw new Error('Failed to get episodes', { cause: e }); } } public async monitorEpisodes(episodeIds: number[]): Promise { try { await this.axios.put('/episode/monitor', { episodeIds, monitored: true, }); } catch (e) { logger.error('Failed to monitor episodes', { label: 'Sonarr API', errorMessage: e.message, episodeIds, }); throw new Error('Failed to monitor episodes', { cause: e }); } } private buildSeasonList( seasons: number[], existingSeasons?: SonarrSeason[] ): SonarrSeason[] { if (existingSeasons) { const newSeasons = existingSeasons.map((season) => { if (seasons.includes(season.seasonNumber)) { season.monitored = true; } return season; }); return newSeasons; } const newSeasons = seasons.map( (seasonNumber): SonarrSeason => ({ seasonNumber, monitored: true, }) ); return newSeasons; } public removeSeries = async (serieId: number): Promise => { try { const { id, title } = await this.getSeriesByTvdbId(serieId); await this.axios.delete(`/series/${id}`, { params: { deleteFiles: true, addImportExclusion: false, }, }); logger.info(`[Sonarr] Removed series ${title}`); } catch (e) { throw new Error(`[Sonarr] Failed to remove series: ${e.message}`, { cause: e, }); } }; public clearCache = ({ tvdbId, externalId, title, }: { tvdbId?: number | null; externalId?: number | null; title?: string | null; }) => { if (tvdbId) { this.removeCache('/series/lookup', { term: `tvdb:${tvdbId}`, }); } if (externalId) { this.removeCache(`/series/${externalId}`); } if (title) { this.removeCache('/series/lookup', { term: title, }); } }; } export default SonarrAPI; ================================================ FILE: server/api/tautulli.ts ================================================ import type { User } from '@server/entity/User'; import type { TautulliSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { requestInterceptorFunction } from '@server/utils/customProxyAgent'; import type { AxiosInstance } from 'axios'; import axios from 'axios'; import { uniqWith } from 'lodash'; export interface TautulliHistoryRecord { date: number; duration: number; friendly_name: string; full_title: string; grandparent_rating_key: number; grandparent_title: string; original_title: string; group_count: number; group_ids?: string; guid: string; ip_address: string; live: number; machine_id: string; media_index: number; media_type: string; originally_available_at: string; parent_media_index: number; parent_rating_key: number; parent_title: string; paused_counter: number; percent_complete: number; platform: string; product: string; player: string; rating_key: number; reference_id?: number; row_id?: number; session_key?: string; started: number; state?: string; stopped: number; thumb: string; title: string; transcode_decision: string; user: string; user_id: number; watched_status: number; year: number; } interface TautulliHistoryResponse { response: { result: string; message?: string; data: { draw: number; recordsTotal: number; recordsFiltered: number; total_duration: string; filter_duration: string; data: TautulliHistoryRecord[]; }; }; } interface TautulliWatchStats { query_days: number; total_time: number; total_plays: number; } interface TautulliWatchStatsResponse { response: { result: string; message?: string; data: TautulliWatchStats[]; }; } interface TautulliWatchUser { friendly_name: string; user_id: number; user_thumb: string; username: string; total_plays: number; total_time: number; } interface TautulliWatchUsersResponse { response: { result: string; message?: string; data: TautulliWatchUser[]; }; } interface TautulliInfo { tautulli_install_type: string; tautulli_version: string; tautulli_branch: string; tautulli_commit: string; tautulli_platform: string; tautulli_platform_release: string; tautulli_platform_version: string; tautulli_platform_linux_distro: string; tautulli_platform_device_name: string; tautulli_python_version: string; } interface TautulliInfoResponse { response: { result: string; message?: string; data: TautulliInfo; }; } class TautulliAPI { private axios: AxiosInstance; constructor(settings: TautulliSettings) { this.axios = axios.create({ baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ settings.port }${settings.urlBase ?? ''}`, params: { apikey: settings.apiKey }, }); this.axios.interceptors.request.use(requestInterceptorFunction); } public async getInfo(): Promise { try { return ( await this.axios.get('/api/v2', { params: { cmd: 'get_tautulli_info' }, }) ).data.response.data; } catch (e) { logger.error('Something went wrong fetching Tautulli server info', { label: 'Tautulli API', errorMessage: e.message, }); throw new Error( `[Tautulli] Failed to fetch Tautulli server info: ${e.message}`, { cause: e } ); } } public async getMediaWatchStats( ratingKey: string ): Promise { try { return ( await this.axios.get('/api/v2', { params: { cmd: 'get_item_watch_time_stats', rating_key: ratingKey, grouping: 1, }, }) ).data.response.data; } catch (e) { logger.error( 'Something went wrong fetching media watch stats from Tautulli', { label: 'Tautulli API', errorMessage: e.message, ratingKey, } ); throw new Error( `[Tautulli] Failed to fetch media watch stats: ${e.message}`, { cause: e } ); } } public async getMediaWatchUsers( ratingKey: string ): Promise { try { return ( await this.axios.get('/api/v2', { params: { cmd: 'get_item_user_stats', rating_key: ratingKey, grouping: 1, }, }) ).data.response.data; } catch (e) { logger.error( 'Something went wrong fetching media watch users from Tautulli', { label: 'Tautulli API', errorMessage: e.message, ratingKey, } ); throw new Error( `[Tautulli] Failed to fetch media watch users: ${e.message}`, { cause: e } ); } } public async getUserWatchStats(user: User): Promise { try { if (!user.plexId) { throw new Error('User does not have an associated Plex ID'); } return ( await this.axios.get('/api/v2', { params: { cmd: 'get_user_watch_time_stats', user_id: user.plexId, query_days: 0, grouping: 1, }, }) ).data.response.data[0]; } catch (e) { logger.error( 'Something went wrong fetching user watch stats from Tautulli', { label: 'Tautulli API', errorMessage: e.message, user: user.displayName, } ); throw new Error( `[Tautulli] Failed to fetch user watch stats: ${e.message}`, { cause: e } ); } } public async getUserWatchHistory( user: User ): Promise { let results: TautulliHistoryRecord[] = []; try { if (!user.plexId) { throw new Error('User does not have an associated Plex ID'); } const take = 100; let start = 0; while (results.length < 20) { const tautulliData = ( await this.axios.get('/api/v2', { params: { cmd: 'get_history', grouping: 1, order_column: 'date', order_dir: 'desc', user_id: user.plexId, media_type: 'movie,episode', length: take, start, }, }) ).data.response.data.data; if (!tautulliData.length) { return results; } results = uniqWith(results.concat(tautulliData), (recordA, recordB) => recordA.grandparent_rating_key && recordB.grandparent_rating_key ? recordA.grandparent_rating_key === recordB.grandparent_rating_key : recordA.parent_rating_key && recordB.parent_rating_key ? recordA.parent_rating_key === recordB.parent_rating_key : recordA.rating_key === recordB.rating_key ); start += take; } return results.slice(0, 20); } catch (e) { logger.error( 'Something went wrong fetching user watch history from Tautulli', { label: 'Tautulli API', errorMessage: e.message, user: user.displayName, } ); throw new Error( `[Tautulli] Failed to fetch user watch history: ${e.message}`, { cause: e } ); } } } export default TautulliAPI; ================================================ FILE: server/api/themoviedb/constants.ts ================================================ export const ANIME_KEYWORD_ID = 210024; ================================================ FILE: server/api/themoviedb/index.ts ================================================ import ExternalAPI from '@server/api/externalapi'; import type { TvShowProvider } from '@server/api/provider'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; import { sortBy } from 'lodash'; import type { TmdbCollection, TmdbCompanySearchResponse, TmdbExternalIdResponse, TmdbGenre, TmdbGenresResult, TmdbKeyword, TmdbKeywordSearchResponse, TmdbLanguage, TmdbMovieDetails, TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetails, TmdbProductionCompany, TmdbRegion, TmdbSearchMovieResponse, TmdbSearchMultiResponse, TmdbSearchTvResponse, TmdbSeasonWithEpisodes, TmdbTvDetails, TmdbUpcomingMoviesResponse, TmdbWatchProviderDetails, TmdbWatchProviderRegion, } from './interfaces'; interface SearchOptions { query: string; page?: number; includeAdult?: boolean; language?: string; } interface SingleSearchOptions extends SearchOptions { year?: number; } export const SortOptionsIterable = [ 'popularity.desc', 'popularity.asc', 'release_date.desc', 'release_date.asc', 'revenue.desc', 'revenue.asc', 'primary_release_date.desc', 'primary_release_date.asc', 'original_title.asc', 'original_title.desc', 'vote_average.desc', 'vote_average.asc', 'vote_count.desc', 'vote_count.asc', 'first_air_date.desc', 'first_air_date.asc', ] as const; export type SortOptions = (typeof SortOptionsIterable)[number]; export interface TmdbCertificationResponse { certifications: { [country: string]: { certification: string; meaning?: string; order?: number; }[]; }; } interface DiscoverMovieOptions { page?: number; includeAdult?: boolean; includeVideo?: boolean; language?: string; primaryReleaseDateGte?: string; primaryReleaseDateLte?: string; withRuntimeGte?: string; withRuntimeLte?: string; voteAverageGte?: string; voteAverageLte?: string; voteCountGte?: string; voteCountLte?: string; originalLanguage?: string; genre?: string; studio?: string; keywords?: string; excludeKeywords?: string; sortBy?: SortOptions; watchRegion?: string; watchProviders?: string; certification?: string; certificationGte?: string; certificationLte?: string; certificationCountry?: string; } interface DiscoverTvOptions { page?: number; language?: string; firstAirDateGte?: string; firstAirDateLte?: string; withRuntimeGte?: string; withRuntimeLte?: string; voteAverageGte?: string; voteAverageLte?: string; voteCountGte?: string; voteCountLte?: string; includeEmptyReleaseDate?: boolean; originalLanguage?: string; genre?: string; network?: number; keywords?: string; excludeKeywords?: string; sortBy?: SortOptions; watchRegion?: string; watchProviders?: string; withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5 certification?: string; certificationGte?: string; certificationLte?: string; certificationCountry?: string; } class TheMovieDb extends ExternalAPI implements TvShowProvider { private locale: string; private discoverRegion?: string; private originalLanguage?: string; constructor({ discoverRegion, originalLanguage, }: { discoverRegion?: string; originalLanguage?: string } = {}) { super( 'https://api.themoviedb.org/3', { api_key: '431a8708161bcd1f1fbe7536137e61ed', }, { nodeCache: cacheManager.getCache('tmdb').data, rateLimit: { maxRequests: 20, maxRPS: 50, }, } ); this.locale = getSettings().main?.locale || 'en'; this.discoverRegion = discoverRegion; this.originalLanguage = originalLanguage; } public searchMulti = async ({ query, page = 1, includeAdult = false, language = this.locale, }: SearchOptions): Promise => { try { const data = await this.get('/search/multi', { params: { query, page, include_adult: includeAdult, language }, }); return data; } catch { return { page: 1, results: [], total_pages: 1, total_results: 0, }; } }; public searchMovies = async ({ query, page = 1, includeAdult = false, language = this.locale, year, }: SingleSearchOptions): Promise => { try { const data = await this.get('/search/movie', { params: { query, page, include_adult: includeAdult, language, primary_release_year: year, }, }); return data; } catch { return { page: 1, results: [], total_pages: 1, total_results: 0, }; } }; public searchTvShows = async ({ query, page = 1, includeAdult = false, language = this.locale, year, }: SingleSearchOptions): Promise => { try { const data = await this.get('/search/tv', { params: { query, page, include_adult: includeAdult, language, first_air_date_year: year, }, }); return data; } catch { return { page: 1, results: [], total_pages: 1, total_results: 0, }; } }; public getPerson = async ({ personId, language = this.locale, }: { personId: number; language?: string; }): Promise => { try { const data = await this.get(`/person/${personId}`, { params: { language }, }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch person details: ${e.message}`, { cause: e, }); } }; public getPersonCombinedCredits = async ({ personId, language = this.locale, }: { personId: number; language?: string; }): Promise => { try { const data = await this.get( `/person/${personId}/combined_credits`, { params: { language }, } ); return data; } catch (e) { throw new Error( `[TMDB] Failed to fetch person combined credits: ${e.message}`, { cause: e } ); } }; public getMovie = async ({ movieId, language = this.locale, }: { movieId: number; language?: string; }): Promise => { try { const data = await this.get( `/movie/${movieId}`, { params: { language, append_to_response: 'credits,external_ids,videos,keywords,release_dates,watch/providers', include_video_language: language, }, }, 43200 ); if ( (!language || !language.startsWith('en')) && !data.videos?.results?.some((video) => video.type === 'Trailer') ) { try { const fallback = await this.get( `/movie/${movieId}`, { params: { language, append_to_response: 'videos', include_video_language: 'en', }, }, 43200 ); const localizedVideos = data.videos?.results ?? []; const localizedVideoKeys = new Set( localizedVideos.map((video) => video.key) ); const englishFallbackTrailers = fallback.videos?.results?.filter( (video) => video.type === 'Trailer' && !localizedVideoKeys.has(video.key) ) ?? []; if (englishFallbackTrailers.length > 0) { data.videos = { ...(data.videos ?? { results: [] }), results: [...localizedVideos, ...englishFallbackTrailers], }; } } catch { // Ignore trailer fallback failures; return the original data. } } return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch movie details: ${e.message}`, { cause: e, }); } }; public getTvShow = async ({ tvId, language = this.locale, }: { tvId: number; language?: string; }): Promise => { try { const data = await this.get( `/tv/${tvId}`, { params: { language, append_to_response: 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers', include_video_language: language, }, }, 43200 ); if ( (!language || !language.startsWith('en')) && !data.videos?.results?.some((video) => video.type === 'Trailer') ) { try { const fallback = await this.get( `/tv/${tvId}`, { params: { language, append_to_response: 'videos', include_video_language: 'en', }, }, 43200 ); const localizedVideos = data.videos?.results ?? []; const localizedVideoKeys = new Set( localizedVideos.map((video) => video.key) ); const englishFallbackTrailers = fallback.videos?.results?.filter( (video) => video.type === 'Trailer' && !localizedVideoKeys.has(video.key) ) ?? []; if (englishFallbackTrailers.length > 0) { data.videos = { ...(data.videos ?? { results: [] }), results: [...localizedVideos, ...englishFallbackTrailers], }; } } catch { // Ignore trailer fallback failures; return the original data. } } return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`, { cause: e, }); } }; public getTvSeason = async ({ tvId, seasonNumber, language, }: { tvId: number; seasonNumber: number; language?: string; }): Promise => { try { const data = await this.get( `/tv/${tvId}/season/${seasonNumber}`, { params: { language, append_to_response: 'external_ids', }, } ); data.episodes = data.episodes.map((episode) => { if (episode.still_path) { episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`; } return episode; }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`, { cause: e, }); } }; public async getMovieRecommendations({ movieId, page = 1, language = this.locale, }: { movieId: number; page?: number; language?: string; }): Promise { try { const data = await this.get( `/movie/${movieId}/recommendations`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`, { cause: e, }); } } public async getMovieSimilar({ movieId, page = 1, language = this.locale, }: { movieId: number; page?: number; language?: string; }): Promise { try { const data = await this.get( `/movie/${movieId}/similar`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`, { cause: e, }); } } public async getMoviesByKeyword({ keywordId, page = 1, language = this.locale, }: { keywordId: number; page?: number; language?: string; }): Promise { try { const data = await this.get( `/keyword/${keywordId}/movies`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error( `[TMDB] Failed to fetch movies by keyword: ${e.message}`, { cause: e } ); } } public async getTvRecommendations({ tvId, page = 1, language = this.locale, }: { tvId: number; page?: number; language?: string; }): Promise { try { const data = await this.get( `/tv/${tvId}/recommendations`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error( `[TMDB] Failed to fetch TV recommendations: ${e.message}`, { cause: e } ); } } public async getTvSimilar({ tvId, page = 1, language = this.locale, }: { tvId: number; page?: number; language?: string; }): Promise { try { const data = await this.get(`/tv/${tvId}/similar`, { params: { page, language, }, }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV similar: ${e.message}`, { cause: e, }); } } public getDiscoverMovies = async ({ sortBy = 'popularity.desc', page = 1, includeAdult = false, includeVideo = true, language = this.locale, primaryReleaseDateGte, primaryReleaseDateLte, originalLanguage, genre, studio, keywords, excludeKeywords, withRuntimeGte, withRuntimeLte, voteAverageGte, voteAverageLte, voteCountGte, voteCountLte, watchProviders, watchRegion, certification, certificationGte, certificationLte, certificationCountry, }: DiscoverMovieOptions = {}): Promise => { try { const defaultFutureDate = new Date( Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5) ) .toISOString() .split('T')[0]; const defaultPastDate = new Date('1900-01-01') .toISOString() .split('T')[0]; const data = await this.get('/discover/movie', { params: { sort_by: sortBy, page, include_adult: includeAdult, include_video: includeVideo, language, region: this.discoverRegion || '', with_original_language: originalLanguage && originalLanguage !== 'all' ? originalLanguage : originalLanguage === 'all' ? undefined : this.originalLanguage, // Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set! 'primary_release_date.gte': !primaryReleaseDateGte && primaryReleaseDateLte ? defaultPastDate : primaryReleaseDateGte, 'primary_release_date.lte': !primaryReleaseDateLte && primaryReleaseDateGte ? defaultFutureDate : primaryReleaseDateLte, with_genres: genre, with_companies: studio, with_keywords: keywords, without_keywords: excludeKeywords, 'with_runtime.gte': withRuntimeGte, 'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte, 'vote_count.gte': voteCountGte, 'vote_count.lte': voteCountLte, watch_region: watchRegion, with_watch_providers: watchProviders, certification: certification, 'certification.gte': certificationGte, 'certification.lte': certificationLte, certification_country: certificationCountry, }, }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch discover movies: ${e.message}`, { cause: e, }); } }; public getDiscoverTv = async ({ sortBy = 'popularity.desc', page = 1, language = this.locale, firstAirDateGte, firstAirDateLte, includeEmptyReleaseDate = false, originalLanguage, genre, network, keywords, excludeKeywords, withRuntimeGte, withRuntimeLte, voteAverageGte, voteAverageLte, voteCountGte, voteCountLte, watchProviders, watchRegion, withStatus, certification, certificationGte, certificationLte, certificationCountry, }: DiscoverTvOptions = {}): Promise => { try { const defaultFutureDate = new Date( Date.now() + 1000 * 60 * 60 * 24 * (365 * 1.5) ) .toISOString() .split('T')[0]; const defaultPastDate = new Date('1900-01-01') .toISOString() .split('T')[0]; const data = await this.get('/discover/tv', { params: { sort_by: sortBy, page, language, region: this.discoverRegion || '', // Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set! 'first_air_date.gte': !firstAirDateGte && firstAirDateLte ? defaultPastDate : firstAirDateGte, 'first_air_date.lte': !firstAirDateLte && firstAirDateGte ? defaultFutureDate : firstAirDateLte, with_original_language: originalLanguage && originalLanguage !== 'all' ? originalLanguage : originalLanguage === 'all' ? undefined : this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate, with_genres: genre, with_networks: network, with_keywords: keywords, without_keywords: excludeKeywords, 'with_runtime.gte': withRuntimeGte, 'with_runtime.lte': withRuntimeLte, 'vote_average.gte': voteAverageGte, 'vote_average.lte': voteAverageLte, 'vote_count.gte': voteCountGte, 'vote_count.lte': voteCountLte, with_watch_providers: watchProviders, watch_region: watchRegion, with_status: withStatus, certification: certification, 'certification.gte': certificationGte, 'certification.lte': certificationLte, certification_country: certificationCountry, }, }); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch discover TV: ${e.message}`, { cause: e, }); } }; public getUpcomingMovies = async ({ page = 1, language = this.locale, }: { page: number; language: string; }): Promise => { try { const data = await this.get( '/movie/upcoming', { params: { page, language, region: this.discoverRegion, originalLanguage: this.originalLanguage, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch upcoming movies: ${e.message}`, { cause: e, }); } }; public getAllTrending = async ({ page = 1, timeWindow = 'day', language = this.locale, }: { page?: number; timeWindow?: 'day' | 'week'; language?: string; } = {}): Promise => { try { const data = await this.get( `/trending/all/${timeWindow}`, { params: { page, language, region: this.discoverRegion, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`, { cause: e, }); } }; public getMovieTrending = async ({ page = 1, timeWindow = 'day', language = this.locale, }: { page?: number; timeWindow?: 'day' | 'week'; language?: string; } = {}): Promise => { try { const data = await this.get( `/trending/movie/${timeWindow}`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`, { cause: e, }); } }; public getTvTrending = async ({ page = 1, timeWindow = 'day', language = this.locale, }: { page?: number; timeWindow?: 'day' | 'week'; language?: string; } = {}): Promise => { try { const data = await this.get( `/trending/tv/${timeWindow}`, { params: { page, language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch all trending: ${e.message}`, { cause: e, }); } }; public async getByExternalId({ externalId, type, language = this.locale, }: | { externalId: string; type: 'imdb'; language?: string; } | { externalId: number; type: 'tvdb'; language?: string; }): Promise { try { const data = await this.get( `/find/${externalId}`, { params: { external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to find by external ID: ${e.message}`, { cause: e, }); } } public async getMediaByImdbId({ imdbId, language = this.locale, }: { imdbId: string; language?: string; }): Promise { try { const extResponse = await this.getByExternalId({ externalId: imdbId, type: 'imdb', }); if (extResponse.movie_results[0]) { const movie = await this.getMovie({ movieId: extResponse.movie_results[0].id, language, }); return movie; } if (extResponse.tv_results[0]) { const tvshow = await this.getTvShow({ tvId: extResponse.tv_results[0].id, language, }); return tvshow; } throw new Error(`No movie or show returned from API for ID ${imdbId}`); } catch (e) { throw new Error( `[TMDB] Failed to find media using external IMDb ID: ${e.message}`, { cause: e } ); } } public async getShowByTvdbId({ tvdbId, language = this.locale, }: { tvdbId: number; language?: string; }): Promise { try { const extResponse = await this.getByExternalId({ externalId: tvdbId, type: 'tvdb', }); if (extResponse.tv_results[0]) { const tvshow = await this.getTvShow({ tvId: extResponse.tv_results[0].id, language, }); return tvshow; } throw new Error(`No show returned from API for ID ${tvdbId}`); } catch (e) { throw new Error( `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`, { cause: e } ); } } public async getCollection({ collectionId, language = this.locale, }: { collectionId: number; language?: string; }): Promise { try { const data = await this.get( `/collection/${collectionId}`, { params: { language, }, } ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`, { cause: e, }); } } public async getRegions(): Promise { try { const data = await this.get( '/configuration/countries', {}, 86400 // 24 hours ); const regions = sortBy(data, 'english_name'); return regions; } catch (e) { throw new Error(`[TMDB] Failed to fetch countries: ${e.message}`, { cause: e, }); } } public async getLanguages(): Promise { try { const data = await this.get( '/configuration/languages', {}, 86400 // 24 hours ); const languages = sortBy(data, 'english_name'); return languages; } catch (e) { throw new Error(`[TMDB] Failed to fetch langauges: ${e.message}`, { cause: e, }); } } public async getStudio(studioId: number): Promise { try { const data = await this.get( `/company/${studioId}` ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch movie studio: ${e.message}`, { cause: e, }); } } public async getNetwork(networkId: number): Promise { try { const data = await this.get(`/network/${networkId}`); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV network: ${e.message}`, { cause: e, }); } } public async getMovieGenres({ language = this.locale, }: { language?: string; } = {}): Promise { try { const data = await this.get( '/genre/movie/list', { params: { language, }, }, 86400 // 24 hours ); if ( !language.startsWith('en') && data.genres.some((genre) => !genre.name) ) { const englishData = await this.get( '/genre/movie/list', { params: { language: 'en', }, }, 86400 // 24 hours ); data.genres .filter((genre) => !genre.name) .forEach((genre) => { genre.name = englishData.genres.find( (englishGenre) => englishGenre.id === genre.id )?.name ?? ''; }); } const movieGenres = sortBy( data.genres.filter((genre) => genre.name), 'name' ); return movieGenres; } catch (e) { throw new Error(`[TMDB] Failed to fetch movie genres: ${e.message}`, { cause: e, }); } } public async getTvGenres({ language = this.locale, }: { language?: string; } = {}): Promise { try { const data = await this.get( '/genre/tv/list', { params: { language, }, }, 86400 // 24 hours ); if ( !language.startsWith('en') && data.genres.some((genre) => !genre.name) ) { const englishData = await this.get( '/genre/tv/list', { params: { language: 'en', }, }, 86400 // 24 hours ); data.genres .filter((genre) => !genre.name) .forEach((genre) => { genre.name = englishData.genres.find( (englishGenre) => englishGenre.id === genre.id )?.name ?? ''; }); } const tvGenres = sortBy( data.genres.filter((genre) => genre.name), 'name' ); return tvGenres; } catch (e) { throw new Error(`[TMDB] Failed to fetch TV genres: ${e.message}`, { cause: e, }); } } public getMovieCertifications = async (): Promise => { try { const data = await this.get( '/certification/movie/list', {}, 604800 // 7 days ); return data; } catch (e) { throw new Error(`[TMDB] Failed to fetch movie certifications: ${e}`, { cause: e, }); } }; public getTvCertifications = async (): Promise => { try { const data = await this.get( '/certification/tv/list', {}, 604800 // 7 days ); return data; } catch (e) { throw new Error( `[TMDB] Failed to fetch TV certifications: ${e.message}`, { cause: e } ); } }; public async getKeywordDetails({ keywordId, }: { keywordId: number; }): Promise { try { const data = await this.get( `/keyword/${keywordId}`, undefined, 604800 // 7 days ); return data; } catch (e) { if (e.response?.status === 404) { return null; } throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`, { cause: e, }); } } public async searchKeyword({ query, page = 1, }: { query: string; page?: number; }): Promise { try { const data = await this.get( '/search/keyword', { params: { query, page, }, }, 86400 // 24 hours ); return data; } catch (e) { throw new Error(`[TMDB] Failed to search keyword: ${e.message}`, { cause: e, }); } } public async searchCompany({ query, page = 1, }: { query: string; page?: number; }): Promise { try { const data = await this.get( '/search/company', { params: { query, page, }, }, 86400 // 24 hours ); return data; } catch (e) { throw new Error(`[TMDB] Failed to search companies: ${e.message}`, { cause: e, }); } } public async getAvailableWatchProviderRegions({ language, }: { language?: string; }) { try { const data = await this.get<{ results: TmdbWatchProviderRegion[] }>( '/watch/providers/regions', { params: { language: language ?? this.originalLanguage, }, }, 86400 // 24 hours ); return data.results; } catch (e) { throw new Error( `[TMDB] Failed to fetch available watch regions: ${e.message}`, { cause: e } ); } } public async getMovieWatchProviders({ language, watchRegion, }: { language?: string; watchRegion: string; }) { try { const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( '/watch/providers/movie', { params: { language: language ?? this.originalLanguage, watch_region: watchRegion, }, }, 86400 // 24 hours ); return data.results; } catch (e) { throw new Error( `[TMDB] Failed to fetch movie watch providers: ${e.message}`, { cause: e } ); } } public async getTvWatchProviders({ language, watchRegion, }: { language?: string; watchRegion: string; }) { try { const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( '/watch/providers/tv', { params: { language: language ?? this.originalLanguage, watch_region: watchRegion, }, }, 86400 // 24 hours ); return data.results; } catch (e) { throw new Error( `[TMDB] Failed to fetch TV watch providers: ${e.message}`, { cause: e } ); } } } export default TheMovieDb; ================================================ FILE: server/api/themoviedb/interfaces.ts ================================================ interface TmdbMediaResult { id: number; media_type: string; popularity: number; poster_path?: string; backdrop_path?: string; vote_count: number; vote_average: number; genre_ids: number[]; overview: string; original_language: string; } export interface TmdbMovieResult extends TmdbMediaResult { media_type: 'movie'; title: string; original_title: string; release_date: string; adult: boolean; video: boolean; } export interface TmdbTvResult extends TmdbMediaResult { media_type: 'tv'; name: string; original_name: string; origin_country: string[]; first_air_date: string; } export interface TmdbCollectionResult { id: number; media_type: 'collection'; title: string; original_title: string; adult: boolean; poster_path?: string; backdrop_path?: string; overview: string; original_language: string; } export interface TmdbPersonResult { id: number; name: string; popularity: number; profile_path?: string; adult: boolean; media_type: 'person'; known_for: (TmdbMovieResult | TmdbTvResult)[]; } interface TmdbPaginatedResponse { page: number; total_results: number; total_pages: number; } export interface TmdbSearchMultiResponse extends TmdbPaginatedResponse { results: ( | TmdbMovieResult | TmdbTvResult | TmdbPersonResult | TmdbCollectionResult )[]; } export interface TmdbSearchMovieResponse extends TmdbPaginatedResponse { results: TmdbMovieResult[]; } export interface TmdbSearchTvResponse extends TmdbPaginatedResponse { results: TmdbTvResult[]; } export interface TmdbUpcomingMoviesResponse extends TmdbPaginatedResponse { dates: { maximum: string; minimum: string; }; results: TmdbMovieResult[]; } export interface TmdbExternalIdResponse { movie_results: TmdbMovieResult[]; tv_results: TmdbTvResult[]; person_results: TmdbPersonResult[]; } export interface TmdbCreditCast { cast_id: number; character: string; credit_id: string; gender?: number; id: number; name: string; order: number; profile_path?: string; } export interface TmdbAggregateCreditCast extends TmdbCreditCast { roles: { credit_id: string; character: string; episode_count: number; }[]; } export interface TmdbCreditCrew { credit_id: string; gender?: number; id: number; name: string; profile_path?: string; job: string; department: string; } export interface TmdbExternalIds { imdb_id?: string; freebase_mid?: string; freebase_id?: string; tvdb_id?: number; tvrage_id?: string; facebook_id?: string; instagram_id?: string; twitter_id?: string; } export interface TmdbProductionCompany { id: number; logo_path?: string; name: string; origin_country: string; homepage?: string; headquarters?: string; description?: string; } export interface TmdbMovieDetails { id: number; imdb_id?: string; adult: boolean; backdrop_path?: string; poster_path?: string; budget: number; genres: { id: number; name: string; }[]; homepage?: string; original_language: string; original_title: string; overview?: string; popularity: number; production_companies: TmdbProductionCompany[]; production_countries: { iso_3166_1: string; name: string; }[]; release_date: string; release_dates: TmdbMovieReleaseResult; revenue: number; runtime?: number; spoken_languages: { iso_639_1: string; name: string; }[]; status: string; tagline?: string; title: string; video: boolean; vote_average: number; vote_count: number; credits: { cast: TmdbCreditCast[]; crew: TmdbCreditCrew[]; }; belongs_to_collection?: { id: number; name: string; poster_path?: string; backdrop_path?: string; }; external_ids: TmdbExternalIds; videos: TmdbVideoResult; 'watch/providers'?: { id: number; results?: { [iso_3166_1: string]: TmdbWatchProviders }; }; keywords: { keywords: TmdbKeyword[]; }; } export interface TmdbVideo { id: string; key: string; name: string; site: 'YouTube'; size: number; type: | 'Clip' | 'Teaser' | 'Trailer' | 'Featurette' | 'Opening Credits' | 'Behind the Scenes' | 'Bloopers'; } export interface TmdbTvEpisodeResult { id: number; air_date: string | null; episode_number: number; name: string; overview: string; production_code: string; season_number: number; show_id: number; still_path: string; vote_average: number; vote_count: number; } export interface TmdbTvSeasonResult { id: number; air_date: string; episode_count: number; name: string; overview: string; poster_path?: string; season_number: number; } export interface TmdbTvDetails { id: number; backdrop_path?: string; content_ratings: TmdbTvRatingResult; created_by: { id: number; credit_id: string; name: string; gender: number; profile_path?: string; }[]; episode_run_time: number[]; first_air_date: string; genres: { id: number; name: string; }[]; homepage: string; in_production: boolean; languages: string[]; last_air_date: string; last_episode_to_air?: TmdbTvEpisodeResult; name: string; next_episode_to_air?: TmdbTvEpisodeResult; networks: TmdbNetwork[]; number_of_episodes: number; number_of_seasons: number; origin_country: string[]; original_language: string; original_name: string; overview: string; popularity: number; poster_path?: string; production_companies: { id: number; logo_path?: string; name: string; origin_country: string; }[]; production_countries: { iso_3166_1: string; name: string; }[]; spoken_languages: { english_name: string; iso_639_1: string; name: string; }[]; seasons: TmdbTvSeasonResult[]; status: string; tagline?: string; type: string; vote_average: number; vote_count: number; aggregate_credits: { cast: TmdbAggregateCreditCast[]; }; credits: { crew: TmdbCreditCrew[]; }; external_ids: TmdbExternalIds; keywords: { results: TmdbKeyword[]; }; videos: TmdbVideoResult; 'watch/providers'?: { id: number; results?: { [iso_3166_1: string]: TmdbWatchProviders }; }; } export interface TmdbVideoResult { results: TmdbVideo[]; } export interface TmdbTvRatingResult { results: TmdbRating[]; } export interface TmdbRating { iso_3166_1: string; rating: string; } export interface TmdbMovieReleaseResult { results: TmdbRelease[]; } export interface TmdbRelease extends TmdbRating { release_dates: { certification: string; iso_639_1?: string; note?: string; release_date: string; type: number; }[]; } export interface TmdbKeyword { id: number; name: string; } export interface TmdbPersonDetails { id: number; name: string; birthday: string; deathday: string; known_for_department: string; also_known_as?: string[]; gender: number; biography: string; popularity: number; place_of_birth?: string; profile_path?: string; adult: boolean; imdb_id?: string; homepage?: string; } export interface TmdbPersonCredit { id: number; original_language: string; episode_count: number; overview: string; origin_country: string[]; original_name: string; vote_count: number; name: string; media_type?: string; popularity: number; credit_id: string; backdrop_path?: string; first_air_date: string; vote_average: number; genre_ids?: number[]; poster_path?: string; original_title: string; video?: boolean; title: string; adult: boolean; release_date: string; } export interface TmdbPersonCreditCast extends TmdbPersonCredit { character: string; } export interface TmdbPersonCreditCrew extends TmdbPersonCredit { department: string; job: string; } export interface TmdbPersonCombinedCredits { id: number; cast: TmdbPersonCreditCast[]; crew: TmdbPersonCreditCrew[]; } export interface TmdbSeasonWithEpisodes extends Omit< TmdbTvSeasonResult, 'episode_count' > { episodes: TmdbTvEpisodeResult[]; external_ids: TmdbExternalIds; } export interface TmdbCollection { id: number; name: string; overview?: string; poster_path?: string; backdrop_path?: string; parts: TmdbMovieResult[]; } export interface TmdbRegion { iso_3166_1: string; english_name: string; } export interface TmdbLanguage { iso_639_1: string; english_name: string; name: string; } export interface TmdbGenresResult { genres: TmdbGenre[]; } export interface TmdbGenre { id: number; name: string; } export interface TmdbNetwork { id: number; name: string; headquarters?: string; homepage?: string; logo_path?: string; origin_country?: string; } export interface TmdbWatchProviders { link?: string; buy?: TmdbWatchProviderDetails[]; flatrate?: TmdbWatchProviderDetails[]; } export interface TmdbWatchProviderDetails { display_priority?: number; logo_path?: string; provider_id: number; provider_name: string; } export interface TmdbKeywordSearchResponse extends TmdbPaginatedResponse { results: TmdbKeyword[]; } // We have production companies, but the company search results return less data export interface TmdbCompany { id: number; logo_path?: string; name: string; } export interface TmdbCompanySearchResponse extends TmdbPaginatedResponse { results: TmdbCompany[]; } export interface TmdbWatchProviderRegion { iso_3166_1: string; english_name: string; native_name: string; } ================================================ FILE: server/api/tvdb/index.ts ================================================ import ExternalAPI from '@server/api/externalapi'; import type { TvShowProvider } from '@server/api/provider'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbSeasonWithEpisodes, TmdbTvDetails, TmdbTvEpisodeResult, TmdbTvSeasonResult, } from '@server/api/themoviedb/interfaces'; import { convertTmdbLanguageToTvdbWithFallback, type TvdbBaseResponse, type TvdbEpisode, type TvdbLoginResponse, type TvdbSeasonDetails, type TvdbTvDetails, } from '@server/api/tvdb/interfaces'; import cacheManager, { type AvailableCacheIds } from '@server/lib/cache'; import logger from '@server/logger'; interface TvdbConfig { baseUrl: string; maxRequestsPerSecond: number; maxRequests: number; cachePrefix: AvailableCacheIds; } const DEFAULT_CONFIG: TvdbConfig = { baseUrl: 'https://api4.thetvdb.com/v4', maxRequestsPerSecond: 50, maxRequests: 20, cachePrefix: 'tvdb' as const, }; const enum TvdbIdStatus { INVALID = -1, } type TvdbId = number; type ValidTvdbId = Exclude; class Tvdb extends ExternalAPI implements TvShowProvider { static instance: Tvdb; private readonly tmdb: TheMovieDb; private static readonly DEFAULT_CACHE_TTL = 43200; private static readonly DEFAULT_LANGUAGE = 'eng'; private token: string; private pin?: string; constructor(pin?: string) { const finalConfig = { ...DEFAULT_CONFIG }; super( finalConfig.baseUrl, {}, { nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data, rateLimit: { maxRequests: finalConfig.maxRequests, maxRPS: finalConfig.maxRequestsPerSecond, }, } ); this.pin = pin; this.tmdb = new TheMovieDb(); } public static async getInstance(): Promise { if (!this.instance) { this.instance = new Tvdb(); await this.instance.login(); } return this.instance; } private async refreshToken(): Promise { try { if (!this.token) { await this.login(); return; } const base64Url = this.token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const payload = JSON.parse(Buffer.from(base64, 'base64').toString()); if (!payload.exp) { await this.login(); } const now = Math.floor(Date.now() / 1000); const diff = payload.exp - now; // refresh token 1 week before expiration if (diff < 604800) { await this.login(); } } catch (error) { this.handleError('Failed to refresh token', error); } } public async test(): Promise { try { await this.login(); } catch (error) { this.handleError('Login failed', error); throw error; } } async login(): Promise { let body: { apiKey: string; pin?: string } = { apiKey: 'd00d9ecb-a9d0-4860-958a-74b14a041405', }; if (this.pin) { body = { ...body, pin: this.pin, }; } const response = await this.post>( '/login', { ...body, } ); this.token = response.data.token; return response.data; } public async getShowByTvdbId({ tvdbId, language, }: { tvdbId: number; language?: string; }): Promise { try { const tmdbTvShow = await this.tmdb.getShowByTvdbId({ tvdbId: tvdbId, language, }); try { await this.refreshToken(); const validTvdbId = this.getTvdbIdFromTmdb(tmdbTvShow); if (this.isValidTvdbId(validTvdbId)) { return this.enrichTmdbShowWithTvdbData(tmdbTvShow, validTvdbId); } return tmdbTvShow; } catch { return tmdbTvShow; } } catch (error) { this.handleError('Failed to fetch TV show details', error); throw error; } } public async getTvShow({ tvId, language, }: { tvId: number; language?: string; }): Promise { try { const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language }); try { await this.refreshToken(); const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow); if (this.isValidTvdbId(tvdbId)) { return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId); } return tmdbTvShow; } catch (error) { this.handleError('Failed to fetch TV show details', error); return tmdbTvShow; } } catch (error) { this.handleError('Failed to fetch TV show details', error); return this.tmdb.getTvShow({ tvId, language }); } } public async getTvSeason({ tvId, seasonNumber, language = Tvdb.DEFAULT_LANGUAGE, }: { tvId: number; seasonNumber: number; language?: string; }): Promise { try { const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language }); try { await this.refreshToken(); const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow); if (!this.isValidTvdbId(tvdbId)) { return await this.tmdb.getTvSeason({ tvId, seasonNumber, language }); } return await this.getTvdbSeasonData( tvdbId, seasonNumber, tvId, language ); } catch (error) { this.handleError('Failed to fetch TV season details', error); return await this.tmdb.getTvSeason({ tvId, seasonNumber, language }); } } catch (error) { logger.error( `[TVDB] Failed to fetch TV season details: ${error.message}` ); throw error; } } private async enrichTmdbShowWithTvdbData( tmdbTvShow: TmdbTvDetails, tvdbId: ValidTvdbId ): Promise { try { await this.refreshToken(); const tvdbData = await this.fetchTvdbShowData(tvdbId); const seasons = this.processSeasons(tvdbData); if (!seasons.length) { return tmdbTvShow; } return { ...tmdbTvShow, seasons }; } catch (error) { logger.error( `Failed to enrich TMDB show with TVDB data: ${error.message} token: ${this.token}` ); return tmdbTvShow; } } private async fetchTvdbShowData(tvdbId: number): Promise { const resp = await this.get>( `/series/${tvdbId}/extended?meta=episodes&short=true`, { headers: { Authorization: `Bearer ${this.token}`, }, }, Tvdb.DEFAULT_CACHE_TTL ); return resp.data; } private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] { if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) { return []; } const seasons = tvdbData.seasons .filter((season) => season.type && season.type.type === 'official') .sort((a, b) => a.number - b.number) .map((season) => this.createSeasonData(season, tvdbData)) .filter( (season) => season && season.season_number >= 0 ) as TmdbTvSeasonResult[]; return seasons; } private createSeasonData( season: TvdbSeasonDetails, tvdbData: TvdbTvDetails ): TmdbTvSeasonResult { const seasonNumber = season.number ?? -1; if (seasonNumber < 0) { return { id: 0, episode_count: 0, name: '', overview: '', season_number: -1, poster_path: '', air_date: '', }; } const episodeCount = tvdbData.episodes.filter( (episode) => episode.seasonNumber === season.number ).length; return { id: tvdbData.id, episode_count: episodeCount, name: `${season.number}`, overview: '', season_number: season.number, poster_path: '', air_date: '', }; } private async getTvdbSeasonData( tvdbId: number, seasonNumber: number, tvId: number, language: string = Tvdb.DEFAULT_LANGUAGE ): Promise { const tvdbData = await this.fetchTvdbShowData(tvdbId); if (!tvdbData) { logger.error(`Failed to fetch TVDB data for ID: ${tvdbId}`); return this.createEmptySeasonResponse(tvId); } // get season id const season = tvdbData.seasons.find( (season) => season.number === seasonNumber && season.type.type && season.type.type === 'official' ); if (!season) { logger.error( `Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}` ); return this.createEmptySeasonResponse(tvId); } const wantedTranslation = convertTmdbLanguageToTvdbWithFallback( language, Tvdb.DEFAULT_LANGUAGE ); // check if translation is available for the season const availableTranslation = season.nameTranslations.filter( (translation) => translation === wantedTranslation || translation === Tvdb.DEFAULT_LANGUAGE ); if (!availableTranslation) { return this.getSeasonWithOriginalLanguage( tvdbId, tvId, seasonNumber, season ); } return this.getSeasonWithTranslation( tvdbId, tvId, seasonNumber, season, wantedTranslation ); } private async getSeasonWithTranslation( tvdbId: number, tvId: number, seasonNumber: number, season: TvdbSeasonDetails, language: string ): Promise { if (!season) { logger.error( `Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}` ); return this.createEmptySeasonResponse(tvId); } const allEpisodes = [] as TvdbEpisode[]; let page = 0; // Limit to max 50 pages to avoid infinite loops. // 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough const maxPages = 50; while (page < maxPages) { const resp = await this.get>( `/series/${tvdbId}/episodes/default/${language}`, { headers: { Authorization: `Bearer ${this.token}`, }, params: { page: page, }, } ); if (!resp?.data?.episodes) { logger.warn( `No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}` ); break; } const { episodes } = resp.data; if (!episodes) { logger.debug( `No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}` ); break; } allEpisodes.push(...episodes); const hasNextPage = resp.links?.next && episodes.length > 0; if (!hasNextPage) { break; } page++; } if (page >= maxPages) { logger.warn( `Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.` ); } const episodes = this.processEpisodes( { ...season, episodes: allEpisodes }, seasonNumber, tvId ); return { episodes, external_ids: { tvdb_id: tvdbId }, name: '', overview: '', id: season.id, air_date: season.firstAired, season_number: episodes.length, }; } private async getSeasonWithOriginalLanguage( tvdbId: number, tvId: number, seasonNumber: number, season: TvdbSeasonDetails ): Promise { if (!season) { logger.error( `Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}` ); return this.createEmptySeasonResponse(tvId); } const resp = await this.get>( `/seasons/${season.id}/extended`, { headers: { Authorization: `Bearer ${this.token}`, }, } ); const seasons = resp.data; const episodes = this.processEpisodes(seasons, seasonNumber, tvId); return { episodes, external_ids: { tvdb_id: tvdbId }, name: '', overview: '', id: seasons.id, air_date: seasons.firstAired, season_number: episodes.length, }; } private processEpisodes( tvdbSeason: TvdbSeasonDetails, seasonNumber: number, tvId: number ): TmdbTvEpisodeResult[] { if (!tvdbSeason || !tvdbSeason.episodes) { logger.error('No episodes found in TVDB season data'); return []; } return tvdbSeason.episodes .filter((episode) => episode.seasonNumber === seasonNumber) .map((episode, index) => this.createEpisodeData(episode, index, tvId)); } private createEpisodeData( episode: TvdbEpisode, index: number, tvId: number ): TmdbTvEpisodeResult { return { id: episode.id, air_date: episode.aired, episode_number: episode.number, name: episode.name || `Episode ${index + 1}`, overview: episode.overview || '', season_number: episode.seasonNumber, production_code: '', show_id: tvId, still_path: episode.image && !episode.image.startsWith('https://') ? 'https://artworks.thetvdb.com' + episode.image : '', vote_average: 1, vote_count: 1, }; } private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes { return { episodes: [], external_ids: { tvdb_id: tvId }, name: '', overview: '', id: 0, air_date: '', season_number: 0, }; } private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId { return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID; } private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId { return tvdbId !== TvdbIdStatus.INVALID; } private handleError(context: string, error: Error): void { throw new Error(`[TVDB] ${context}: ${error.message}`); } } export default Tvdb; ================================================ FILE: server/api/tvdb/interfaces.ts ================================================ import { type AvailableLocale } from '@server/types/languages'; export interface TvdbBaseResponse { data: T; errors: string; links?: TvdbPagination; } export interface TvdbPagination { prev?: string; self: string; next?: string; totalItems: number; pageSize: number; } export interface TvdbLoginResponse { token: string; } interface TvDetailsAliases { language: string; name: string; } interface TvDetailsStatus { id: number; name: string; recordType: string; keepUpdated: boolean; } export interface TvdbTvDetails { id: number; name: string; slug: string; image: string; nameTranslations: string[]; overwiewTranslations: string[]; aliases: TvDetailsAliases[]; firstAired: Date; lastAired: Date; nextAired: Date | string; score: number; status: TvDetailsStatus; originalCountry: string; originalLanguage: string; defaultSeasonType: string; isOrderRandomized: boolean; lastUpdated: Date; averageRuntime: number; seasons: TvdbSeasonDetails[]; episodes: TvdbEpisode[]; } interface TvdbCompanyType { companyTypeId: number; companyTypeName: string; } interface TvdbParentCompany { id?: number; name?: string; relation?: { id?: number; typeName?: string; }; } interface TvdbCompany { id: number; name: string; slug: string; nameTranslations?: string[]; overviewTranslations?: string[]; aliases?: string[]; country: string; primaryCompanyType: number; activeDate: string; inactiveDate?: string; companyType: TvdbCompanyType; parentCompany: TvdbParentCompany; tagOptions?: string[]; } interface TvdbType { id: number; name: string; type: string; alternateName?: string; } interface TvdbArtwork { id: number; image: string; thumbnail: string; language: string; type: number; score: number; width: number; height: number; includesText: boolean; } export interface TvdbEpisode { id: number; seriesId: number; name: string; aired: string; runtime: number; nameTranslations: string[]; overview?: string; overviewTranslations: string[]; image: string; imageType: number; isMovie: number; seasons?: string[]; number: number; absoluteNumber: number; seasonNumber: number; lastUpdated: string; finaleType?: string; year: string; } export interface TvdbSeasonDetails { id: number; seriesId: number; type: TvdbType; number: number; nameTranslations: string[]; overviewTranslations: string[]; image: string; imageType: number; companies: { studio: TvdbCompany[]; network: TvdbCompany[]; production: TvdbCompany[]; distributor: TvdbCompany[]; special_effects: TvdbCompany[]; }; lastUpdated: string; year: string; episodes: TvdbEpisode[]; trailers: string[]; artwork: TvdbArtwork[]; tagOptions?: string[]; firstAired: string; } export interface TvdbEpisodeTranslation { name: string; overview: string; language: string; } const TMDB_TO_TVDB_MAPPING: Record & { [key in AvailableLocale]: string; } = { ar: 'ara', // Arabic bg: 'bul', // Bulgarian ca: 'cat', // Catalan cs: 'ces', // Czech da: 'dan', // Danish de: 'deu', // German el: 'ell', // Greek en: 'eng', // English es: 'spa', // Spanish fi: 'fin', // Finnish fr: 'fra', // French he: 'heb', // Hebrew hi: 'hin', // Hindi hr: 'hrv', // Croatian hu: 'hun', // Hungarian it: 'ita', // Italian ja: 'jpn', // Japanese ko: 'kor', // Korean lb: 'ltz', // Luxembourgish lt: 'lit', // Lithuanian nl: 'nld', // Dutch pl: 'pol', // Polish ro: 'ron', // Romanian ru: 'rus', // Russian sq: 'sqi', // Albanian sr: 'srp', // Serbian sv: 'swe', // Swedish tr: 'tur', // Turkish uk: 'ukr', // Ukrainian vi: 'vie', // Vietnamese 'es-MX': 'spa', // Spanish (Latin America) -> Spanish 'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian 'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data) 'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data) 'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China 'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan }; export function convertTMDBToTVDB(tmdbCode: string): string | null { const normalizedCode = tmdbCode.toLowerCase(); return ( TMDB_TO_TVDB_MAPPING[tmdbCode] || TMDB_TO_TVDB_MAPPING[normalizedCode] || null ); } export function convertTmdbLanguageToTvdbWithFallback( tmdbCode: string, fallback: string ): string { // First try exact match const tvdbCode = convertTMDBToTVDB(tmdbCode); if (tvdbCode) return tvdbCode; return tvdbCode || fallback || 'eng'; // Default to English if no match found } ================================================ FILE: server/constants/discover.ts ================================================ import type DiscoverSlider from '@server/entity/DiscoverSlider'; export enum DiscoverSliderType { RECENTLY_ADDED = 1, RECENT_REQUESTS, PLEX_WATCHLIST, TRENDING, POPULAR_MOVIES, MOVIE_GENRES, UPCOMING_MOVIES, STUDIOS, POPULAR_TV, TV_GENRES, UPCOMING_TV, NETWORKS, TMDB_MOVIE_KEYWORD, TMDB_MOVIE_GENRE, TMDB_TV_KEYWORD, TMDB_TV_GENRE, TMDB_SEARCH, TMDB_STUDIO, TMDB_NETWORK, TMDB_MOVIE_STREAMING_SERVICES, TMDB_TV_STREAMING_SERVICES, } export const defaultSliders: Partial[] = [ { type: DiscoverSliderType.RECENTLY_ADDED, enabled: true, isBuiltIn: true, order: 0, }, { type: DiscoverSliderType.RECENT_REQUESTS, enabled: true, isBuiltIn: true, order: 1, }, { type: DiscoverSliderType.PLEX_WATCHLIST, enabled: true, isBuiltIn: true, order: 2, }, { type: DiscoverSliderType.TRENDING, enabled: true, isBuiltIn: true, order: 3, }, { type: DiscoverSliderType.POPULAR_MOVIES, enabled: true, isBuiltIn: true, order: 4, }, { type: DiscoverSliderType.MOVIE_GENRES, enabled: true, isBuiltIn: true, order: 5, }, { type: DiscoverSliderType.UPCOMING_MOVIES, enabled: true, isBuiltIn: true, order: 6, }, { type: DiscoverSliderType.STUDIOS, enabled: true, isBuiltIn: true, order: 7, }, { type: DiscoverSliderType.POPULAR_TV, enabled: true, isBuiltIn: true, order: 8, }, { type: DiscoverSliderType.TV_GENRES, enabled: true, isBuiltIn: true, order: 9, }, { type: DiscoverSliderType.UPCOMING_TV, enabled: true, isBuiltIn: true, order: 10, }, { type: DiscoverSliderType.NETWORKS, enabled: true, isBuiltIn: true, order: 11, }, ]; ================================================ FILE: server/constants/error.ts ================================================ export enum ApiErrorCode { InvalidUrl = 'INVALID_URL', InvalidCredentials = 'INVALID_CREDENTIALS', InvalidAuthToken = 'INVALID_AUTH_TOKEN', InvalidEmail = 'INVALID_EMAIL', NotAdmin = 'NOT_ADMIN', NoAdminUser = 'NO_ADMIN_USER', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', Unauthorized = 'UNAUTHORIZED', Unknown = 'UNKNOWN', } ================================================ FILE: server/constants/issue.ts ================================================ export enum IssueType { VIDEO = 1, AUDIO = 2, SUBTITLES = 3, OTHER = 4, } export enum IssueStatus { OPEN = 1, RESOLVED = 2, } export const IssueTypeName = { [IssueType.AUDIO]: 'Audio', [IssueType.VIDEO]: 'Video', [IssueType.SUBTITLES]: 'Subtitle', [IssueType.OTHER]: 'Other', }; ================================================ FILE: server/constants/media.ts ================================================ export enum MediaRequestStatus { PENDING = 1, APPROVED, DECLINED, FAILED, COMPLETED, } export enum MediaType { MOVIE = 'movie', TV = 'tv', } export enum MediaStatus { UNKNOWN = 1, PENDING, PROCESSING, PARTIALLY_AVAILABLE, AVAILABLE, BLOCKLISTED, DELETED, } ================================================ FILE: server/constants/server.ts ================================================ export enum MediaServerType { PLEX = 1, JELLYFIN, EMBY, NOT_CONFIGURED, } export enum ServerType { JELLYFIN = 'Jellyfin', EMBY = 'Emby', } ================================================ FILE: server/constants/user.ts ================================================ export enum UserType { PLEX = 1, LOCAL = 2, JELLYFIN = 3, EMBY = 4, } ================================================ FILE: server/datasource.ts ================================================ import fs from 'fs'; import type { TlsOptions } from 'tls'; import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm'; import { DataSource } from 'typeorm'; const DB_SSL_PREFIX = 'DB_SSL_'; function boolFromEnv(envVar: string, defaultVal = false) { if (process.env[envVar]) { return process.env[envVar]?.toLowerCase() === 'true'; } return defaultVal; } function stringOrReadFileFromEnv(envVar: string): Buffer | string | undefined { if (process.env[envVar]) { return process.env[envVar]; } const filePath = process.env[`${envVar}_FILE`]; if (filePath) { return fs.readFileSync(filePath); } return undefined; } function buildSslConfig(): TlsOptions | undefined { if (process.env.DB_USE_SSL?.toLowerCase() !== 'true') { return undefined; } return { rejectUnauthorized: boolFromEnv( `${DB_SSL_PREFIX}REJECT_UNAUTHORIZED`, true ), ca: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CA`), key: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}KEY`), cert: stringOrReadFileFromEnv(`${DB_SSL_PREFIX}CERT`), }; } const testConfig: DataSourceOptions = { type: 'sqlite', database: ':memory:', synchronize: true, dropSchema: true, logging: boolFromEnv('DB_LOG_QUERIES'), entities: ['server/entity/**/*.ts'], migrations: ['server/migration/sqlite/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'], }; const devConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` : 'config/db/db.sqlite3', synchronize: true, migrationsRun: false, logging: boolFromEnv('DB_LOG_QUERIES'), enableWAL: true, entities: ['server/entity/**/*.ts'], migrations: ['server/migration/sqlite/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'], }; const prodConfig: DataSourceOptions = { type: 'sqlite', database: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/db/db.sqlite3` : 'config/db/db.sqlite3', synchronize: false, migrationsRun: false, logging: boolFromEnv('DB_LOG_QUERIES'), enableWAL: true, entities: ['dist/entity/**/*.js'], migrations: ['dist/migration/sqlite/**/*.js'], subscribers: ['dist/subscriber/**/*.js'], }; const postgresDevConfig: DataSourceOptions = { type: 'postgres', host: process.env.DB_SOCKET_PATH || process.env.DB_HOST, port: process.env.DB_SOCKET_PATH ? undefined : parseInt(process.env.DB_PORT ?? '5432'), username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME ?? 'seerr', ssl: buildSslConfig(), synchronize: false, migrationsRun: true, logging: boolFromEnv('DB_LOG_QUERIES'), entities: ['server/entity/**/*.ts'], migrations: ['server/migration/postgres/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'], }; const postgresProdConfig: DataSourceOptions = { type: 'postgres', host: process.env.DB_SOCKET_PATH || process.env.DB_HOST, port: process.env.DB_SOCKET_PATH ? undefined : parseInt(process.env.DB_PORT ?? '5432'), username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME ?? 'seerr', ssl: buildSslConfig(), synchronize: false, migrationsRun: false, logging: boolFromEnv('DB_LOG_QUERIES'), entities: ['dist/entity/**/*.js'], migrations: ['dist/migration/postgres/**/*.js'], subscribers: ['dist/subscriber/**/*.js'], }; export const isPgsql = process.env.DB_TYPE === 'postgres'; function getDataSource(): DataSourceOptions { if (process.env.NODE_ENV === 'test') { return testConfig; } else if (process.env.NODE_ENV === 'production') { return isPgsql ? postgresProdConfig : prodConfig; } else { return isPgsql ? postgresDevConfig : devConfig; } } const dataSource = new DataSource(getDataSource()); export const getRepository = ( target: EntityTarget ): Repository => { return dataSource.getRepository(target); }; export default dataSource; ================================================ FILE: server/entity/Blocklist.ts ================================================ import { MediaStatus, type MediaType } from '@server/constants/media'; import dataSource from '@server/datasource'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import type { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import type { EntityManager } from 'typeorm'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, Unique, } from 'typeorm'; import type { ZodNumber, ZodOptional, ZodString } from 'zod'; @Entity() @Unique(['tmdbId', 'mediaType']) export class Blocklist implements BlocklistItem { @PrimaryGeneratedColumn() public id: number; @Column({ type: 'varchar' }) public mediaType: MediaType; @Column({ nullable: true, type: 'varchar' }) title?: string; @Column() @Index() public tmdbId: number; @ManyToOne(() => User, (user) => user.id, { eager: true, }) @Index() user?: User; @OneToOne(() => Media, (media) => media.blocklist, { onDelete: 'CASCADE', }) @JoinColumn() public media: Media; @Column({ nullable: true, type: 'varchar' }) public blocklistedTags?: string; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; constructor(init?: Partial) { Object.assign(this, init); } public static async addToBlocklist( { blocklistRequest, }: { blocklistRequest: { mediaType: MediaType; title?: ZodOptional['_output']; tmdbId: ZodNumber['_output']; blocklistedTags?: string; }; }, entityManager?: EntityManager ): Promise { const em = entityManager ?? dataSource; const blocklist = new this({ ...blocklistRequest, }); const mediaRepository = em.getRepository(Media); let media = await mediaRepository.findOne({ where: { tmdbId: blocklistRequest.tmdbId, mediaType: blocklistRequest.mediaType, }, }); const blocklistRepository = em.getRepository(this); await blocklistRepository.save(blocklist); if (!media) { media = new Media({ tmdbId: blocklistRequest.tmdbId, status: MediaStatus.BLOCKLISTED, status4k: MediaStatus.BLOCKLISTED, mediaType: blocklistRequest.mediaType, blocklist: Promise.resolve(blocklist), }); await mediaRepository.save(media); } else { media.blocklist = Promise.resolve(blocklist); media.status = MediaStatus.BLOCKLISTED; media.status4k = MediaStatus.BLOCKLISTED; await mediaRepository.save(media); } } } ================================================ FILE: server/entity/DiscoverSlider.ts ================================================ import type { DiscoverSliderType } from '@server/constants/discover'; import { defaultSliders } from '@server/constants/discover'; import { getRepository } from '@server/datasource'; import logger from '@server/logger'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity() class DiscoverSlider { public static async bootstrapSliders(): Promise { const sliderRepository = getRepository(DiscoverSlider); for (const slider of defaultSliders) { const existingSlider = await sliderRepository.findOne({ where: { type: slider.type, }, }); if (!existingSlider) { logger.info('Creating built-in discovery slider', { label: 'Discover Slider', slider, }); await sliderRepository.save(new DiscoverSlider(slider)); } } } @PrimaryGeneratedColumn() public id: number; @Column({ type: 'int' }) public type: DiscoverSliderType; @Column({ type: 'int' }) public order: number; @Column({ default: false }) public isBuiltIn: boolean; @Column({ default: true }) public enabled: boolean; @Column({ nullable: true }) // Title is not required for built in sliders because we will // use translations for them. public title?: string; @Column({ nullable: true }) public data?: string; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; constructor(init?: Partial) { Object.assign(this, init); } } export default DiscoverSlider; ================================================ FILE: server/entity/Issue.ts ================================================ import type { IssueType } from '@server/constants/issue'; import { IssueStatus } from '@server/constants/issue'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { AfterLoad, Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; import IssueComment from './IssueComment'; import Media from './Media'; import { User } from './User'; @Entity() class Issue { @PrimaryGeneratedColumn() public id: number; @Column({ type: 'int' }) @Index() public issueType: IssueType; @Column({ type: 'int', default: IssueStatus.OPEN }) public status: IssueStatus; @Column({ type: 'int', default: 0 }) public problemSeason: number; @Column({ type: 'int', default: 0 }) public problemEpisode: number; @ManyToOne(() => Media, (media) => media.issues, { eager: true, onDelete: 'CASCADE', }) @Index() public media: Media; @ManyToOne(() => User, (user) => user.createdIssues, { eager: true, onDelete: 'CASCADE', }) @Index() public createdBy: User; @ManyToOne(() => User, { eager: true, onDelete: 'CASCADE', nullable: true, }) @Index() public modifiedBy?: User; @OneToMany(() => IssueComment, (comment) => comment.issue, { cascade: true, eager: true, }) public comments: IssueComment[]; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; @AfterLoad() sortComments() { this.comments?.sort((a, b) => a.id - b.id); } constructor(init?: Partial) { Object.assign(this, init); } } export default Issue; ================================================ FILE: server/entity/IssueComment.ts ================================================ import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; import Issue from './Issue'; import { User } from './User'; @Entity() class IssueComment { @PrimaryGeneratedColumn() public id: number; @ManyToOne(() => User, { eager: true, onDelete: 'CASCADE', }) @Index() public user: User; @ManyToOne(() => Issue, (issue) => issue.comments, { onDelete: 'CASCADE', }) @Index() public issue: Issue; @Column({ type: 'text' }) public message: string; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; constructor(init?: Partial) { Object.assign(this, init); } } export default IssueComment; ================================================ FILE: server/entity/Media.ts ================================================ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import { Blocklist } from '@server/entity/Blocklist'; import type { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { getHostname } from '@server/utils/getHostname'; import { AfterLoad, Column, Entity, Index, OneToMany, OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; import Season from './Season'; @Entity() @Index(['tmdbId', 'mediaType']) class Media { public static async getRelatedMedia( user: User | undefined, items: { tmdbId: number; mediaType: string }[] ): Promise { const mediaRepository = getRepository(Media); try { if (items.length === 0) { return []; } const finalIds = [...new Set(items.map((i) => i.tmdbId))]; const media = await mediaRepository .createQueryBuilder('media') .leftJoinAndSelect( 'media.watchlists', 'watchlist', 'media.id= watchlist.media and watchlist.requestedBy = :userId', { userId: user?.id } ) //, .where(' media.tmdbId in (:...finalIds)', { finalIds }) .getMany(); return media.filter((m) => items.some((i) => i.tmdbId === m.tmdbId && i.mediaType === m.mediaType) ); } catch (e) { logger.error(e.message); return []; } } public static async getMedia( id: number, mediaType: MediaType ): Promise { const mediaRepository = getRepository(Media); try { const media = await mediaRepository.findOne({ where: { tmdbId: id, mediaType: mediaType }, relations: { requests: true, issues: true }, }); return media ?? undefined; } catch (e) { logger.error(e.message); return undefined; } } @PrimaryGeneratedColumn() public id: number; @Column({ type: 'varchar' }) public mediaType: MediaType; @Column() @Index() public tmdbId: number; @Column({ unique: true, nullable: true }) @Index() public tvdbId?: number; @Column({ nullable: true }) @Index() public imdbId?: string; @Column({ type: 'int', default: MediaStatus.UNKNOWN }) @Index() public status: MediaStatus; @Column({ type: 'int', default: MediaStatus.UNKNOWN }) @Index() public status4k: MediaStatus; @OneToMany(() => MediaRequest, (request) => request.media, { cascade: ['insert', 'remove'], }) public requests: MediaRequest[]; @OneToMany(() => Watchlist, (watchlist) => watchlist.media) public watchlists: null | Watchlist[]; @OneToMany(() => Season, (season) => season.media, { cascade: true, eager: true, }) public seasons: Season[]; @OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) public issues: Issue[]; @OneToOne(() => Blocklist, (blocklist) => blocklist.media) public blocklist: Promise; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; /** * The `lastSeasonChange` column stores the date and time when the media was added to the library. * It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`. */ @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public lastSeasonChange: Date; /** * The `mediaAddedAt` column stores the date and time when the media was added to the library. * It needs to be database-aware because SQLite supports `datetime` while PostgreSQL supports `timestamp with timezone (timestampz)`. * This column is nullable because it can be null when the media is not yet synced to the library. */ @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', nullable: true, }) public mediaAddedAt: Date; @Column({ nullable: true, type: 'int' }) public serviceId?: number | null; @Column({ nullable: true, type: 'int' }) public serviceId4k?: number | null; @Column({ nullable: true, type: 'int' }) public externalServiceId?: number | null; @Column({ nullable: true, type: 'int' }) public externalServiceId4k?: number | null; @Column({ nullable: true, type: 'varchar' }) public externalServiceSlug?: string | null; @Column({ nullable: true, type: 'varchar' }) public externalServiceSlug4k?: string | null; @Column({ nullable: true, type: 'varchar' }) public ratingKey?: string | null; @Column({ nullable: true, type: 'varchar' }) public ratingKey4k?: string | null; @Column({ nullable: true, type: 'varchar' }) public jellyfinMediaId?: string | null; @Column({ nullable: true, type: 'varchar' }) public jellyfinMediaId4k?: string | null; public serviceUrl?: string; public serviceUrl4k?: string; public downloadStatus?: DownloadingItem[] = []; public downloadStatus4k?: DownloadingItem[] = []; public mediaUrl?: string; public mediaUrl4k?: string; public iOSPlexUrl?: string; public iOSPlexUrl4k?: string; public tautulliUrl?: string; public tautulliUrl4k?: string; constructor(init?: Partial) { Object.assign(this, init); } public resetServiceData(): void { this.serviceId = null; this.serviceId4k = null; this.externalServiceId = null; this.externalServiceId4k = null; this.externalServiceSlug = null; this.externalServiceSlug4k = null; this.ratingKey = null; this.ratingKey4k = null; this.jellyfinMediaId = null; this.jellyfinMediaId4k = null; } @AfterLoad() public setPlexUrls(): void { const { machineId, webAppUrl } = getSettings().plex; const { externalUrl: tautulliUrl } = getSettings().tautulli; if (getSettings().main.mediaServerType == MediaServerType.PLEX) { if (this.ratingKey) { this.mediaUrl = `${ webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop' }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ this.ratingKey }`; this.iOSPlexUrl = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey}&server=${machineId}`; if (tautulliUrl) { this.tautulliUrl = `${tautulliUrl}/info?rating_key=${this.ratingKey}`; } } if (this.ratingKey4k) { this.mediaUrl4k = `${ webAppUrl ? webAppUrl : 'https://app.plex.tv/desktop' }#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${ this.ratingKey4k }`; this.iOSPlexUrl4k = `plex://preplay/?metadataKey=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}&server=${machineId}`; if (tautulliUrl) { this.tautulliUrl4k = `${tautulliUrl}/info?rating_key=${this.ratingKey4k}`; } } } else { const pageName = getSettings().main.mediaServerType == MediaServerType.EMBY ? 'item' : 'details'; const { serverId, externalHostname } = getSettings().jellyfin; const jellyfinHost = externalHostname && externalHostname.length > 0 ? externalHostname : getHostname(); if (this.jellyfinMediaId) { this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; } if (this.jellyfinMediaId4k) { this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`; } } } @AfterLoad() public setServiceUrl(): void { if (this.mediaType === MediaType.MOVIE) { if (this.serviceId !== null && this.externalServiceSlug !== null) { const settings = getSettings(); const server = settings.radarr.find( (radarr) => radarr.id === this.serviceId ); if (server) { this.serviceUrl = server.externalUrl ? `${server.externalUrl}/movie/${this.externalServiceSlug}` : RadarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`); } } if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) { const settings = getSettings(); const server = settings.radarr.find( (radarr) => radarr.id === this.serviceId4k ); if (server) { this.serviceUrl4k = server.externalUrl ? `${server.externalUrl}/movie/${this.externalServiceSlug4k}` : RadarrAPI.buildUrl( server, `/movie/${this.externalServiceSlug4k}` ); } } } if (this.mediaType === MediaType.TV) { if (this.serviceId !== null && this.externalServiceSlug !== null) { const settings = getSettings(); const server = settings.sonarr.find( (sonarr) => sonarr.id === this.serviceId ); if (server) { this.serviceUrl = server.externalUrl ? `${server.externalUrl}/series/${this.externalServiceSlug}` : SonarrAPI.buildUrl(server, `/series/${this.externalServiceSlug}`); } } if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) { const settings = getSettings(); const server = settings.sonarr.find( (sonarr) => sonarr.id === this.serviceId4k ); if (server) { this.serviceUrl4k = server.externalUrl ? `${server.externalUrl}/series/${this.externalServiceSlug4k}` : SonarrAPI.buildUrl( server, `/series/${this.externalServiceSlug4k}` ); } } } } @AfterLoad() public getDownloadingItem(): void { if (this.mediaType === MediaType.MOVIE) { if ( this.externalServiceId !== undefined && this.externalServiceId !== null && this.serviceId !== undefined && this.serviceId !== null ) { this.downloadStatus = downloadTracker.getMovieProgress( this.serviceId, this.externalServiceId ); } if ( this.externalServiceId4k !== undefined && this.externalServiceId4k !== null && this.serviceId4k !== undefined && this.serviceId4k !== null ) { this.downloadStatus4k = downloadTracker.getMovieProgress( this.serviceId4k, this.externalServiceId4k ); } } if (this.mediaType === MediaType.TV) { if ( this.externalServiceId !== undefined && this.externalServiceId !== null && this.serviceId !== undefined && this.serviceId !== null ) { this.downloadStatus = downloadTracker.getSeriesProgress( this.serviceId, this.externalServiceId ); } if ( this.externalServiceId4k !== undefined && this.externalServiceId4k !== null && this.serviceId4k !== undefined && this.serviceId4k !== null ) { this.downloadStatus4k = downloadTracker.getSeriesProgress( this.serviceId4k, this.externalServiceId4k ); } } } } export default Media; ================================================ FILE: server/entity/MediaRequest.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus, MediaType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import OverrideRule from '@server/entity/OverrideRule'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { truncate } from 'lodash'; import { AfterInsert, AfterLoad, AfterUpdate, Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, RelationCount, } from 'typeorm'; import Media from './Media'; import SeasonRequest from './SeasonRequest'; import { User } from './User'; export class RequestPermissionError extends Error {} export class QuotaRestrictedError extends Error {} export class DuplicateMediaRequestError extends Error {} export class NoSeasonsAvailableError extends Error {} export class BlocklistedMediaError extends Error {} type MediaRequestOptions = { isAutoRequest?: boolean; }; @Entity() export class MediaRequest { public static async request( requestBody: MediaRequestBody, user: User, options: MediaRequestOptions = {} ): Promise { const tmdb = new TheMovieDb(); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); const settings = getSettings(); let requestUser = user; if ( requestBody.userId && !requestUser.hasPermission([ Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS, ]) ) { throw new RequestPermissionError( 'You do not have permission to modify the request user.' ); } else if (requestBody.userId) { requestUser = await userRepository.findOneOrFail({ where: { id: requestBody.userId }, }); } if (!requestUser) { throw new Error('User missing from request context.'); } if ( requestBody.mediaType === MediaType.MOVIE && !requestUser.hasPermission( requestBody.is4k ? [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE] : [Permission.REQUEST, Permission.REQUEST_MOVIE], { type: 'or', } ) ) { throw new RequestPermissionError( `You do not have permission to make ${ requestBody.is4k ? '4K ' : '' }movie requests.` ); } else if ( requestBody.mediaType === MediaType.TV && !requestUser.hasPermission( requestBody.is4k ? [Permission.REQUEST_4K, Permission.REQUEST_4K_TV] : [Permission.REQUEST, Permission.REQUEST_TV], { type: 'or', } ) ) { throw new RequestPermissionError( `You do not have permission to make ${ requestBody.is4k ? '4K ' : '' }series requests.` ); } const quotas = await requestUser.getQuota(); if (requestBody.mediaType === MediaType.MOVIE && quotas.movie.restricted) { throw new QuotaRestrictedError('Movie Quota exceeded.'); } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { throw new QuotaRestrictedError('Series Quota exceeded.'); } const tmdbMedia = requestBody.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: requestBody.mediaId }) : await tmdb.getTvShow({ tvId: requestBody.mediaId }); let media = await mediaRepository.findOne({ where: { tmdbId: requestBody.mediaId, mediaType: requestBody.mediaType, }, relations: ['requests'], }); if (!media) { media = new Media({ tmdbId: tmdbMedia.id, tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: requestBody.mediaType, }); } else { if (media.status === MediaStatus.BLOCKLISTED) { logger.warn('Request for media blocked due to being blocklisted', { tmdbId: tmdbMedia.id, mediaType: requestBody.mediaType, label: 'Media Request', }); throw new BlocklistedMediaError('This media is blocklisted.'); } if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { media.status = MediaStatus.PENDING; } if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { media.status4k = MediaStatus.PENDING; } } const existing = await requestRepository .createQueryBuilder('request') .leftJoin('request.media', 'media') .leftJoinAndSelect('request.requestedBy', 'user') .where('request.is4k = :is4k', { is4k: requestBody.is4k }) .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) .andWhere('media.mediaType = :mediaType', { mediaType: requestBody.mediaType, }) .getMany(); if (existing && existing.length > 0) { // If there is an existing movie request that isn't declined, don't allow a new one. if ( requestBody.mediaType === MediaType.MOVIE && existing[0].status !== MediaRequestStatus.DECLINED && existing[0].status !== MediaRequestStatus.COMPLETED ) { logger.warn('Duplicate request for media blocked', { tmdbId: tmdbMedia.id, mediaType: requestBody.mediaType, is4k: requestBody.is4k, label: 'Media Request', }); throw new DuplicateMediaRequestError( 'Request for this media already exists.' ); } // If an existing auto-request for this media exists from the same user, // don't allow a new one. if ( existing.find( (r) => r.requestedBy.id === requestUser.id && r.isAutoRequest ) ) { throw new DuplicateMediaRequestError( 'Auto-request for this media and user already exists.' ); } } // Apply overrides if the user is not an admin or has the "advanced request" permission const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], { type: 'or', }); let rootFolder = requestBody.rootFolder; let profileId = requestBody.profileId; let tags = requestBody.tags; if (useOverrides) { const defaultRadarrId = requestBody.is4k ? settings.radarr.findIndex((r) => r.is4k && r.isDefault) : settings.radarr.findIndex((r) => !r.is4k && r.isDefault); const defaultSonarrId = requestBody.is4k ? settings.sonarr.findIndex((s) => s.is4k && s.isDefault) : settings.sonarr.findIndex((s) => !s.is4k && s.isDefault); const overrideRuleRepository = getRepository(OverrideRule); const overrideRules = await overrideRuleRepository.find({ where: requestBody.mediaType === MediaType.MOVIE ? { radarrServiceId: defaultRadarrId } : { sonarrServiceId: defaultSonarrId }, }); const appliedOverrideRules = overrideRules.filter((rule) => { const hasAnimeKeyword = 'results' in tmdbMedia.keywords && tmdbMedia.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID ); // Skip override rules if the media is an anime TV show as anime TV // is handled by default and override rules do not explicitly include // the anime keyword if ( requestBody.mediaType === MediaType.TV && hasAnimeKeyword && (!rule.keywords || !rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID)) ) { return false; } if ( rule.users && !rule.users .split(',') .some((userId) => Number(userId) === requestUser.id) ) { return false; } if ( rule.genre && !rule.genre .split(',') .some((genreId) => tmdbMedia.genres .map((genre) => genre.id) .includes(Number(genreId)) ) ) { return false; } if ( rule.language && !rule.language .split('|') .some((languageId) => languageId === tmdbMedia.original_language) ) { return false; } if ( rule.keywords && !rule.keywords.split(',').some((keywordId) => { let keywordList: TmdbKeyword[] = []; if ('keywords' in tmdbMedia.keywords) { keywordList = tmdbMedia.keywords.keywords; } else if ('results' in tmdbMedia.keywords) { keywordList = tmdbMedia.keywords.results; } return keywordList .map((keyword: TmdbKeyword) => keyword.id) .includes(Number(keywordId)); }) ) { return false; } return true; }); // hacky way to prioritize rules // TODO: make this better const prioritizedRule = appliedOverrideRules.sort((a, b) => { const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; const aSpecificity = keys.filter((key) => a[key] !== null).length; const bSpecificity = keys.filter((key) => b[key] !== null).length; // Take the rule with the most specific condition first return bSpecificity - aSpecificity; })[0]; if (prioritizedRule) { if (prioritizedRule.rootFolder) { rootFolder = prioritizedRule.rootFolder; } if (prioritizedRule.profileId) { profileId = prioritizedRule.profileId; } if (prioritizedRule.tags) { tags = [ ...new Set([ ...(tags || []), ...prioritizedRule.tags.split(',').map((tag) => Number(tag)), ]), ]; } logger.debug('Override rule applied.', { label: 'Media Request', overrides: prioritizedRule, }); } } if (requestBody.mediaType === MediaType.MOVIE) { await mediaRepository.save(media); const request = new MediaRequest({ type: MediaType.MOVIE, media, requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: user.hasPermission( [ requestBody.is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, requestBody.is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE, Permission.MANAGE_REQUESTS, ], { type: 'or' } ) ? MediaRequestStatus.APPROVED : MediaRequestStatus.PENDING, modifiedBy: user.hasPermission( [ requestBody.is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, requestBody.is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE, Permission.MANAGE_REQUESTS, ], { type: 'or' } ) ? user : undefined, is4k: requestBody.is4k, serverId: requestBody.serverId, profileId: profileId, rootFolder: rootFolder, tags: tags, isAutoRequest: options.isAutoRequest ?? false, }); await requestRepository.save(request); return request; } else { const tmdbMediaShow = tmdbMedia as Awaited< ReturnType >; let requestedSeasons = requestBody.seasons === 'all' ? tmdbMediaShow.seasons .filter((season) => season.season_number !== 0) .map((season) => season.season_number) : (requestBody.seasons as number[]); if (!settings.main.enableSpecialEpisodes) { requestedSeasons = requestedSeasons.filter((sn) => sn > 0); } let existingSeasons: number[] = []; // We need to check existing requests on this title to make sure we don't double up on seasons that were // already requested. In the case they were, we just throw out any duplicates but still approve the request. // (Unless there are no seasons, in which case we abort) if (media.requests) { existingSeasons = media.requests .filter( (request) => request.is4k === requestBody.is4k && request.status !== MediaRequestStatus.DECLINED && request.status !== MediaRequestStatus.COMPLETED ) .reduce((seasons, request) => { const combinedSeasons = request.seasons.map( (season) => season.seasonNumber ); return [...seasons, ...combinedSeasons]; }, [] as number[]); } // We should also check seasons that are available/partially available but don't have existing requests if (media.seasons) { existingSeasons = [ ...existingSeasons, ...media.seasons .filter( (season) => season[requestBody.is4k ? 'status4k' : 'status'] !== MediaStatus.UNKNOWN && season[requestBody.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED ) .map((season) => season.seasonNumber), ]; } const finalSeasons = requestedSeasons.filter( (rs) => !existingSeasons.includes(rs) ); if (finalSeasons.length === 0) { throw new NoSeasonsAvailableError('No seasons available to request'); } else if ( quotas.tv.limit && finalSeasons.length > (quotas.tv.remaining ?? 0) ) { throw new QuotaRestrictedError('Series Quota exceeded.'); } await mediaRepository.save(media); const request = new MediaRequest({ type: MediaType.TV, media, requestedBy: requestUser, // If the user is an admin or has the "auto approve" permission, automatically approve the request status: user.hasPermission( [ requestBody.is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, requestBody.is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV, Permission.MANAGE_REQUESTS, ], { type: 'or' } ) ? MediaRequestStatus.APPROVED : MediaRequestStatus.PENDING, modifiedBy: user.hasPermission( [ requestBody.is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, requestBody.is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV, Permission.MANAGE_REQUESTS, ], { type: 'or' } ) ? user : undefined, is4k: requestBody.is4k, serverId: requestBody.serverId, profileId: profileId, rootFolder: rootFolder, languageProfileId: requestBody.languageProfileId, tags: tags, seasons: finalSeasons.map( (sn) => new SeasonRequest({ seasonNumber: sn, status: user.hasPermission( [ requestBody.is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE, requestBody.is4k ? Permission.AUTO_APPROVE_4K_TV : Permission.AUTO_APPROVE_TV, Permission.MANAGE_REQUESTS, ], { type: 'or' } ) ? MediaRequestStatus.APPROVED : MediaRequestStatus.PENDING, }) ), isAutoRequest: options.isAutoRequest ?? false, }); await requestRepository.save(request); return request; } } @PrimaryGeneratedColumn() public id: number; @Column({ type: 'integer' }) @Index() public status: MediaRequestStatus; @ManyToOne(() => Media, (media) => media.requests, { eager: true, onDelete: 'CASCADE', }) @Index() public media: Media; @ManyToOne(() => User, (user) => user.requests, { eager: true, onDelete: 'CASCADE', }) @Index() public requestedBy: User; @ManyToOne(() => User, { nullable: true, cascade: true, eager: true, onDelete: 'SET NULL', }) @Index() public modifiedBy?: User; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; @Column({ type: 'varchar' }) public type: MediaType; @RelationCount((request: MediaRequest) => request.seasons) public seasonCount: number; @OneToMany(() => SeasonRequest, (season) => season.request, { eager: true, cascade: true, }) public seasons: SeasonRequest[]; @Column({ default: false }) public is4k: boolean; @Column({ nullable: true }) public serverId: number; @Column({ nullable: true }) public profileId: number; @Column({ nullable: true }) public rootFolder: string; @Column({ nullable: true }) public languageProfileId: number; @Column({ type: 'text', nullable: true, transformer: { from: (value: string | null): number[] | null => { if (value) { if (value === 'none') { return []; } return value.split(',').map((v) => Number(v)); } return null; }, to: (value: number[] | null): string | null => { if (value) { const finalValue = value.join(','); // We want to keep the actual state of an "empty array" so we use // the keyword "none" to track this. if (!finalValue) { return 'none'; } return finalValue; } return null; }, }, }) public tags?: number[]; @Column({ default: false }) public isAutoRequest: boolean; constructor(init?: Partial) { Object.assign(this, init); } @AfterInsert() public async notifyNewRequest(): Promise { if (this.status === MediaRequestStatus.PENDING) { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: this.media.id }, }); if (!media) { logger.error('Media data not found', { label: 'Media Request', requestId: this.id, mediaId: this.media.id, }); return; } MediaRequest.sendNotification(this, media, Notification.MEDIA_PENDING); if (this.isAutoRequest) { MediaRequest.sendNotification( this, media, Notification.MEDIA_AUTO_REQUESTED ); } } } /** * Notification for approval * * We only check on AfterUpdate as to not trigger this for * auto approved content */ @AfterUpdate() public async notifyApprovedOrDeclined(autoApproved = false): Promise { if ( this.status === MediaRequestStatus.APPROVED || this.status === MediaRequestStatus.DECLINED ) { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: this.media.id }, }); if (!media) { logger.error('Media data not found', { label: 'Media Request', requestId: this.id, mediaId: this.media.id, }); return; } if (media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) { logger.warn( 'Media became available before request was approved. Skipping approval notification', { label: 'Media Request', requestId: this.id, mediaId: this.media.id } ); return; } MediaRequest.sendNotification( this, media, this.status === MediaRequestStatus.APPROVED ? autoApproved ? Notification.MEDIA_AUTO_APPROVED : Notification.MEDIA_APPROVED : Notification.MEDIA_DECLINED ); if ( this.status === MediaRequestStatus.APPROVED && autoApproved && this.isAutoRequest ) { MediaRequest.sendNotification( this, media, Notification.MEDIA_AUTO_REQUESTED ); } } } @AfterInsert() public async autoapprovalNotification(): Promise { if (this.status === MediaRequestStatus.APPROVED) { this.notifyApprovedOrDeclined(true); } } @AfterLoad() private sortSeasons() { if (Array.isArray(this.seasons)) { this.seasons.sort((a, b) => a.id - b.id); } } static async sendNotification( entity: MediaRequest, media: Media, type: Notification ) { const tmdb = new TheMovieDb(); try { const mediaType = entity.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; let notifyAdmin = true; let notifySystem = true; switch (type) { case Notification.MEDIA_APPROVED: event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Approved`; notifyAdmin = false; break; case Notification.MEDIA_DECLINED: event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Declined`; notifyAdmin = false; break; case Notification.MEDIA_PENDING: event = `New ${entity.is4k ? '4K ' : ''}${mediaType} Request`; break; case Notification.MEDIA_AUTO_REQUESTED: event = `${ entity.is4k ? '4K ' : '' }${mediaType} Request Automatically Submitted`; notifyAdmin = false; notifySystem = false; break; case Notification.MEDIA_AUTO_APPROVED: event = `${ entity.is4k ? '4K ' : '' }${mediaType} Request Automatically Approved`; break; case Notification.MEDIA_FAILED: event = `${entity.is4k ? '4K ' : ''}${mediaType} Request Failed`; break; } if (entity.type === MediaType.MOVIE) { const movie = await tmdb.getMovie({ movieId: media.tmdbId }); notificationManager.sendNotification(type, { media, request: entity, notifyAdmin, notifySystem, notifyUser: notifyAdmin ? undefined : entity.requestedBy, event, subject: `${movie.title}${ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' }`, message: truncate(movie.overview, { length: 500, separator: /\s/, omission: '…', }), image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, }); } else if (entity.type === MediaType.TV) { const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); notificationManager.sendNotification(type, { media, request: entity, notifyAdmin, notifySystem, notifyUser: notifyAdmin ? undefined : entity.requestedBy, event, subject: `${tv.name}${ tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' }`, message: truncate(tv.overview, { length: 500, separator: /\s/, omission: '…', }), image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, extra: [ { name: 'Requested Seasons', value: entity.seasons .map((season) => season.seasonNumber) .join(', '), }, ], }); } } catch (e) { logger.error('Something went wrong sending media notification(s)', { label: 'Notifications', errorMessage: e.message, requestId: entity.id, mediaId: entity.media.id, }); } } } export default MediaRequest; ================================================ FILE: server/entity/OverrideRule.ts ================================================ import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity() class OverrideRule { @PrimaryGeneratedColumn() public id: number; @Column({ type: 'int', nullable: true }) public radarrServiceId?: number; @Column({ type: 'int', nullable: true }) public sonarrServiceId?: number; @Column({ nullable: true }) public users?: string; @Column({ nullable: true }) public genre?: string; @Column({ nullable: true }) public language?: string; @Column({ nullable: true }) public keywords?: string; @Column({ type: 'int', nullable: true }) public profileId?: number; @Column({ nullable: true }) public rootFolder?: string; @Column({ nullable: true }) public tags?: string; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; constructor(init?: Partial) { Object.assign(this, init); } } export default OverrideRule; ================================================ FILE: server/entity/Season.ts ================================================ import { MediaStatus } from '@server/constants/media'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; import Media from './Media'; @Entity() class Season { @PrimaryGeneratedColumn() public id: number; @Column() public seasonNumber: number; @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status: MediaStatus; @Column({ type: 'int', default: MediaStatus.UNKNOWN }) public status4k: MediaStatus; @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE', }) @Index() public media: Promise; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; constructor(init?: Partial) { Object.assign(this, init); } } export default Season; ================================================ FILE: server/entity/SeasonRequest.ts ================================================ import { MediaRequestStatus } from '@server/constants/media'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; import { MediaRequest } from './MediaRequest'; @Entity() class SeasonRequest { @PrimaryGeneratedColumn() public id: number; @Column() public seasonNumber: number; @Column({ type: 'int', default: MediaRequestStatus.PENDING }) public status: MediaRequestStatus; @ManyToOne(() => MediaRequest, (request) => request.seasons, { onDelete: 'CASCADE', }) @Index() public request: MediaRequest; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; constructor(init?: Partial) { Object.assign(this, init); } } export default SeasonRequest; ================================================ FILE: server/entity/Session.ts ================================================ import type { ISession } from 'connect-typeorm'; import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; @Entity() export class Session implements ISession { @Index() @Column('bigint') public expiredAt = Date.now(); @PrimaryColumn('varchar', { length: 255 }) public id = ''; @Column('text') public json = ''; } ================================================ FILE: server/entity/User.ts ================================================ import { MediaRequestStatus, MediaType } from '@server/constants/media'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { Watchlist } from '@server/entity/Watchlist'; import type { QuotaResponse } from '@server/interfaces/api/userInterfaces'; import PreparedEmail from '@server/lib/email'; import type { PermissionCheckOptions } from '@server/lib/permissions'; import { Permission, hasPermission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { AfterDate } from '@server/utils/dateHelpers'; import bcrypt from 'bcrypt'; import { randomUUID } from 'crypto'; import path from 'path'; import { default as generatePassword } from 'secure-random-password'; import { AfterLoad, Column, Entity, Not, OneToMany, OneToOne, PrimaryGeneratedColumn, RelationCount, } from 'typeorm'; import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; import { UserPushSubscription } from './UserPushSubscription'; import { UserSettings } from './UserSettings'; @Entity() export class User { public static filterMany( users: User[], showFiltered?: boolean ): Partial[] { return users.map((u) => u.filter(showFiltered)); } static readonly filteredFields: string[] = [ 'email', 'plexId', 'password', 'resetPasswordGuid', 'jellyfinDeviceId', 'jellyfinAuthToken', 'plexToken', 'settings', ]; public displayName: string; @PrimaryGeneratedColumn() public id: number; @Column({ unique: true, transformer: { from: (value: string): string => (value ?? '').toLowerCase(), to: (value: string): string => (value ?? '').toLowerCase(), }, }) public email: string; @Column({ type: 'varchar', nullable: true }) public plexUsername?: string | null; @Column({ type: 'varchar', nullable: true }) public jellyfinUsername?: string | null; @Column({ nullable: true }) public username?: string; @Column({ nullable: true, select: false }) public password?: string; @Column({ nullable: true, select: false }) public resetPasswordGuid?: string; @DbAwareColumn({ type: 'datetime', nullable: true }) public recoveryLinkExpirationDate?: Date | null; @Column({ type: 'integer', default: UserType.PLEX }) public userType: UserType; @Column({ type: 'integer', nullable: true, select: true }) public plexId?: number | null; @Column({ type: 'varchar', nullable: true }) public jellyfinUserId?: string | null; @Column({ type: 'varchar', nullable: true, select: false }) public jellyfinDeviceId?: string | null; @Column({ type: 'varchar', nullable: true, select: false }) public jellyfinAuthToken?: string | null; @Column({ type: 'varchar', nullable: true, select: false }) public plexToken?: string | null; @Column({ type: 'integer', default: 0 }) public permissions = 0; @Column() public avatar: string; @Column({ type: 'varchar', nullable: true }) public avatarETag?: string | null; @Column({ type: 'varchar', nullable: true }) public avatarVersion?: string | null; @RelationCount((user: User) => user.requests) public requestCount: number; @OneToMany(() => MediaRequest, (request) => request.requestedBy) public requests: MediaRequest[]; @OneToMany(() => Watchlist, (watchlist) => watchlist.requestedBy) public watchlists: Watchlist[]; @Column({ nullable: true }) public movieQuotaLimit?: number; @Column({ nullable: true }) public movieQuotaDays?: number; @Column({ nullable: true }) public tvQuotaLimit?: number; @Column({ nullable: true }) public tvQuotaDays?: number; @OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, eager: true, onDelete: 'CASCADE', }) public settings?: UserSettings; @OneToMany(() => UserPushSubscription, (pushSub) => pushSub.user) public pushSubscriptions: UserPushSubscription[]; @OneToMany(() => Issue, (issue) => issue.createdBy, { cascade: true }) public createdIssues: Issue[]; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; public warnings: string[] = []; constructor(init?: Partial) { Object.assign(this, init); } public filter(showFiltered?: boolean): Partial { const filtered: Partial = Object.assign( {}, ...(Object.keys(this) as (keyof User)[]) .filter((k) => showFiltered || !User.filteredFields.includes(k)) .map((k) => ({ [k]: this[k] })) ); return filtered; } public hasPermission( permissions: Permission | Permission[], options?: PermissionCheckOptions ): boolean { return !!hasPermission(permissions, this.permissions, options); } public passwordMatch(password: string): Promise { return new Promise((resolve) => { if (this.password) { resolve(bcrypt.compare(password, this.password)); } else { return resolve(false); } }); } public async setPassword(password: string): Promise { const hashedPassword = await bcrypt.hash(password, 12); this.password = hashedPassword; } public async generatePassword(): Promise { const password = generatePassword.randomPassword({ length: 16 }); this.setPassword(password); const { applicationTitle, applicationUrl } = getSettings().main; try { logger.info(`Sending generated password email for ${this.email}`, { label: 'User Management', }); const email = new PreparedEmail(getSettings().notifications.agents.email); await email.send({ template: path.join(__dirname, '../templates/email/generatedpassword'), message: { to: this.email, }, locals: { password: password, applicationUrl, applicationTitle, recipientName: this.username, }, }); } catch (e) { logger.error('Failed to send out generated password email', { label: 'User Management', message: e.message, }); } } public async resetPassword(): Promise { const guid = randomUUID(); this.resetPasswordGuid = guid; // 24 hours into the future const targetDate = new Date(); targetDate.setDate(targetDate.getDate() + 1); this.recoveryLinkExpirationDate = targetDate; const { applicationTitle, applicationUrl } = getSettings().main; const resetPasswordLink = `${applicationUrl}/resetpassword/${guid}`; try { logger.info(`Sending reset password email for ${this.email}`, { label: 'User Management', }); const email = new PreparedEmail(getSettings().notifications.agents.email); await email.send({ template: path.join(__dirname, '../templates/email/resetpassword'), message: { to: this.email, }, locals: { resetPasswordLink, applicationUrl, applicationTitle, recipientName: this.displayName, recipientEmail: this.email, }, }); } catch (e) { logger.error('Failed to send out reset password email', { label: 'User Management', message: e.message, }); } } @AfterLoad() public setDisplayName(): void { this.displayName = this.username || this.plexUsername || this.jellyfinUsername || this.email; } public async getQuota(): Promise { const { main: { defaultQuotas }, } = getSettings(); const requestRepository = getRepository(MediaRequest); const canBypass = this.hasPermission([Permission.MANAGE_USERS], { type: 'or', }); const movieQuotaLimit = !canBypass ? (this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit) : 0; const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays; // Count movie requests made during quota period const movieDate = new Date(); if (movieQuotaDays) { movieDate.setDate(movieDate.getDate() - movieQuotaDays); } const movieQuotaUsed = movieQuotaLimit ? await requestRepository.count({ where: { requestedBy: { id: this.id, }, createdAt: AfterDate(movieDate), type: MediaType.MOVIE, status: Not(MediaRequestStatus.DECLINED), }, }) : 0; const tvQuotaLimit = !canBypass ? (this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit) : 0; const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays; // Count tv season requests made during quota period const tvDate = new Date(); if (tvQuotaDays) { tvDate.setDate(tvDate.getDate() - tvQuotaDays); } const tvQuotaStartDate = tvDate.toJSON(); const tvQuotaUsed = tvQuotaLimit ? ( await requestRepository .createQueryBuilder('request') .leftJoin('request.seasons', 'seasons') .leftJoin('request.requestedBy', 'requestedBy') .where('request.type = :requestType', { requestType: MediaType.TV, }) .andWhere('requestedBy.id = :userId', { userId: this.id, }) .andWhere('request.createdAt > :date', { date: tvQuotaStartDate, }) .andWhere('request.status != :declinedStatus', { declinedStatus: MediaRequestStatus.DECLINED, }) .addSelect((subQuery) => { return subQuery .select('COUNT(season.id)', 'seasonCount') .from(SeasonRequest, 'season') .leftJoin('season.request', 'parentRequest') .where('parentRequest.id = request.id'); }, 'seasonCount') .getMany() ).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0) : 0; return { movie: { days: movieQuotaDays, limit: movieQuotaLimit, used: movieQuotaUsed, remaining: movieQuotaLimit ? Math.max(0, movieQuotaLimit - movieQuotaUsed) : undefined, restricted: movieQuotaLimit && movieQuotaLimit - movieQuotaUsed <= 0 ? true : false, }, tv: { days: tvQuotaDays, limit: tvQuotaLimit, used: tvQuotaUsed, remaining: tvQuotaLimit ? Math.max(0, tvQuotaLimit - tvQuotaUsed) : undefined, restricted: tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false, }, }; } } ================================================ FILE: server/entity/UserPushSubscription.ts ================================================ import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, Unique, } from 'typeorm'; import { User } from './User'; @Entity() @Unique(['endpoint', 'user']) export class UserPushSubscription { @PrimaryGeneratedColumn() public id: number; @ManyToOne(() => User, (user) => user.pushSubscriptions, { eager: true, onDelete: 'CASCADE', }) @Index() public user: User; @Column() public endpoint: string; @Column() public p256dh: string; @Column() public auth: string; @Column({ nullable: true }) public userAgent: string; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', nullable: true, }) public createdAt: Date; constructor(init?: Partial) { Object.assign(this, init); } } ================================================ FILE: server/entity/UserSettings.ts ================================================ import type { NotificationAgentTypes } from '@server/interfaces/api/userSettingsInterfaces'; import { hasNotificationType, Notification } from '@server/lib/notifications'; import { NotificationAgentKey } from '@server/lib/settings'; import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; import { User } from './User'; export const ALL_NOTIFICATIONS = Object.values(Notification) .filter((v) => !isNaN(Number(v))) .reduce((a, v) => a + Number(v), 0); @Entity() export class UserSettings { constructor(init?: Partial) { Object.assign(this, init); } @PrimaryGeneratedColumn() public id: number; @OneToOne(() => User, (user) => user.settings, { onDelete: 'CASCADE' }) @JoinColumn() public user: User; @Column({ default: '' }) public locale?: string; @Column({ nullable: true }) public discoverRegion?: string; @Column({ nullable: true }) public streamingRegion?: string; @Column({ nullable: true }) public originalLanguage?: string; @Column({ nullable: true }) public pgpKey?: string; @Column({ nullable: true }) public discordId?: string; @Column({ nullable: true }) public pushbulletAccessToken?: string; @Column({ nullable: true }) public pushoverApplicationToken?: string; @Column({ nullable: true }) public pushoverUserKey?: string; @Column({ nullable: true }) public pushoverSound?: string; @Column({ nullable: true }) public telegramChatId?: string; @Column({ nullable: true }) public telegramMessageThreadId?: string; @Column({ nullable: true }) public telegramSendSilently?: boolean; @Column({ nullable: true }) public watchlistSyncMovies?: boolean; @Column({ nullable: true }) public watchlistSyncTv?: boolean; @Column({ type: 'text', nullable: true, transformer: { from: (value: string | null): Partial => { const defaultTypes = { email: ALL_NOTIFICATIONS, discord: 0, pushbullet: 0, pushover: 0, slack: 0, telegram: 0, webhook: 0, webpush: ALL_NOTIFICATIONS, }; if (!value) { return defaultTypes; } const values = JSON.parse(value) as Partial; // Something with the migration to this field has caused some issue where // the value pre-populates with just a raw "2"? Here we check if that's the case // and return the default notification types if so if (typeof values !== 'object') { return defaultTypes; } if (values.email == null) { values.email = ALL_NOTIFICATIONS; } if (values.webpush == null) { values.webpush = ALL_NOTIFICATIONS; } return values; }, to: (value: Partial): string | null => { if (!value || typeof value !== 'object') { return null; } const allowedKeys = Object.values(NotificationAgentKey); // Remove any unknown notification agent keys before saving to db (Object.keys(value) as (keyof NotificationAgentTypes)[]).forEach( (key) => { if (!allowedKeys.includes(key)) { delete value[key]; } } ); return JSON.stringify(value); }, }, }) public notificationTypes: Partial; public hasNotificationType( key: NotificationAgentKey, type: Notification ): boolean { return hasNotificationType(type, this.notificationTypes[key] ?? 0); } } ================================================ FILE: server/entity/Watchlist.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import logger from '@server/logger'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, Unique, } from 'typeorm'; import type { ZodNumber, ZodOptional, ZodString } from 'zod'; export class DuplicateWatchlistRequestError extends Error {} export class NotFoundError extends Error { constructor(message = 'Not found') { super(message); this.name = 'NotFoundError'; } } @Entity() @Unique('UNIQUE_USER_DB', ['tmdbId', 'mediaType', 'requestedBy']) export class Watchlist implements WatchlistItem { @PrimaryGeneratedColumn() id: number; @Column({ type: 'varchar' }) public ratingKey = ''; @Column({ type: 'varchar' }) public mediaType: MediaType; @Column({ type: 'varchar' }) title = ''; @Column() @Index() public tmdbId: number; @ManyToOne(() => User, (user) => user.watchlists, { eager: true, onDelete: 'CASCADE', }) @Index() public requestedBy: User; @ManyToOne(() => Media, (media) => media.watchlists, { eager: true, onDelete: 'CASCADE', }) @Index() public media: Media; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP', }) public updatedAt: Date; constructor(init?: Partial) { Object.assign(this, init); } public static async createWatchlist({ watchlistRequest, user, }: { watchlistRequest: { mediaType: MediaType; ratingKey?: ZodOptional['_output']; title?: ZodOptional['_output']; tmdbId: ZodNumber['_output']; }; user: User; }): Promise { const watchlistRepository = getRepository(this); const mediaRepository = getRepository(Media); const tmdb = new TheMovieDb(); const tmdbMedia = watchlistRequest.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: watchlistRequest.tmdbId }) : await tmdb.getTvShow({ tvId: watchlistRequest.tmdbId }); const existing = await watchlistRepository .createQueryBuilder('watchlist') .leftJoinAndSelect('watchlist.requestedBy', 'user') .where('user.id = :userId', { userId: user.id }) .andWhere('watchlist.tmdbId = :tmdbId', { tmdbId: watchlistRequest.tmdbId, }) .andWhere('watchlist.mediaType = :mediaType', { mediaType: watchlistRequest.mediaType, }) .getMany(); if (existing && existing.length > 0) { logger.warn('Duplicate request for watchlist blocked', { tmdbId: watchlistRequest.tmdbId, mediaType: watchlistRequest.mediaType, label: 'Watchlist', }); throw new DuplicateWatchlistRequestError(); } let media = await mediaRepository.findOne({ where: { tmdbId: watchlistRequest.tmdbId, mediaType: watchlistRequest.mediaType, }, }); if (!media) { media = new Media({ tmdbId: tmdbMedia.id, tvdbId: tmdbMedia.external_ids.tvdb_id, mediaType: watchlistRequest.mediaType, }); } const watchlist = new this({ ...watchlistRequest, requestedBy: user, media, }); await mediaRepository.save(media); await watchlistRepository.save(watchlist); return watchlist; } public static async deleteWatchlist( tmdbId: Watchlist['tmdbId'], mediaType: MediaType, user: User ): Promise { const watchlistRepository = getRepository(this); const watchlist = await watchlistRepository.findOneBy({ tmdbId, mediaType, requestedBy: { id: user.id }, }); if (!watchlist) { throw new NotFoundError('not Found'); } if (watchlist) { await watchlistRepository.delete(watchlist.id); } return watchlist; } } ================================================ FILE: server/index.ts ================================================ import csurf from '@dr.pogodin/csurf'; import PlexAPI from '@server/api/plexapi'; import dataSource, { getRepository, isPgsql } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; import notificationManager from '@server/lib/notifications'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; import GotifyAgent from '@server/lib/notifications/agents/gotify'; import NtfyAgent from '@server/lib/notifications/agents/ntfy'; import PushbulletAgent from '@server/lib/notifications/agents/pushbullet'; import PushoverAgent from '@server/lib/notifications/agents/pushover'; import SlackAgent from '@server/lib/notifications/agents/slack'; import TelegramAgent from '@server/lib/notifications/agents/telegram'; import WebhookAgent from '@server/lib/notifications/agents/webhook'; import WebPushAgent from '@server/lib/notifications/agents/webpush'; import checkOverseerrMerge from '@server/lib/overseerrMerge'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; import avatarproxy from '@server/routes/avatarproxy'; import imageproxy from '@server/routes/imageproxy'; import { appDataPermissions } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; import createCustomProxyAgent from '@server/utils/customProxyAgent'; import { initializeDnsCache } from '@server/utils/dnsCache'; import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; import axios from 'axios'; import { TypeormStore } from 'connect-typeorm/out'; import cookieParser from 'cookie-parser'; import type { NextFunction, Request, Response } from 'express'; import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; import type { Store } from 'express-session'; import session from 'express-session'; import http from 'http'; import https from 'https'; import next from 'next'; import path from 'path'; import swaggerUi from 'swagger-ui-express'; import YAML from 'yamljs'; const API_SPEC_PATH = path.join(__dirname, '../seerr-api.yml'); logger.info(`Starting Seerr version ${getAppVersion()}`); const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); if (!appDataPermissions()) { logger.error( 'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.seerr.dev/getting-started' ); } app .prepare() .then(async () => { // Run Overseerr to Seerr migration await checkOverseerrMerge(); const dbConnection = dataSource.isInitialized ? dataSource : await dataSource.initialize(); // Run migrations in production if (process.env.NODE_ENV === 'production') { if (isPgsql) { await dbConnection.runMigrations(); } else { await dbConnection.query('PRAGMA foreign_keys=OFF'); await dbConnection.runMigrations(); await dbConnection.query('PRAGMA foreign_keys=ON'); } } // Load Settings const settings = await getSettings().load(); restartFlag.initializeSettings(settings); if (settings.network.forceIpv4First) { axios.defaults.httpAgent = new http.Agent({ family: 4 }); axios.defaults.httpsAgent = new https.Agent({ family: 4 }); } // Add DNS caching if (settings.network.dnsCache?.enabled) { initializeDnsCache({ forceMinTtl: settings.network.dnsCache.forceMinTtl, forceMaxTtl: settings.network.dnsCache.forceMaxTtl, }); } // Register HTTP proxy if (settings.network.proxy.enabled) { await createCustomProxyAgent( settings.network.proxy, settings.network.forceIpv4First ); } // Migrate library types if ( settings.plex.libraries.length > 1 && !settings.plex.libraries[0].type ) { const userRepository = getRepository(User); const admin = await userRepository.findOne({ select: { id: true, plexToken: true }, where: { id: 1 }, }); if (admin) { logger.info('Migrating Plex libraries to include media type', { label: 'Settings', }); const plexapi = new PlexAPI({ plexToken: admin.plexToken }); await plexapi.syncLibraries(); } } // Register Notification Agents notificationManager.registerAgents([ new DiscordAgent(), new EmailAgent(), new GotifyAgent(), new NtfyAgent(), new PushbulletAgent(), new PushoverAgent(), new SlackAgent(), new TelegramAgent(), new WebhookAgent(), new WebPushAgent(), ]); const userRepository = getRepository(User); const totalUsers = await userRepository.count(); if (totalUsers > 0) { startJobs(); } else { logger.info( `Skipping starting the scheduled jobs as we have no Plex/Jellyfin/Emby servers setup yet`, { label: 'Server', } ); } // Bootstrap Discovery Sliders await DiscoverSlider.bootstrapSliders(); const server = express(); if (settings.network.trustProxy) { server.enable('trust proxy'); } server.use(cookieParser()); server.use(express.json()); server.use(express.urlencoded({ extended: true })); server.use((req, _res, next) => { try { const descriptor = Object.getOwnPropertyDescriptor(req, 'ip'); if (descriptor?.writable === true) { Object.defineProperty(req, 'ip', { ...descriptor, value: getClientIp(req) ?? '', }); } } catch (e) { logger.error('Failed to attach the ip to the request', { label: 'Middleware', message: (e as Error).message, }); } finally { next(); } }); if (settings.network.csrfProtection) { server.use( csurf({ cookie: { httpOnly: true, sameSite: true, secure: !dev, }, }) ); server.use((req, res, next) => { res.cookie('XSRF-TOKEN', req.csrfToken(), { sameSite: true, secure: !dev, }); next(); }); } // Set up sessions const sessionRespository = getRepository(Session); server.use( '/api', session({ secret: settings.clientId, resave: false, saveUninitialized: false, cookie: { maxAge: 1000 * 60 * 60 * 24 * 30, httpOnly: true, sameSite: settings.network.csrfProtection ? 'strict' : 'lax', secure: 'auto', }, store: new TypeormStore({ cleanupLimit: 2, ttl: 60 * 60 * 24 * 30, }).connect(sessionRespository) as Store, }) ); const apiDocs = YAML.load(API_SPEC_PATH); server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs)); server.use( OpenApiValidator.middleware({ apiSpec: API_SPEC_PATH, validateRequests: true, }) ); /** * This is a workaround to convert dates to strings before they are validated by * OpenAPI validator. Otherwise, they are treated as objects instead of strings * and response validation will fail */ server.use((_req, res, next) => { const original = res.json; res.json = function jsonp(json) { return original.call(this, JSON.parse(JSON.stringify(json))); }; next(); }); server.use('/api/v1', routes); // Do not set cookies so CDNs can cache them server.use('/imageproxy', clearCookies, imageproxy); server.use('/avatarproxy', clearCookies, avatarproxy); server.get('*', (req, res) => handle(req, res)); server.use( ( err: { status: number; message: string; errors: string[] }, _req: Request, res: Response, // We must provide a next function for the function signature here even though its not used // eslint-disable-next-line @typescript-eslint/no-unused-vars _next: NextFunction ) => { // format error res.status(err.status || 500).json({ message: err.message, errors: err.errors, }); } ); const port = Number(process.env.PORT) || 5055; const host = process.env.HOST; if (host) { server.listen(port, host, () => { logger.info(`Server ready on ${host} port ${port}`, { label: 'Server', }); }); } else { server.listen(port, () => { logger.info(`Server ready on port ${port}`, { label: 'Server', }); }); } }) .catch((err) => { logger.error(err.stack); process.exit(1); }); ================================================ FILE: server/interfaces/api/blocklistInterfaces.ts ================================================ import type { User } from '@server/entity/User'; import type { PaginatedResponse } from '@server/interfaces/api/common'; export interface BlocklistItem { tmdbId: number; mediaType: 'movie' | 'tv'; title?: string; createdAt?: Date; user?: User; blocklistedTags?: string; } export interface BlocklistResultsResponse extends PaginatedResponse { results: BlocklistItem[]; } ================================================ FILE: server/interfaces/api/common.ts ================================================ interface PageInfo { pages: number; page: number; results: number; pageSize: number; } export interface PaginatedResponse { pageInfo: PageInfo; } /** * Get the keys of an object that are not functions */ type NonFunctionPropertyNames = { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [K in keyof T]: T[K] extends Function ? never : K; }[keyof T]; /** * Get the properties of an object that are not functions */ export type NonFunctionProperties = Pick>; ================================================ FILE: server/interfaces/api/discoverInterfaces.ts ================================================ export interface GenreSliderItem { id: number; name: string; backdrops: string[]; } export interface WatchlistItem { id: number; ratingKey: string; tmdbId: number; mediaType: 'movie' | 'tv'; title: string; } export interface WatchlistResponse { page: number; totalPages: number; totalResults: number; results: WatchlistItem[]; } ================================================ FILE: server/interfaces/api/issueInterfaces.ts ================================================ import type Issue from '@server/entity/Issue'; import type { PaginatedResponse } from './common'; export interface IssueResultsResponse extends PaginatedResponse { results: Issue[]; } ================================================ FILE: server/interfaces/api/mediaInterfaces.ts ================================================ import type Media from '@server/entity/Media'; import type { User } from '@server/entity/User'; import type { PaginatedResponse } from './common'; export interface MediaResultsResponse extends PaginatedResponse { results: Media[]; } export interface MediaWatchDataResponse { data?: { users: User[]; playCount: number; playCount7Days: number; playCount30Days: number; }; data4k?: { users: User[]; playCount: number; playCount7Days: number; playCount30Days: number; }; } ================================================ FILE: server/interfaces/api/overrideRuleInterfaces.ts ================================================ import type OverrideRule from '@server/entity/OverrideRule'; export type OverrideRuleResultsResponse = OverrideRule[]; ================================================ FILE: server/interfaces/api/personInterfaces.ts ================================================ import type { PersonCreditCast, PersonCreditCrew } from '@server/models/Person'; export interface PersonCombinedCreditsResponse { id: number; cast: PersonCreditCast[]; crew: PersonCreditCrew[]; } ================================================ FILE: server/interfaces/api/plexInterfaces.ts ================================================ import type { PlexSettings } from '@server/lib/settings'; export interface PlexStatus { settings: PlexSettings; status: number; message: string; } export interface PlexConnection { protocol: string; address: string; port: number; uri: string; local: boolean; status?: number; message?: string; } export interface PlexDevice { name: string; product: string; productVersion: string; platform: string; platformVersion: string; device: string; clientIdentifier: string; createdAt: Date; lastSeenAt: Date; provides: string[]; owned: boolean; accessToken?: string; publicAddress?: string; httpsRequired?: boolean; synced?: boolean; relay?: boolean; dnsRebindingProtection?: boolean; natLoopbackSupported?: boolean; publicAddressMatches?: boolean; presence?: boolean; ownerID?: string; home?: boolean; sourceTitle?: string; connection: PlexConnection[]; } ================================================ FILE: server/interfaces/api/requestInterfaces.ts ================================================ import type { MediaType } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { NonFunctionProperties, PaginatedResponse } from './common'; export interface RequestResultsResponse extends PaginatedResponse { results: (NonFunctionProperties & { profileName?: string; canRemove?: boolean; })[]; serviceErrors: { radarr: { id: number; name: string }[]; sonarr: { id: number; name: string }[]; }; } export type MediaRequestBody = { mediaType: MediaType; mediaId: number; tvdbId?: number; seasons?: number[] | 'all'; is4k?: boolean; serverId?: number; profileId?: number; profileName?: string; rootFolder?: string; languageProfileId?: number; userId?: number; tags?: number[]; }; ================================================ FILE: server/interfaces/api/serviceInterfaces.ts ================================================ import type { QualityProfile, RootFolder, Tag } from '@server/api/servarr/base'; import type { LanguageProfile } from '@server/api/servarr/sonarr'; export interface ServiceCommonServer { id: number; name: string; is4k: boolean; isDefault: boolean; activeProfileId: number; activeDirectory: string; activeLanguageProfileId?: number; activeAnimeProfileId?: number; activeAnimeDirectory?: string; activeAnimeLanguageProfileId?: number; activeTags: number[]; activeAnimeTags?: number[]; } export interface ServiceCommonServerWithDetails { server: ServiceCommonServer; profiles: QualityProfile[]; rootFolders: Partial[]; languageProfiles?: LanguageProfile[]; tags: Tag[]; } ================================================ FILE: server/interfaces/api/settingsInterfaces.ts ================================================ import type { DnsEntries, DnsStats } from 'dns-caching'; import type { PaginatedResponse } from './common'; export type LogMessage = { timestamp: string; level: string; label?: string; message: string; data?: Record; }; export interface LogsResultsResponse extends PaginatedResponse { results: LogMessage[]; } export interface SettingsAboutResponse { version: string; totalRequests: number; totalMediaItems: number; tz?: string; appDataPath: string; } export interface PublicSettingsResponse { jellyfinHost?: string; jellyfinExternalHost?: string; jellyfinServerName?: string; jellyfinForgotPasswordUrl?: string; initialized: boolean; applicationTitle: string; applicationUrl: string; hideAvailable: boolean; hideBlocklisted: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; discoverRegion: string; streamingRegion: string; originalLanguage: string; mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; locale: string; emailEnabled: boolean; newPlexLogin: boolean; youtubeUrl: string; } export interface CacheItem { id: string; name: string; stats: { hits: number; misses: number; keys: number; ksize: number; vsize: number; }; } export interface CacheResponse { apiCaches: CacheItem[]; imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>; dnsCache: { stats: DnsStats | undefined; entries: DnsEntries | undefined; }; } export interface StatusResponse { version: string; commitTag: string; updateAvailable: boolean; commitsBehind: number; restartRequired: boolean; } ================================================ FILE: server/interfaces/api/userInterfaces.ts ================================================ import type Media from '@server/entity/Media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { User } from '@server/entity/User'; import type { PaginatedResponse } from './common'; export interface UserResultsResponse extends PaginatedResponse { results: User[]; } export interface UserRequestsResponse extends PaginatedResponse { results: MediaRequest[]; } export interface QuotaStatus { days?: number; limit?: number; used: number; remaining?: number; restricted: boolean; } export interface QuotaResponse { movie: QuotaStatus; tv: QuotaStatus; } export interface UserWatchDataResponse { recentlyWatched: Media[]; playCount: number; } ================================================ FILE: server/interfaces/api/userSettingsInterfaces.ts ================================================ import type { NotificationAgentKey } from '@server/lib/settings'; export interface UserSettingsGeneralResponse { username?: string; email?: string; discordId?: string; locale?: string; discoverRegion?: string; streamingRegion?: string; originalLanguage?: string; movieQuotaLimit?: number; movieQuotaDays?: number; tvQuotaLimit?: number; tvQuotaDays?: number; globalMovieQuotaDays?: number; globalMovieQuotaLimit?: number; globalTvQuotaLimit?: number; globalTvQuotaDays?: number; watchlistSyncMovies?: boolean; watchlistSyncTv?: boolean; } export type NotificationAgentTypes = Record; export interface UserSettingsNotificationsResponse { emailEnabled?: boolean; pgpKey?: string; discordEnabled?: boolean; discordEnabledTypes?: number; discordId?: string; pushbulletAccessToken?: string; pushoverApplicationToken?: string; pushoverUserKey?: string; pushoverSound?: string; telegramEnabled?: boolean; telegramBotUsername?: string; telegramChatId?: string; telegramMessageThreadId?: string; telegramSendSilently?: boolean; webPushEnabled?: boolean; notificationTypes: Partial; } ================================================ FILE: server/interfaces/api/watchlistCreate.ts ================================================ import { MediaType } from '@server/constants/media'; import { z } from 'zod'; export const watchlistCreate = z.object({ ratingKey: z.coerce.string().optional(), tmdbId: z.coerce.number(), mediaType: z.nativeEnum(MediaType), title: z.coerce.string().optional(), }); ================================================ FILE: server/job/blocklistedTagsProcessor.ts ================================================ import type { SortOptions } from '@server/api/themoviedb'; import { SortOptionsIterable } from '@server/api/themoviedb'; import type { TmdbSearchMovieResponse, TmdbSearchTvResponse, } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import dataSource from '@server/datasource'; import { Blocklist } from '@server/entity/Blocklist'; import Media from '@server/entity/Media'; import type { RunnableScanner, StatusBase, } from '@server/lib/scanners/baseScanner'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { createTmdbWithRegionLanguage } from '@server/routes/discover'; import type { EntityManager } from 'typeorm'; const TMDB_API_DELAY_MS = 250; class AbortTransaction extends Error {} class BlocklistedTagProcessor implements RunnableScanner { private running = false; private progress = 0; private total = 0; public async run() { this.running = true; try { await dataSource.transaction(async (em) => { await this.cleanBlocklist(em); await this.createBlocklistEntries(em); }); } catch (err) { if (err instanceof AbortTransaction) { logger.info('Aborting job: Process Blocklisted Tags', { label: 'Jobs', }); } else { throw err; } } finally { this.reset(); } } public status(): StatusBase { return { running: this.running, progress: this.progress, total: this.total, }; } public cancel() { this.running = false; this.progress = 0; this.total = 0; } private reset() { this.cancel(); } private async createBlocklistEntries(em: EntityManager) { const tmdb = createTmdbWithRegionLanguage(); const settings = getSettings(); const blocklistedTags = settings.main.blocklistedTags; const blocklistedTagsArr = blocklistedTags.split(','); const pageLimit = settings.main.blocklistedTagsLimit; const invalidKeywords = new Set(); if (blocklistedTags.length === 0) { return; } // The maximum number of queries we're expected to execute this.total = 2 * blocklistedTagsArr.length * pageLimit * SortOptionsIterable.length; for (const type of [MediaType.MOVIE, MediaType.TV]) { const getDiscover = type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv; // Iterate for each tag for (const tag of blocklistedTagsArr) { const keywordDetails = await tmdb.getKeywordDetails({ keywordId: Number(tag), }); if (keywordDetails === null) { logger.warn('Skipping invalid keyword in blocklisted tags', { label: 'Blocklisted Tags Processor', keywordId: tag, }); invalidKeywords.add(tag); continue; } let queryMax = pageLimit * SortOptionsIterable.length; let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag for (let query = 0; query < queryMax; query++) { const page: number = fixedSortMode ? query + 1 : (query % pageLimit) + 1; const sortBy: SortOptions | undefined = fixedSortMode ? undefined : SortOptionsIterable[query % SortOptionsIterable.length]; if (!this.running) { throw new AbortTransaction(); } try { const response = await getDiscover({ page, sortBy, keywords: tag, }); await this.processResults(response, tag, type, em); await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS)); this.progress++; if (page === 1 && response.total_pages <= queryMax) { // We will finish the tag with less queries than expected, move progress accordingly this.progress += queryMax - response.total_pages; fixedSortMode = true; queryMax = response.total_pages; } } catch (error) { logger.error('Error processing keyword in blocklisted tags', { label: 'Blocklisted Tags Processor', keywordId: tag, errorMessage: error.message, }); } } } } if (invalidKeywords.size > 0) { const currentTags = blocklistedTagsArr.filter( (tag) => !invalidKeywords.has(tag) ); const cleanedTags = currentTags.join(','); if (cleanedTags !== blocklistedTags) { settings.main.blocklistedTags = cleanedTags; await settings.save(); logger.info('Cleaned up invalid keywords from settings', { label: 'Blocklisted Tags Processor', removedKeywords: Array.from(invalidKeywords), newBlocklistedTags: cleanedTags, }); } } } private async processResults( response: TmdbSearchMovieResponse | TmdbSearchTvResponse, keywordId: string, mediaType: MediaType, em: EntityManager ) { const blocklistRepository = em.getRepository(Blocklist); for (const entry of response.results) { const blocklistEntry = await blocklistRepository.findOne({ where: { tmdbId: entry.id, mediaType }, }); if (blocklistEntry) { // Don't mark manual blocklists with tags // If media wasn't previously blocklisted for this tag, add the tag to the media's blocklist if ( blocklistEntry.blocklistedTags && !blocklistEntry.blocklistedTags.includes(`,${keywordId},`) ) { await blocklistRepository.update(blocklistEntry.id, { blocklistedTags: `${blocklistEntry.blocklistedTags}${keywordId},`, }); } } else { // Media wasn't previously blocklisted, add it to the blocklist await Blocklist.addToBlocklist( { blocklistRequest: { mediaType, title: 'title' in entry ? entry.title : entry.name, tmdbId: entry.id, blocklistedTags: `,${keywordId},`, }, }, em ); } } } private async cleanBlocklist(em: EntityManager) { // Remove blocklist and media entries blocklisted by tags const mediaRepository = em.getRepository(Media); const mediaToRemove = await mediaRepository .createQueryBuilder('media') .innerJoinAndSelect( Blocklist, 'blist', 'blist.tmdbId = media.tmdbId AND blist.mediaType = media.mediaType' ) .where(`blist.blocklistedTags IS NOT NULL`) .getMany(); // Batch removes so the query doesn't get too large for (let i = 0; i < mediaToRemove.length; i += 500) { await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blocklist entries via cascading } } } const blocklistedTagsProcessor = new BlocklistedTagProcessor(); export default blocklistedTagsProcessor; ================================================ FILE: server/job/schedule.ts ================================================ import { MediaServerType } from '@server/constants/server'; import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor'; import availabilitySync from '@server/lib/availabilitySync'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; import refreshToken from '@server/lib/refreshToken'; import { jellyfinFullScanner, jellyfinRecentScanner, } from '@server/lib/scanners/jellyfin'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { radarrScanner } from '@server/lib/scanners/radarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr'; import type { JobId } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import watchlistSync from '@server/lib/watchlistsync'; import logger from '@server/logger'; import schedule from 'node-schedule'; interface ScheduledJob { id: JobId; job: schedule.Job; name: string; type: 'process' | 'command'; interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed'; cronSchedule: string; running?: () => boolean; cancelFn?: () => void; } export const scheduledJobs: ScheduledJob[] = []; export const startJobs = (): void => { const jobs = getSettings().jobs; const mediaServerType = getSettings().main.mediaServerType; if (mediaServerType === MediaServerType.PLEX) { // Run recently added plex scan every 5 minutes scheduledJobs.push({ id: 'plex-recently-added-scan', name: 'Plex Recently Added Scan', type: 'process', interval: 'minutes', cronSchedule: jobs['plex-recently-added-scan'].schedule, job: schedule.scheduleJob( jobs['plex-recently-added-scan'].schedule, () => { logger.info('Starting scheduled job: Plex Recently Added Scan', { label: 'Jobs', }); plexRecentScanner.run(); } ), running: () => plexRecentScanner.status().running, cancelFn: () => plexRecentScanner.cancel(), }); // Run full plex scan every 24 hours scheduledJobs.push({ id: 'plex-full-scan', name: 'Plex Full Library Scan', type: 'process', interval: 'hours', cronSchedule: jobs['plex-full-scan'].schedule, job: schedule.scheduleJob(jobs['plex-full-scan'].schedule, () => { logger.info('Starting scheduled job: Plex Full Library Scan', { label: 'Jobs', }); plexFullScanner.run(); }), running: () => plexFullScanner.status().running, cancelFn: () => plexFullScanner.cancel(), }); scheduledJobs.push({ id: 'plex-refresh-token', name: 'Plex Refresh Token', type: 'process', interval: 'fixed', cronSchedule: jobs['plex-refresh-token'].schedule, job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => { logger.info('Starting scheduled job: Plex Refresh Token', { label: 'Jobs', }); refreshToken.run(); }), }); // Watchlist Sync scheduledJobs.push({ id: 'plex-watchlist-sync', name: 'Plex Watchlist Sync', type: 'process', interval: 'seconds', cronSchedule: jobs['plex-watchlist-sync'].schedule, job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => { logger.info('Starting scheduled job: Plex Watchlist Sync', { label: 'Jobs', }); watchlistSync.syncWatchlist(); }), }); } else if ( mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY ) { // Run recently added jellyfin sync every 5 minutes scheduledJobs.push({ id: 'jellyfin-recently-added-scan', name: 'Jellyfin Recently Added Scan', type: 'process', interval: 'minutes', cronSchedule: jobs['jellyfin-recently-added-scan'].schedule, job: schedule.scheduleJob( jobs['jellyfin-recently-added-scan'].schedule, () => { logger.info('Starting scheduled job: Jellyfin Recently Added Scan', { label: 'Jobs', }); jellyfinRecentScanner.run(); } ), running: () => jellyfinRecentScanner.status().running, cancelFn: () => jellyfinRecentScanner.cancel(), }); // Run full jellyfin sync every 24 hours scheduledJobs.push({ id: 'jellyfin-full-scan', name: 'Jellyfin Full Library Scan', type: 'process', interval: 'hours', cronSchedule: jobs['jellyfin-full-scan'].schedule, job: schedule.scheduleJob(jobs['jellyfin-full-scan'].schedule, () => { logger.info('Starting scheduled job: Jellyfin Full Scan', { label: 'Jobs', }); jellyfinFullScanner.run(); }), running: () => jellyfinFullScanner.status().running, cancelFn: () => jellyfinFullScanner.cancel(), }); } // Run full radarr scan every 24 hours scheduledJobs.push({ id: 'radarr-scan', name: 'Radarr Scan', type: 'process', interval: 'hours', cronSchedule: jobs['radarr-scan'].schedule, job: schedule.scheduleJob(jobs['radarr-scan'].schedule, () => { logger.info('Starting scheduled job: Radarr Scan', { label: 'Jobs' }); radarrScanner.run(); }), running: () => radarrScanner.status().running, cancelFn: () => radarrScanner.cancel(), }); // Run full sonarr scan every 24 hours scheduledJobs.push({ id: 'sonarr-scan', name: 'Sonarr Scan', type: 'process', interval: 'hours', cronSchedule: jobs['sonarr-scan'].schedule, job: schedule.scheduleJob(jobs['sonarr-scan'].schedule, () => { logger.info('Starting scheduled job: Sonarr Scan', { label: 'Jobs' }); sonarrScanner.run(); }), running: () => sonarrScanner.status().running, cancelFn: () => sonarrScanner.cancel(), }); // Checks if media is still available in plex/sonarr/radarr libs scheduledJobs.push({ id: 'availability-sync', name: 'Media Availability Sync', type: 'process', interval: 'hours', cronSchedule: jobs['availability-sync'].schedule, job: schedule.scheduleJob(jobs['availability-sync'].schedule, () => { logger.info('Starting scheduled job: Media Availability Sync', { label: 'Jobs', }); availabilitySync.run(); }), running: () => availabilitySync.running, cancelFn: () => availabilitySync.cancel(), }); // Run download sync every minute scheduledJobs.push({ id: 'download-sync', name: 'Download Sync', type: 'command', interval: 'seconds', cronSchedule: jobs['download-sync'].schedule, job: schedule.scheduleJob(jobs['download-sync'].schedule, () => { logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs', }); downloadTracker.updateDownloads(); }), }); // Reset download sync everyday at 01:00 am scheduledJobs.push({ id: 'download-sync-reset', name: 'Download Sync Reset', type: 'command', interval: 'hours', cronSchedule: jobs['download-sync-reset'].schedule, job: schedule.scheduleJob(jobs['download-sync-reset'].schedule, () => { logger.info('Starting scheduled job: Download Sync Reset', { label: 'Jobs', }); downloadTracker.resetDownloadTracker(); }), }); // Run image cache cleanup every 24 hours scheduledJobs.push({ id: 'image-cache-cleanup', name: 'Image Cache Cleanup', type: 'process', interval: 'hours', cronSchedule: jobs['image-cache-cleanup'].schedule, job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => { logger.info('Starting scheduled job: Image Cache Cleanup', { label: 'Jobs', }); // Clean TMDB image cache ImageProxy.clearCache('tmdb'); // Clean users avatar image cache ImageProxy.clearCache('avatar'); }), }); scheduledJobs.push({ id: 'process-blocklisted-tags', name: 'Process Blocklisted Tags', type: 'process', interval: 'days', cronSchedule: jobs['process-blocklisted-tags'].schedule, job: schedule.scheduleJob(jobs['process-blocklisted-tags'].schedule, () => { logger.info('Starting scheduled job: Process Blocklisted Tags', { label: 'Jobs', }); blocklistedTagsProcessor.run(); }), running: () => blocklistedTagsProcessor.status().running, cancelFn: () => blocklistedTagsProcessor.cancel(), }); logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; ================================================ FILE: server/lib/availabilitySync.ts ================================================ import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin'; import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import type Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { getHostname } from '@server/utils/getHostname'; class AvailabilitySync { public running = false; private plexClient: PlexAPI; private plexSeasonsCache: Record; private jellyfinClient: JellyfinAPI; private jellyfinSeasonsCache: Record; private sonarrSeasonsCache: Record; private radarrServers: RadarrSettings[]; private sonarrServers: SonarrSettings[]; async run() { const settings = getSettings(); const mediaServerType = getSettings().main.mediaServerType; this.running = true; this.plexSeasonsCache = {}; this.jellyfinSeasonsCache = {}; this.sonarrSeasonsCache = {}; this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); try { logger.info(`Starting availability sync...`, { label: 'Availability Sync', }); const pageSize = 50; const userRepository = getRepository(User); // If it is plex admin is selected using plexToken if jellyfin admin is selected using jellyfinUserID let admin = null; if (mediaServerType === MediaServerType.PLEX) { admin = await userRepository.findOne({ select: { id: true, plexToken: true }, where: { id: 1 }, }); } else if ( mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY ) { admin = await userRepository.findOne({ where: { id: 1 }, select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], order: { id: 'ASC' }, }); } switch (mediaServerType) { case MediaServerType.PLEX: if (admin && admin.plexToken) { this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); } else { logger.error('Plex admin is not configured.'); } break; case MediaServerType.JELLYFIN: case MediaServerType.EMBY: if (admin) { this.jellyfinClient = new JellyfinAPI( getHostname(), settings.jellyfin.apiKey, admin.jellyfinDeviceId ); this.jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); try { await this.jellyfinClient.getSystemInfo(); } catch (e) { logger.error('Sync interrupted.', { label: 'AvailabilitySync', status: e.statusCode, error: e.name, errorMessage: e.errorCode, }); this.running = false; return; } } else { logger.error('Jellyfin admin is not configured.'); this.running = false; return; } break; default: logger.error('An admin is not configured.'); this.running = false; return; } for await (const media of this.loadAvailableMediaPaginated(pageSize)) { if (!this.running) { throw new Error('Job aborted'); } // Check plex, radarr, and sonarr for that specific media and // if unavailable, then we change the status accordingly. // If a non-4k or 4k version exists in at least one of the instances, we will only update that specific version if (media.mediaType === 'movie') { let movieExists = false; let movieExists4k = false; // if (mediaServerType === MediaServerType.PLEX) { // await this.mediaExistsInPlex(media, false); // } else if ( // mediaServerType === MediaServerType.JELLYFIN || // mediaServerType === MediaServerType.EMBY // ) { // await this.mediaExistsInJellyfin(media, false); // } const existsInRadarr = await this.mediaExistsInRadarr(media, false); const existsInRadarr4k = await this.mediaExistsInRadarr(media, true); // plex if (mediaServerType === MediaServerType.PLEX) { const { existsInPlex } = await this.mediaExistsInPlex(media, false); const { existsInPlex: existsInPlex4k } = await this.mediaExistsInPlex(media, true); if (existsInPlex || existsInRadarr) { movieExists = true; logger.info( `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } if (existsInPlex4k || existsInRadarr4k) { movieExists4k = true; logger.info( `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } } //jellyfin if ( mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY ) { const { existsInJellyfin } = await this.mediaExistsInJellyfin( media, false ); const { existsInJellyfin: existsInJellyfin4k } = await this.mediaExistsInJellyfin(media, true); if (existsInJellyfin || existsInRadarr) { movieExists = true; logger.info( `The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } if (existsInJellyfin4k || existsInRadarr4k) { movieExists4k = true; logger.info( `The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } } if (!movieExists && media.status === MediaStatus.AVAILABLE) { await this.mediaUpdater(media, false, mediaServerType); } if (!movieExists4k && media.status4k === MediaStatus.AVAILABLE) { await this.mediaUpdater(media, true, mediaServerType); } } // If both versions still exist in plex, we still need // to check through sonarr to verify season availability if (media.mediaType === 'tv') { let showExists = false; let showExists4k = false; //plex const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = await this.mediaExistsInPlex(media, false); const { existsInPlex: existsInPlex4k, seasonsMap: plexSeasonsMap4k = new Map(), } = await this.mediaExistsInPlex(media, true); //jellyfin const { existsInJellyfin, seasonsMap: jellyfinSeasonsMap = new Map(), } = await this.mediaExistsInJellyfin(media, false); const { existsInJellyfin: existsInJellyfin4k, seasonsMap: jellyfinSeasonsMap4k = new Map(), } = await this.mediaExistsInJellyfin(media, true); const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = await this.mediaExistsInSonarr(media, false); const { existsInSonarr: existsInSonarr4k, seasonsMap: sonarrSeasonsMap4k, } = await this.mediaExistsInSonarr(media, true); //plex if (mediaServerType === MediaServerType.PLEX) { if (existsInPlex || existsInSonarr) { showExists = true; logger.info( `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } } if (mediaServerType === MediaServerType.PLEX) { if (existsInPlex4k || existsInSonarr4k) { showExists4k = true; logger.info( `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } } //jellyfin if ( mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY ) { if (existsInJellyfin || existsInSonarr) { showExists = true; logger.info( `The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } } if ( mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY ) { if (existsInJellyfin4k || existsInSonarr4k) { showExists4k = true; logger.info( `The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`, { label: 'AvailabilitySync', } ); } } // Here we will create a final map that will cross compare // with plex and sonarr. Filtered seasons will go through // each season and assume the season does not exist. If Plex or // Sonarr finds that season, we will change the final seasons value // to true. const filteredSeasonsMap: Map = new Map(); media.seasons .filter( (season) => season.status === MediaStatus.AVAILABLE || season.status === MediaStatus.PARTIALLY_AVAILABLE ) .forEach((season) => filteredSeasonsMap.set(season.seasonNumber, false) ); const filteredSeasonsMap4k: Map = new Map(); media.seasons .filter( (season) => season.status4k === MediaStatus.AVAILABLE || season.status4k === MediaStatus.PARTIALLY_AVAILABLE ) .forEach((season) => filteredSeasonsMap4k.set(season.seasonNumber, false) ); let finalSeasons: Map; let finalSeasons4k: Map; if (mediaServerType === MediaServerType.PLEX) { finalSeasons = new Map([ ...filteredSeasonsMap, ...plexSeasonsMap, ...sonarrSeasonsMap, ]); finalSeasons4k = new Map([ ...filteredSeasonsMap4k, ...plexSeasonsMap4k, ...sonarrSeasonsMap4k, ]); } else { // Jellyfin/Emby finalSeasons = new Map([ ...filteredSeasonsMap, ...jellyfinSeasonsMap, ...sonarrSeasonsMap, ]); finalSeasons4k = new Map([ ...filteredSeasonsMap4k, ...jellyfinSeasonsMap4k, ...sonarrSeasonsMap4k, ]); } if ( !showExists && (media.status === MediaStatus.AVAILABLE || media.status === MediaStatus.PARTIALLY_AVAILABLE || media.seasons.some( (season) => season.status === MediaStatus.AVAILABLE ) || media.seasons.some( (season) => season.status === MediaStatus.PARTIALLY_AVAILABLE )) ) { await this.mediaUpdater(media, false, mediaServerType); } if ( !showExists4k && (media.status4k === MediaStatus.AVAILABLE || media.status4k === MediaStatus.PARTIALLY_AVAILABLE || media.seasons.some( (season) => season.status4k === MediaStatus.AVAILABLE ) || media.seasons.some( (season) => season.status4k === MediaStatus.PARTIALLY_AVAILABLE )) ) { await this.mediaUpdater(media, true, mediaServerType); } // TODO: Figure out how to run seasonUpdater for each season if ([...finalSeasons.values()].includes(false)) { await this.seasonUpdater( media, finalSeasons, false, mediaServerType ); } if ([...finalSeasons4k.values()].includes(false)) { await this.seasonUpdater( media, finalSeasons4k, true, mediaServerType ); } } } } catch (ex) { logger.error('Failed to complete availability sync.', { errorMessage: ex.message, label: 'Availability Sync', }); } finally { logger.info(`Availability sync complete.`, { label: 'Availability Sync', }); this.running = false; } } public cancel() { this.running = false; } private async *loadAvailableMediaPaginated(pageSize: number) { let offset = 0; const mediaRepository = getRepository(Media); const whereOptions = [ { status: MediaStatus.AVAILABLE }, { status: MediaStatus.PARTIALLY_AVAILABLE }, { status4k: MediaStatus.AVAILABLE }, { status4k: MediaStatus.PARTIALLY_AVAILABLE }, { seasons: { status: MediaStatus.AVAILABLE } }, { seasons: { status: MediaStatus.PARTIALLY_AVAILABLE } }, { seasons: { status4k: MediaStatus.AVAILABLE } }, { seasons: { status4k: MediaStatus.PARTIALLY_AVAILABLE } }, ]; let mediaPage: Media[]; do { yield* (mediaPage = await mediaRepository.find({ where: whereOptions, skip: offset, take: pageSize, })); offset += pageSize; } while (mediaPage.length > 0); } private async mediaUpdater( media: Media, is4k: boolean, mediaServerType: MediaServerType ): Promise { const mediaRepository = getRepository(Media); try { // If media type is tv, check if a season is processing // to see if we need to keep the external metadata let isMediaProcessing = false; if (media.mediaType === 'tv') { const requestRepository = getRepository(MediaRequest); const request = await requestRepository .createQueryBuilder('request') .leftJoinAndSelect('request.media', 'media') .where('(media.id = :id)', { id: media.id, }) .andWhere( '(request.is4k = :is4k AND request.status = :requestStatus)', { requestStatus: MediaRequestStatus.APPROVED, is4k: is4k, } ) .getOne(); if (request) { isMediaProcessing = true; } } // Set the non-4K or 4K media to deleted // and change related columns to null if media // is not processing media[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED; media[is4k ? 'serviceId4k' : 'serviceId'] = isMediaProcessing ? media[is4k ? 'serviceId4k' : 'serviceId'] : null; media[is4k ? 'externalServiceId4k' : 'externalServiceId'] = isMediaProcessing ? media[is4k ? 'externalServiceId4k' : 'externalServiceId'] : null; media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = isMediaProcessing ? media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] : null; if (mediaServerType === MediaServerType.PLEX) { media[is4k ? 'ratingKey4k' : 'ratingKey'] = isMediaProcessing ? media[is4k ? 'ratingKey4k' : 'ratingKey'] : null; } else if ( mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY ) { media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] = isMediaProcessing ? media[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] : null; } logger.info( `The ${is4k ? '4K' : 'non-4K'} ${ media.mediaType === 'movie' ? 'movie' : 'show' } [TMDB ID ${media.tmdbId}] was not found in any ${ media.mediaType === 'movie' ? 'Radarr' : 'Sonarr' } and ${ mediaServerType === MediaServerType.PLEX ? 'plex' : mediaServerType === MediaServerType.JELLYFIN ? 'jellyfin' : 'emby' } instance. Status will be changed to deleted.`, { label: 'AvailabilitySync' } ); await mediaRepository.save(media); } catch (ex) { logger.debug( `Failure updating the ${is4k ? '4K' : 'non-4K'} ${ media.mediaType === 'tv' ? 'show' : 'movie' } [TMDB ID ${media.tmdbId}].`, { errorMessage: ex.message, label: 'Availability Sync', } ); } } private async seasonUpdater( media: Media, seasons: Map, is4k: boolean, mediaServerType: MediaServerType ): Promise { const mediaRepository = getRepository(Media); // Filter out only the values that are false // (media that should be deleted) const seasonsPendingRemoval = new Map( // Disabled linter as only the value is needed from the filter // eslint-disable-next-line @typescript-eslint/no-unused-vars [...seasons].filter(([_, exists]) => !exists) ); // Retrieve the season keys to pass into our log const seasonKeys = [...seasonsPendingRemoval.keys()]; // let isSeasonRemoved = false; try { for (const mediaSeason of media.seasons) { if (seasonsPendingRemoval.has(mediaSeason.seasonNumber)) { mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED; } } if (media.status === MediaStatus.AVAILABLE && !is4k) { media.status = MediaStatus.PARTIALLY_AVAILABLE; logger.info( `Marking the non-4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`, { label: 'Availability Sync' } ); } if (media.status4k === MediaStatus.AVAILABLE && is4k) { media.status4k = MediaStatus.PARTIALLY_AVAILABLE; logger.info( `Marking the 4K show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season removal has occurred.`, { label: 'Availability Sync' } ); } media.lastSeasonChange = new Date(); await mediaRepository.save(media); logger.info( `The ${is4k ? '4K' : 'non-4K'} season(s) [${seasonKeys}] [TMDB ID ${ media.tmdbId }] was not found in any ${ media.mediaType === 'tv' ? 'Sonarr' : 'Radarr' } and ${ mediaServerType === MediaServerType.PLEX ? 'plex' : mediaServerType === MediaServerType.JELLYFIN ? 'jellyfin' : 'emby' } instance. Status will be changed to deleted.`, { label: 'AvailabilitySync' } ); } catch (ex) { logger.debug( `Failure updating the ${ is4k ? '4K' : 'non-4K' } season(s) [${seasonKeys}], TMDB ID ${media.tmdbId}.`, { errorMessage: ex.message, label: 'Availability Sync', } ); } } private async mediaExistsInRadarr( media: Media, is4k: boolean ): Promise { let existsInRadarr = false; const hasSameServerInBothModes = this.radarrServers.some((a) => this.radarrServers.some( (b) => a.is4k !== b.is4k && a.hostname === b.hostname && a.port === b.port ) ); // Check for availability in all of the available radarr servers // If any find the media, we will assume the media exists for (const server of this.radarrServers.filter( (server) => server.is4k === is4k )) { const radarrAPI = new RadarrAPI({ apiKey: server.apiKey, url: RadarrAPI.buildUrl(server, '/api/v3'), }); try { let radarr: RadarrMovie | undefined; if (media.externalServiceId && !is4k) { radarr = await radarrAPI.getMovie({ id: media.externalServiceId, }); } if (media.externalServiceId4k && is4k) { radarr = await radarrAPI.getMovie({ id: media.externalServiceId4k, }); } if (radarr && radarr.hasFile) { const resolution = radarr?.movieFile?.mediaInfo?.resolution?.split('x'); const is4kMovie = resolution?.length === 2 && Number(resolution[0]) >= 2000; if (hasSameServerInBothModes && resolution?.length === 2) { // Same server in both modes then use resolution to distinguish existsInRadarr = is4k ? is4kMovie : !is4kMovie; } else { // One server type and if file exists, count it existsInRadarr = true; } } } catch (ex) { if (!ex.message.includes('404')) { existsInRadarr = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${ media.tmdbId }] from Radarr.`, { errorMessage: ex.message, label: 'Availability Sync', } ); } } if (existsInRadarr) break; } return existsInRadarr; } private async mediaExistsInSonarr( media: Media, is4k: boolean ): Promise<{ existsInSonarr: boolean; seasonsMap: Map }> { let existsInSonarr = false; let preventSeasonSearch = false; // Check for availability in all of the available sonarr servers // If any find the media, we will assume the media exists for (const server of this.sonarrServers.filter((server) => { return server.is4k === is4k; })) { const sonarrAPI = new SonarrAPI({ apiKey: server.apiKey, url: SonarrAPI.buildUrl(server, '/api/v3'), }); try { let sonarr: SonarrSeries | undefined; if (media.externalServiceId && !is4k) { sonarr = await sonarrAPI.getSeriesById(media.externalServiceId); this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`] = sonarr.seasons; } if (media.externalServiceId4k && is4k) { sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k); this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`] = sonarr.seasons; } if (sonarr && sonarr.statistics.episodeFileCount > 0) { existsInSonarr = true; } } catch (ex) { if (!ex.message.includes('404')) { existsInSonarr = true; preventSeasonSearch = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${ media.tmdbId }] from Sonarr.`, { errorMessage: ex.message, label: 'Availability Sync', } ); } } } // Here we check each season for availability // If the API returns an error other than a 404, // we will have to prevent the season check from happening const seasonsMap: Map = new Map(); if (!preventSeasonSearch) { const filteredSeasons = media.seasons.filter( (season) => season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE ); for (const season of filteredSeasons) { const seasonExists = await this.seasonExistsInSonarr( media, season, is4k ); if (seasonExists) { seasonsMap.set(season.seasonNumber, true); } } } return { existsInSonarr, seasonsMap }; } private async seasonExistsInSonarr( media: Media, season: Season, is4k: boolean ): Promise { let seasonExists = false; // Check each sonarr instance to see if the media still exists // If found, we will assume the media exists and prevent removal // We can use the cache we built when we fetched the series with mediaExistsInSonarr for (const server of this.sonarrServers.filter( (server) => server.is4k === is4k )) { let sonarrSeasons: SonarrSeason[] | undefined; if (media.externalServiceId && !is4k) { sonarrSeasons = this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`]; } if (media.externalServiceId4k && is4k) { sonarrSeasons = this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`]; } const seasonIsAvailable = sonarrSeasons?.find( ({ seasonNumber, statistics }) => season.seasonNumber === seasonNumber && statistics?.episodeFileCount && statistics?.episodeFileCount > 0 ); if (seasonIsAvailable && sonarrSeasons) { seasonExists = true; } } return seasonExists; } // Plex private async mediaExistsInPlex( media: Media, is4k: boolean ): Promise<{ existsInPlex: boolean; seasonsMap?: Map }> { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; let existsInPlex = false; let preventSeasonSearch = false; // Check each plex instance to see if the media still exists // If found, we will assume the media exists and prevent removal // We can use the cache we built when we fetched the series with mediaExistsInPlex try { let plexMedia: PlexMetadata | undefined; if (ratingKey && !is4k) { plexMedia = await this.plexClient?.getMetadata(ratingKey); if (media.mediaType === 'tv') { this.plexSeasonsCache[ratingKey] = await this.plexClient?.getChildrenMetadata(ratingKey); } } if (ratingKey4k && is4k) { plexMedia = await this.plexClient?.getMetadata(ratingKey4k); if (media.mediaType === 'tv') { this.plexSeasonsCache[ratingKey4k] = await this.plexClient?.getChildrenMetadata(ratingKey4k); } if (plexMedia) { if (ratingKey === ratingKey4k) { plexMedia = undefined; } if ( plexMedia && media.mediaType === 'movie' && !plexMedia.Media?.some( (mediaItem) => (mediaItem.width ?? 0) >= 2000 ) ) { plexMedia = undefined; } if (plexMedia && media.mediaType === 'tv') { const cachedSeasons = this.plexSeasonsCache[ratingKey4k]; if (cachedSeasons?.length) { let has4kInAnySeason = false; for (const season of cachedSeasons) { try { const episodes = await this.plexClient?.getChildrenMetadata( season.ratingKey ); const has4kEpisode = episodes?.some((episode) => episode.Media?.some( (mediaItem) => (mediaItem.width ?? 0) >= 2000 ) ); if (has4kEpisode) { has4kInAnySeason = true; break; } } catch { // If we can't fetch episodes for a season, continue checking other seasons } } if (!has4kInAnySeason) { plexMedia = undefined; } } } } } if (plexMedia) { existsInPlex = true; } } catch (ex) { if (!ex.message.includes('404')) { existsInPlex = true; preventSeasonSearch = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ media.mediaType === 'tv' ? 'show' : 'movie' } [TMDB ID ${media.tmdbId}] from Plex.`, { errorMessage: ex.message, label: 'Availability Sync', } ); } } // Here we check each season in plex for availability // If the API returns an error other than a 404, // we will have to prevent the season check from happening if (media.mediaType === 'tv') { const seasonsMap: Map = new Map(); if (!preventSeasonSearch) { const filteredSeasons = media.seasons.filter( (season) => season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE ); for (const season of filteredSeasons) { const seasonExists = await this.seasonExistsInPlex( media, season, is4k ); if (seasonExists) { seasonsMap.set(season.seasonNumber, true); } } } return { existsInPlex, seasonsMap }; } return { existsInPlex }; } private async seasonExistsInPlex( media: Media, season: Season, is4k: boolean ): Promise { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; let seasonExistsInPlex = false; // Check each plex instance to see if the season exists let plexSeasons: PlexMetadata[] | undefined; if (ratingKey && !is4k) { plexSeasons = this.plexSeasonsCache[ratingKey]; } if (ratingKey4k && is4k) { plexSeasons = this.plexSeasonsCache[ratingKey4k]; } const seasonIsAvailable = plexSeasons?.find( (plexSeason) => plexSeason.index === season.seasonNumber ); if (seasonIsAvailable) { seasonExistsInPlex = true; } return seasonExistsInPlex; } // Jellyfin private async mediaExistsInJellyfin( media: Media, is4k: boolean ): Promise<{ existsInJellyfin: boolean; seasonsMap?: Map }> { const ratingKey = media.jellyfinMediaId; const ratingKey4k = media.jellyfinMediaId4k; let existsInJellyfin = false; let preventSeasonSearch = false; // Check each jellyfin instance to see if the media still exists // If found, we will assume the media exists and prevent removal // We can use the cache we built when we fetched the series with mediaExistsInJellyfin try { let jellyfinMedia: JellyfinLibraryItem | undefined; if (ratingKey && !is4k) { jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey); if (media.mediaType === 'tv' && jellyfinMedia !== undefined) { this.jellyfinSeasonsCache[ratingKey] = await this.jellyfinClient?.getSeasons(ratingKey); } } if (ratingKey4k && is4k) { jellyfinMedia = await this.jellyfinClient?.getItemData(ratingKey4k); if (media.mediaType === 'tv' && jellyfinMedia !== undefined) { this.jellyfinSeasonsCache[ratingKey4k] = await this.jellyfinClient?.getSeasons(ratingKey4k); } } if (jellyfinMedia) { existsInJellyfin = true; } } catch (ex) { if (!ex.message.includes('404') && !ex.message.includes('500')) { existsInJellyfin = true; preventSeasonSearch = true; logger.debug( `Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${ media.mediaType === 'tv' ? 'show' : 'movie' } [TMDB ID ${media.tmdbId}] from Jellyfin.`, { errorMessage: ex.message, label: 'AvailabilitySync', } ); } } // Here we check each season in jellyfin for availability // If the API returns an error other than a 404, // we will have to prevent the season check from happening if (media.mediaType === 'tv') { const seasonsMap: Map = new Map(); if (!preventSeasonSearch) { const filteredSeasons = media.seasons.filter( (season) => season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === MediaStatus.PARTIALLY_AVAILABLE ); for (const season of filteredSeasons) { const seasonExists = await this.seasonExistsInJellyfin( media, season, is4k ); if (seasonExists) { seasonsMap.set(season.seasonNumber, true); } } } return { existsInJellyfin, seasonsMap }; } return { existsInJellyfin }; } private async seasonExistsInJellyfin( media: Media, season: Season, is4k: boolean ): Promise { const ratingKey = media.jellyfinMediaId; const ratingKey4k = media.jellyfinMediaId4k; let seasonExistsInJellyfin = false; // Check each jellyfin instance to see if the season exists let jellyfinSeasons: JellyfinLibraryItem[] | undefined; if (ratingKey && !is4k) { jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey]; } if (ratingKey4k && is4k) { jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey4k]; } const seasonIsAvailable = jellyfinSeasons?.find( (jellyfinSeason) => jellyfinSeason.IndexNumber === season.seasonNumber ); if (seasonIsAvailable) { seasonExistsInJellyfin = true; } return seasonExistsInJellyfin; } } const availabilitySync = new AvailabilitySync(); export default availabilitySync; ================================================ FILE: server/lib/cache.ts ================================================ import NodeCache from 'node-cache'; export type AvailableCacheIds = | 'tmdb' | 'radarr' | 'sonarr' | 'rt' | 'imdb' | 'github' | 'plexguid' | 'plextv' | 'plexwatchlist' | 'tvdb'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; class Cache { public id: AvailableCacheIds; public data: NodeCache; public name: string; constructor( id: AvailableCacheIds, name: string, options: { stdTtl?: number; checkPeriod?: number } = {} ) { this.id = id; this.name = name; this.data = new NodeCache({ stdTTL: options.stdTtl ?? DEFAULT_TTL, checkperiod: options.checkPeriod ?? DEFAULT_CHECK_PERIOD, }); } public getStats() { return this.data.getStats(); } public flush(): void { this.data.flushAll(); } } class CacheManager { private availableCaches: Record = { tmdb: new Cache('tmdb', 'The Movie Database API', { stdTtl: 21600, checkPeriod: 60 * 30, }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), rt: new Cache('rt', 'Rotten Tomatoes API', { stdTtl: 43200, checkPeriod: 60 * 30, }), imdb: new Cache('imdb', 'IMDB Radarr Proxy', { stdTtl: 43200, checkPeriod: 60 * 30, }), github: new Cache('github', 'GitHub API', { stdTtl: 21600, checkPeriod: 60 * 30, }), plexguid: new Cache('plexguid', 'Plex GUID', { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60 * 30, }), plextv: new Cache('plextv', 'Plex TV', { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60, }), plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'), tvdb: new Cache('tvdb', 'The TVDB API', { stdTtl: 21600, checkPeriod: 60 * 30, }), }; public getCache(id: AvailableCacheIds): Cache { return this.availableCaches[id]; } public getAllCaches(): Record { return this.availableCaches; } } const cacheManager = new CacheManager(); export default cacheManager; ================================================ FILE: server/lib/downloadtracker.ts ================================================ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaType } from '@server/constants/media'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { uniqWith } from 'lodash'; interface EpisodeNumberResult { seasonNumber: number; episodeNumber: number; absoluteEpisodeNumber: number; id: number; } export interface DownloadingItem { mediaType: MediaType; externalId: number; size: number; sizeLeft: number; status: string; timeLeft: string; estimatedCompletionTime: Date; title: string; downloadId: string; episode?: EpisodeNumberResult; } class DownloadTracker { private radarrServers: Record = {}; private sonarrServers: Record = {}; public getMovieProgress( serverId: number, externalServiceId: number ): DownloadingItem[] { if (!this.radarrServers[serverId]) { return []; } return this.radarrServers[serverId].filter( (item) => item.externalId === externalServiceId ); } public getSeriesProgress( serverId: number, externalServiceId: number ): DownloadingItem[] { if (!this.sonarrServers[serverId]) { return []; } return this.sonarrServers[serverId].filter( (item) => item.externalId === externalServiceId ); } public async resetDownloadTracker() { this.radarrServers = {}; this.sonarrServers = {}; } public updateDownloads() { this.updateRadarrDownloads(); this.updateSonarrDownloads(); } private async updateRadarrDownloads() { const settings = getSettings(); // Remove duplicate servers const filteredServers = uniqWith(settings.radarr, (radarrA, radarrB) => { return ( radarrA.hostname === radarrB.hostname && radarrA.port === radarrB.port && radarrA.baseUrl === radarrB.baseUrl ); }); // Load downloads from Radarr servers Promise.all( filteredServers.map(async (server) => { if (server.syncEnabled) { const radarr = new RadarrAPI({ apiKey: server.apiKey, url: RadarrAPI.buildUrl(server, '/api/v3'), }); try { await radarr.refreshMonitoredDownloads(); const queueItems = await radarr.getQueue(); this.radarrServers[server.id] = queueItems.map((item) => ({ externalId: item.movieId, estimatedCompletionTime: new Date(item.estimatedCompletionTime), mediaType: MediaType.MOVIE, size: item.size, sizeLeft: item.sizeleft, status: item.status, timeLeft: item.timeleft, title: item.title, downloadId: item.downloadId, })); if (queueItems.length > 0) { logger.debug( `Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`, { label: 'Download Tracker' } ); } } catch { logger.error( `Unable to get queue from Radarr server: ${server.name}`, { label: 'Download Tracker', } ); } // Duplicate this data to matching servers const matchingServers = settings.radarr.filter( (rs) => rs.hostname === server.hostname && rs.port === server.port && rs.baseUrl === server.baseUrl && rs.id !== server.id ); if (matchingServers.length > 0) { logger.debug( `Matching download data to ${matchingServers.length} other Radarr server(s)`, { label: 'Download Tracker' } ); } matchingServers.forEach((ms) => { if (ms.syncEnabled) { this.radarrServers[ms.id] = this.radarrServers[server.id]; } }); } }) ); } private async updateSonarrDownloads() { const settings = getSettings(); // Remove duplicate servers const filteredServers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => { return ( sonarrA.hostname === sonarrB.hostname && sonarrA.port === sonarrB.port && sonarrA.baseUrl === sonarrB.baseUrl ); }); // Load downloads from Sonarr servers Promise.all( filteredServers.map(async (server) => { if (server.syncEnabled) { const sonarr = new SonarrAPI({ apiKey: server.apiKey, url: SonarrAPI.buildUrl(server, '/api/v3'), }); try { await sonarr.refreshMonitoredDownloads(); const queueItems = await sonarr.getQueue(); this.sonarrServers[server.id] = queueItems.map((item) => ({ externalId: item.seriesId, estimatedCompletionTime: new Date(item.estimatedCompletionTime), mediaType: MediaType.TV, size: item.size, sizeLeft: item.sizeleft, status: item.status, timeLeft: item.timeleft, title: item.title, episode: item.episode, downloadId: item.downloadId, })); if (queueItems.length > 0) { logger.debug( `Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`, { label: 'Download Tracker' } ); } } catch { logger.error( `Unable to get queue from Sonarr server: ${server.name}`, { label: 'Download Tracker', } ); } // Duplicate this data to matching servers const matchingServers = settings.sonarr.filter( (ss) => ss.hostname === server.hostname && ss.port === server.port && ss.baseUrl === server.baseUrl && ss.id !== server.id ); if (matchingServers.length > 0) { logger.debug( `Matching download data to ${matchingServers.length} other Sonarr server(s)`, { label: 'Download Tracker' } ); } matchingServers.forEach((ms) => { if (ms.syncEnabled) { this.sonarrServers[ms.id] = this.sonarrServers[server.id]; } }); } }) ); } } const downloadTracker = new DownloadTracker(); export default downloadTracker; ================================================ FILE: server/lib/email/index.ts ================================================ import type { NotificationAgentEmail } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import Email from 'email-templates'; import nodemailer from 'nodemailer'; import { URL } from 'url'; import { openpgpEncrypt } from './openpgpEncrypt'; class PreparedEmail extends Email { public constructor(settings: NotificationAgentEmail, pgpKey?: string) { const { applicationUrl } = getSettings().main; const transport = nodemailer.createTransport({ name: applicationUrl ? new URL(applicationUrl).hostname : undefined, host: settings.options.smtpHost, port: settings.options.smtpPort, secure: settings.options.secure, ignoreTLS: settings.options.ignoreTls, requireTLS: settings.options.requireTls, tls: settings.options.allowSelfSigned ? { rejectUnauthorized: false, } : undefined, auth: settings.options.authUser && settings.options.authPass ? { user: settings.options.authUser, pass: settings.options.authPass, } : undefined, }); if (pgpKey) { transport.use( 'stream', openpgpEncrypt({ signingKey: settings.options.pgpPrivateKey, password: settings.options.pgpPassword, encryptionKeys: [pgpKey], }) ); } super({ message: { from: { name: settings.options.senderName, address: settings.options.emailFrom, }, }, send: true, transport: transport, preview: false, }); } } export default PreparedEmail; ================================================ FILE: server/lib/email/openpgpEncrypt.ts ================================================ import logger from '@server/logger'; import { randomBytes } from 'crypto'; import * as openpgp from 'openpgp'; import type { TransformCallback } from 'stream'; import { Transform } from 'stream'; interface EncryptorOptions { signingKey?: string; password?: string; encryptionKeys: string[]; } class PGPEncryptor extends Transform { private _messageChunks: Uint8Array[] = []; private _messageLength = 0; private _signingKey?: string; private _password?: string; private _encryptionKeys: string[]; constructor(options: EncryptorOptions) { super(); this._signingKey = options.signingKey; this._password = options.password; this._encryptionKeys = options.encryptionKeys; } // just save the whole message _transform = ( chunk: Uint8Array, _encoding: BufferEncoding, callback: TransformCallback ): void => { this._messageChunks.push(chunk); this._messageLength += chunk.length; callback(); }; // Actually do stuff _flush = async (callback: TransformCallback): Promise => { const message = Buffer.concat(this._messageChunks, this._messageLength); try { // Reconstruct message as buffer const validPublicKeys = await Promise.all( this._encryptionKeys.map((armoredKey) => openpgp.readKey({ armoredKey }) ) ); let privateKey: openpgp.PrivateKey | undefined; // Just return the message if there is no one to encrypt for if (!validPublicKeys.length) { this.push(message); return callback(); } // Only sign the message if private key and password exist if (this._signingKey && this._password) { privateKey = await openpgp.decryptKey({ privateKey: await openpgp.readPrivateKey({ armoredKey: this._signingKey, }), passphrase: this._password, }); } const emailPartDelimiter = '\r\n\r\n'; const messageParts = message.toString().split(emailPartDelimiter); /** * In this loop original headers are split up into two parts, * one for the email that is sent * and one for the encrypted content */ const header = messageParts.shift() as string; const emailHeaders: string[][] = []; const contentHeaders: string[][] = []; const linesInHeader = header.split('\r\n'); let previousHeader: string[] = []; for (let i = 0; i < linesInHeader.length; i++) { const line = linesInHeader[i]; if (/^\s/.test(line) || i === 0) { previousHeader.push(line); } else { if ( /^(content-type|content-transfer-encoding):/i.test( previousHeader[0] ) ) { contentHeaders.push(previousHeader); } else { emailHeaders.push(previousHeader); } previousHeader = [line]; } } if (previousHeader.length > 0) { if ( /^(content-type|content-transfer-encoding):/i.test(previousHeader[0]) ) { contentHeaders.push(previousHeader); } else { emailHeaders.push(previousHeader); } } // Generate a new boundary for the email content const boundary = 'nm_' + randomBytes(14).toString('hex'); /** * Concatenate everything into single strings * and add pgp headers to the email headers */ const emailHeadersRaw = emailHeaders.map((line) => line.join('\r\n')).join('\r\n') + '\r\n' + 'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";' + '\r\n' + ' boundary="' + boundary + '"' + '\r\n' + 'Content-Description: OpenPGP encrypted message' + '\r\n' + 'Content-Transfer-Encoding: 7bit'; const contentHeadersRaw = contentHeaders .map((line) => line.join('\r\n')) .join('\r\n'); const encryptedMessage = await openpgp.encrypt({ message: await openpgp.createMessage({ text: contentHeadersRaw + emailPartDelimiter + messageParts.join(emailPartDelimiter), }), encryptionKeys: validPublicKeys, signingKeys: privateKey, }); const body = '--' + boundary + '\r\n' + 'Content-Type: application/pgp-encrypted\r\n' + 'Content-Transfer-Encoding: 7bit\r\n' + '\r\n' + 'Version: 1\r\n' + '\r\n' + '--' + boundary + '\r\n' + 'Content-Type: application/octet-stream; name=encrypted.asc\r\n' + 'Content-Disposition: inline; filename=encrypted.asc\r\n' + 'Content-Transfer-Encoding: 7bit\r\n' + '\r\n' + encryptedMessage + '\r\n--' + boundary + '--\r\n'; this.push(Buffer.from(emailHeadersRaw + emailPartDelimiter + body)); callback(); } catch (e) { logger.error( 'Something went wrong while encrypting email message with OpenPGP. Sending email without encryption', { label: 'Notifications', errorMessage: e.message, } ); this.push(message); callback(); } }; } export const openpgpEncrypt = (options: EncryptorOptions) => { // Disabling this line because I don't want to fix it but I am tired // of seeing the lint warning // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (mail: any, callback: () => unknown): void { if (!options.encryptionKeys.length) { setImmediate(callback); } mail.message.transform( () => new PGPEncryptor({ signingKey: options.signingKey, password: options.password, encryptionKeys: options.encryptionKeys, }) ); setImmediate(callback); }; }; ================================================ FILE: server/lib/imageproxy.ts ================================================ import logger from '@server/logger'; import { requestInterceptorFunction } from '@server/utils/customProxyAgent'; import axios from 'axios'; import rateLimit, { type rateLimitOptions } from 'axios-rate-limit'; import { createHash } from 'crypto'; import { promises } from 'fs'; import mime from 'mime/lite'; import path, { join } from 'path'; type ImageResponse = { meta: { revalidateAfter: number; curRevalidate: number; isStale: boolean; etag: string; extension: string | null; cacheKey: string; cacheMiss: boolean; }; imageBuffer: Buffer; }; const baseCacheDirectory = process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/cache/images` : path.join(__dirname, '../../config/cache/images'); class ImageProxy { public static async clearCache(key: string) { let deletedImages = 0; const cacheDirectory = path.join(baseCacheDirectory, key); try { const files = await promises.readdir(cacheDirectory); for (const file of files) { const filePath = path.join(cacheDirectory, file); const stat = await promises.lstat(filePath); if (stat.isDirectory()) { const imageFiles = await promises.readdir(filePath); for (const imageFile of imageFiles) { const [, expireAtSt] = imageFile.split('.'); const expireAt = Number(expireAtSt); const now = Date.now(); if (now > expireAt) { await promises.rm(path.join(filePath), { recursive: true, }); deletedImages += 1; } } } } } catch (e) { if (e.code === 'ENOENT') { logger.error('Directory not found', { label: 'Image Cache', message: e.message, }); } else { logger.error('Failed to read directory', { label: 'Image Cache', message: e.message, }); } } logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, { label: 'Image Cache', }); } public static async getImageStats( key: string ): Promise<{ size: number; imageCount: number }> { const cacheDirectory = path.join(baseCacheDirectory, key); const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory); const imageCount = await ImageProxy.getImageCount(cacheDirectory); return { size: imageTotalSize, imageCount, }; } private static async getDirectorySize(dir: string): Promise { try { const files = await promises.readdir(dir, { withFileTypes: true, }); const paths = files.map(async (file) => { const path = join(dir, file.name); if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); if (file.isFile()) { const { size } = await promises.stat(path); return size; } return 0; }); return (await Promise.all(paths)) .flat(Infinity) .reduce((i, size) => i + size, 0); } catch (e) { if (e.code === 'ENOENT') { return 0; } } return 0; } private static async getImageCount(dir: string) { try { const files = await promises.readdir(dir); return files.length; } catch (e) { if (e.code === 'ENOENT') { return 0; } } return 0; } private axios; private cacheVersion; private key; constructor( key: string, baseUrl: string, options: { cacheVersion?: number; rateLimitOptions?: rateLimitOptions; headers?: Record; } = {} ) { this.cacheVersion = options.cacheVersion ?? 1; this.key = key; this.axios = axios.create({ baseURL: baseUrl, headers: options.headers, }); this.axios.interceptors.request.use(requestInterceptorFunction); if (options.rateLimitOptions) { this.axios = rateLimit(this.axios, options.rateLimitOptions); } } public async getImage( path: string, fallbackPath?: string ): Promise { const cacheKey = this.getCacheKey(path); const imageResponse = await this.get(cacheKey); if (!imageResponse) { const newImage = await this.set(path, cacheKey); if (!newImage) { if (fallbackPath) { return await this.getImage(fallbackPath); } else { throw new Error('Failed to load image'); } } return newImage; } // If the image is stale, we will revalidate it in the background. if (imageResponse.meta.isStale) { this.set(path, cacheKey); } return imageResponse; } public async clearCachedImage(path: string) { // find cacheKey const cacheKey = this.getCacheKey(path); const directory = join(this.getCacheDirectory(), cacheKey); try { await promises.access(directory); } catch (e) { if (e.code === 'ENOENT') { logger.debug( `Cache directory '${cacheKey}' does not exist; nothing to clear.`, { label: 'Image Cache', } ); return; } else { logger.error('Error checking cache directory existence', { label: 'Image Cache', message: e.message, }); return; } } try { const files = await promises.readdir(directory); await promises.rm(directory, { recursive: true }); logger.debug(`Cleared ${files[0]} from cache 'avatar'`, { label: 'Image Cache', }); } catch (e) { logger.error('Failed to clear cached image', { label: 'Image Cache', message: e.message, }); } } private async get(cacheKey: string): Promise { try { const directory = join(this.getCacheDirectory(), cacheKey); const files = await promises.readdir(directory); const now = Date.now(); for (const file of files) { const [maxAgeSt, expireAtSt, etag, extension] = file.split('.'); const buffer = await promises.readFile(join(directory, file)); const expireAt = Number(expireAtSt); const maxAge = Number(maxAgeSt); return { meta: { curRevalidate: maxAge, revalidateAfter: maxAge * 1000 + now, isStale: now > expireAt, etag, extension, cacheKey, cacheMiss: false, }, imageBuffer: buffer, }; } } catch { // No files. Treat as empty cache. } return null; } private async set( path: string, cacheKey: string ): Promise { try { const directory = join(this.getCacheDirectory(), cacheKey); const response = await this.axios.get(path, { responseType: 'arraybuffer', }); const buffer = Buffer.from(response.data, 'binary'); const contentType = response.headers['content-type'] || ''; const extension = mime.getExtension(contentType) || ''; let maxAge = Number( (response.headers['cache-control'] ?? '0').split('=')[1] ); if (!maxAge) maxAge = 86400; const expireAt = Date.now() + maxAge * 1000; const etag = (response.headers.etag ?? '').replace(/"/g, ''); await this.writeToCacheDir( directory, extension, maxAge, expireAt, buffer, etag ); return { meta: { curRevalidate: maxAge, revalidateAfter: expireAt, isStale: false, etag, extension, cacheKey, cacheMiss: true, }, imageBuffer: buffer, }; } catch (e) { logger.debug('Something went wrong caching image.', { label: 'Image Cache', errorMessage: e.message, }); return null; } } private async writeToCacheDir( dir: string, extension: string | null, maxAge: number, expireAt: number, buffer: Buffer, etag: string ) { const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`); await promises.rm(dir, { force: true, recursive: true }).catch(() => { // do nothing }); await promises.mkdir(dir, { recursive: true }); await promises.writeFile(filename, buffer); } private getCacheKey(path: string) { return this.getHash([this.key, this.cacheVersion, path]); } private getHash(items: (string | number | Buffer)[]) { const hash = createHash('sha256'); for (const item of items) { if (typeof item === 'number') hash.update(String(item)); else { hash.update(item); } } // See https://en.wikipedia.org/wiki/Base64#Filenames return hash.digest('base64').replace(/\//g, '-'); } private getCacheDirectory() { return path.join(baseCacheDirectory, this.key); } } export default ImageProxy; ================================================ FILE: server/lib/notifications/agents/agent.ts ================================================ import type Issue from '@server/entity/Issue'; import type IssueComment from '@server/entity/IssueComment'; import type Media from '@server/entity/Media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { User } from '@server/entity/User'; import type { NotificationAgentConfig } from '@server/lib/settings'; import type { Notification } from '..'; export interface NotificationPayload { event?: string; subject: string; notifySystem: boolean; notifyAdmin: boolean; notifyUser?: User; media?: Media; image?: string; message?: string; extra?: { name: string; value: string }[]; request?: MediaRequest; issue?: Issue; comment?: IssueComment; pendingRequestsCount?: number; isAdmin?: boolean; } export abstract class BaseAgent { protected settings?: T; public constructor(settings?: T) { this.settings = settings; } protected abstract getSettings(): T; } export interface NotificationAgent { shouldSend(): boolean; send(type: Notification, payload: NotificationPayload): Promise; } ================================================ FILE: server/lib/notifications/agents/discord.ts ================================================ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { NotificationAgentDiscord } from '@server/lib/settings'; import { NotificationAgentKey, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios from 'axios'; import { Notification, hasNotificationType, shouldSendAdminNotification, } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; enum EmbedColors { DEFAULT = 0, AQUA = 1752220, GREEN = 3066993, BLUE = 3447003, PURPLE = 10181046, GOLD = 15844367, ORANGE = 15105570, RED = 15158332, GREY = 9807270, DARKER_GREY = 8359053, NAVY = 3426654, DARK_AQUA = 1146986, DARK_GREEN = 2067276, DARK_BLUE = 2123412, DARK_PURPLE = 7419530, DARK_GOLD = 12745742, DARK_ORANGE = 11027200, DARK_RED = 10038562, DARK_GREY = 9936031, LIGHT_GREY = 12370112, DARK_NAVY = 2899536, LUMINOUS_VIVID_PINK = 16580705, DARK_VIVID_PINK = 12320855, } interface DiscordImageEmbed { url?: string; proxy_url?: string; height?: number; width?: number; } interface Field { name: string; value: string; inline?: boolean; } interface DiscordRichEmbed { title?: string; type?: 'rich'; // Always rich for webhooks description?: string; url?: string; timestamp?: string; color?: number; footer?: { text: string; icon_url?: string; proxy_icon_url?: string; }; image?: DiscordImageEmbed; thumbnail?: DiscordImageEmbed; provider?: { name?: string; url?: string; }; author?: { name?: string; url?: string; icon_url?: string; proxy_icon_url?: string; }; fields?: Field[]; } interface DiscordWebhookPayload { embeds: DiscordRichEmbed[]; username?: string; avatar_url?: string; tts: boolean; content?: string; allowed_mentions?: { parse?: ('users' | 'roles' | 'everyone')[]; roles?: string[]; users?: string[]; }; } class DiscordAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentDiscord { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.discord; } public buildEmbed( type: Notification, payload: NotificationPayload ): DiscordRichEmbed { const settings = getSettings(); const { applicationUrl } = settings.main; const { embedPoster } = settings.notifications.agents.discord; const appUrl = applicationUrl || `http://localhost:${process.env.port || 5055}`; let color = EmbedColors.DARK_PURPLE; const fields: Field[] = []; if (payload.request) { fields.push({ name: 'Requested By', value: payload.request.requestedBy.displayName, inline: true, }); let status = ''; switch (type) { case Notification.MEDIA_PENDING: color = EmbedColors.ORANGE; status = `[Pending Approval](${appUrl}/requests)`; break; case Notification.MEDIA_APPROVED: case Notification.MEDIA_AUTO_APPROVED: color = EmbedColors.PURPLE; status = 'Processing'; break; case Notification.MEDIA_AVAILABLE: color = EmbedColors.GREEN; status = 'Available'; break; case Notification.MEDIA_DECLINED: color = EmbedColors.RED; status = 'Declined'; break; case Notification.MEDIA_FAILED: color = EmbedColors.RED; status = 'Failed'; break; } if (status) { fields.push({ name: 'Request Status', value: status, inline: true, }); } } else if (payload.comment) { fields.push({ name: `Comment from ${payload.comment.user.displayName}`, value: payload.comment.message, inline: false, }); } else if (payload.issue) { fields.push( { name: 'Reported By', value: payload.issue.createdBy.displayName, inline: true, }, { name: 'Issue Type', value: IssueTypeName[payload.issue.issueType], inline: true, }, { name: 'Issue Status', value: payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved', inline: true, } ); switch (type) { case Notification.ISSUE_CREATED: case Notification.ISSUE_REOPENED: color = EmbedColors.RED; break; case Notification.ISSUE_COMMENT: color = EmbedColors.ORANGE; break; case Notification.ISSUE_RESOLVED: color = EmbedColors.GREEN; break; } } for (const extra of payload.extra ?? []) { fields.push({ name: extra.name, value: extra.value, inline: true, }); } const url = applicationUrl ? payload.issue ? `${applicationUrl}/issues/${payload.issue.id}` : payload.media ? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` : undefined : undefined; return { title: payload.event ? `${payload.event}: ${payload.subject}` : payload.subject, url, description: payload.message, color, timestamp: new Date().toISOString(), fields, thumbnail: embedPoster ? { url: payload.image, } : undefined, }; } public shouldSend(): boolean { const settings = this.getSettings(); if (settings.enabled && settings.options.webhookUrl) { return true; } return false; } public async send( type: Notification, payload: NotificationPayload ): Promise { const settings = this.getSettings(); if ( !payload.notifySystem || !hasNotificationType(type, settings.types ?? 0) ) { return true; } logger.debug('Sending Discord notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, }); const userMentions: string[] = []; try { if (settings.options.enableMentions) { if (payload.notifyUser) { if ( payload.notifyUser.settings?.hasNotificationType( NotificationAgentKey.DISCORD, type ) && payload.notifyUser.settings.discordId ) { userMentions.push(`<@${payload.notifyUser.settings.discordId}>`); } } if (payload.notifyAdmin) { const userRepository = getRepository(User); const users = await userRepository.find(); userMentions.push( ...users .filter( (user) => user.settings?.hasNotificationType( NotificationAgentKey.DISCORD, type ) && user.settings.discordId && shouldSendAdminNotification(type, user, payload) ) .map((user) => `<@${user.settings?.discordId}>`) ); } } if (settings.options.webhookRoleId) { userMentions.push(`<@&${settings.options.webhookRoleId}>`); } await axios.post(settings.options.webhookUrl, { username: settings.options.botUsername ? settings.options.botUsername : getSettings().main.applicationTitle, avatar_url: settings.options.botAvatarUrl, embeds: [this.buildEmbed(type, payload)], content: userMentions.join(' '), } as DiscordWebhookPayload); return true; } catch (e) { logger.error('Error sending Discord notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e?.response?.data, }); return false; } } } export default DiscordAgent; ================================================ FILE: server/lib/notifications/agents/email.ts ================================================ import { IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import PreparedEmail from '@server/lib/email'; import type { NotificationAgentEmail } from '@server/lib/settings'; import { NotificationAgentKey, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import type { EmailOptions } from 'email-templates'; import path from 'path'; import validator from 'validator'; import { Notification, shouldSendAdminNotification } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; class EmailAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentEmail { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.email; } public shouldSend(): boolean { const settings = this.getSettings(); if ( settings.enabled && settings.options.emailFrom && settings.options.smtpHost && settings.options.smtpPort ) { return true; } return false; } private buildMessage( type: Notification, payload: NotificationPayload, recipientEmail: string, recipientName?: string ): EmailOptions | undefined { const settings = getSettings(); const { applicationUrl, applicationTitle } = settings.main; const { embedPoster } = settings.notifications.agents.email; if (type === Notification.TEST_NOTIFICATION) { return { template: path.join(__dirname, '../../../templates/email/test-email'), message: { to: recipientEmail, }, locals: { body: payload.message, applicationUrl, applicationTitle, recipientName, recipientEmail, }, }; } const mediaType = payload.media ? payload.media.mediaType === MediaType.MOVIE ? 'movie' : 'series' : undefined; const is4k = payload.request?.is4k; if (payload.request) { let body = ''; switch (type) { case Notification.MEDIA_PENDING: body = `A new request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' }is pending approval:`; break; case Notification.MEDIA_AUTO_REQUESTED: body = `A new request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' }was automatically submitted:`; break; case Notification.MEDIA_APPROVED: body = `Your request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' }has been approved:`; break; case Notification.MEDIA_AUTO_APPROVED: body = `A new request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' }has been automatically approved:`; break; case Notification.MEDIA_AVAILABLE: body = `Your request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' }is now available:`; break; case Notification.MEDIA_DECLINED: body = `Your request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' }was declined:`; break; case Notification.MEDIA_FAILED: body = `A request for the following ${mediaType} ${ is4k ? 'in 4K ' : '' }failed to be added to ${ payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr' }:`; break; } return { template: path.join( __dirname, '../../../templates/email/media-request' ), message: { to: recipientEmail, }, locals: { event: payload.event, body, mediaName: payload.subject, mediaExtra: payload.extra ?? [], imageUrl: embedPoster ? payload.image : undefined, timestamp: new Date().toTimeString(), requestedBy: payload.request.requestedBy.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, applicationUrl, applicationTitle, recipientName, recipientEmail, }, }; } else if (payload.issue) { const issueType = payload.issue && payload.issue.issueType !== IssueType.OTHER ? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue` : 'issue'; let body = ''; switch (type) { case Notification.ISSUE_CREATED: body = `A new ${issueType} has been reported by ${payload.issue.createdBy.displayName} for the ${mediaType} ${payload.subject}:`; break; case Notification.ISSUE_COMMENT: body = `${payload.comment?.user.displayName} commented on the ${issueType} for the ${mediaType} ${payload.subject}:`; break; case Notification.ISSUE_RESOLVED: body = `The ${issueType} for the ${mediaType} ${payload.subject} was marked as resolved by ${payload.issue.modifiedBy?.displayName}!`; break; case Notification.ISSUE_REOPENED: body = `The ${issueType} for the ${mediaType} ${payload.subject} was reopened by ${payload.issue.modifiedBy?.displayName}.`; break; } return { template: path.join(__dirname, '../../../templates/email/media-issue'), message: { to: recipientEmail, }, locals: { event: payload.event, body, issueDescription: payload.message, issueComment: payload.comment?.message, mediaName: payload.subject, extra: payload.extra ?? [], imageUrl: embedPoster ? payload.image : undefined, timestamp: new Date().toTimeString(), actionUrl: applicationUrl ? `${applicationUrl}/issues/${payload.issue.id}` : undefined, applicationUrl, applicationTitle, recipientName, recipientEmail, }, }; } return undefined; } public async send( type: Notification, payload: NotificationPayload ): Promise { if (payload.notifyUser) { if ( !payload.notifyUser.settings || // Check if user has email notifications enabled and fallback to true if undefined // since email should default to true (payload.notifyUser.settings.hasNotificationType( NotificationAgentKey.EMAIL, type ) ?? true) ) { logger.debug('Sending email notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, }); try { const email = new PreparedEmail( this.getSettings(), payload.notifyUser.settings?.pgpKey ); if ( validator.isEmail(payload.notifyUser.email, { require_tld: false }) ) { await email.send( this.buildMessage( type, payload, payload.notifyUser.email, payload.notifyUser.displayName ) ); } else { logger.warn('Invalid email address provided for user', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, }); } } catch (e) { logger.error('Error sending email notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, }); return false; } } } if (payload.notifyAdmin) { const userRepository = getRepository(User); const users = await userRepository.find(); await Promise.all( users .filter( (user) => (!user.settings || // Check if user has email notifications enabled and fallback to true if undefined // since email should default to true (user.settings.hasNotificationType( NotificationAgentKey.EMAIL, type ) ?? true)) && shouldSendAdminNotification(type, user, payload) ) .map(async (user) => { logger.debug('Sending email notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, }); try { const email = new PreparedEmail( this.getSettings(), user.settings?.pgpKey ); if (validator.isEmail(user.email, { require_tld: false })) { await email.send( this.buildMessage(type, payload, user.email, user.displayName) ); } else { logger.warn('Invalid email address provided for user', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, }); } } catch (e) { logger.error('Error sending email notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, }); return false; } }) ); } return true; } } export default EmailAgent; ================================================ FILE: server/lib/notifications/agents/gotify.ts ================================================ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import type { NotificationAgentGotify } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios from 'axios'; import { Notification, hasNotificationType } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; interface GotifyPayload { title: string; message: string; priority: number; extras: Record; } class GotifyAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentGotify { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.gotify; } public shouldSend(): boolean { const settings = this.getSettings(); if ( settings.enabled && settings.options.url && settings.options.token && settings.options.priority !== undefined ) { return true; } return false; } private getNotificationPayload( type: Notification, payload: NotificationPayload ): GotifyPayload { const { applicationUrl, applicationTitle } = getSettings().main; const settings = this.getSettings(); const priority = settings.options.priority ?? 1; const title = payload.event ? `${payload.event} - ${payload.subject}` : payload.subject; let message = payload.message ? `${payload.message} \n\n` : ''; if (payload.request) { message += `\n**Requested By:** ${payload.request.requestedBy.displayName} `; let status = ''; switch (type) { case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; case Notification.MEDIA_APPROVED: case Notification.MEDIA_AUTO_APPROVED: status = 'Processing'; break; case Notification.MEDIA_AVAILABLE: status = 'Available'; break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; case Notification.MEDIA_FAILED: status = 'Failed'; break; } if (status) { message += `\n**Request Status:** ${status} `; } } else if (payload.comment) { message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message} `; } else if (payload.issue) { message += `\n\n**Reported By:** ${payload.issue.createdBy.displayName} `; message += `\n**Issue Type:** ${ IssueTypeName[payload.issue.issueType] } `; message += `\n**Issue Status:** ${ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' } `; } for (const extra of payload.extra ?? []) { message += `\n\n**${extra.name}**\n${extra.value} `; } if (applicationUrl && payload.media) { const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; const displayUrl = actionUrl.length > 40 ? `${actionUrl.slice(0, 41)}...` : actionUrl; message += `\n\n**Open in ${applicationTitle}:** [${displayUrl}](${actionUrl}) `; } return { extras: { 'client::display': { contentType: 'text/markdown', }, }, title, message, priority, }; } public async send( type: Notification, payload: NotificationPayload ): Promise { const settings = this.getSettings(); if ( !payload.notifySystem || !hasNotificationType(type, settings.types ?? 0) ) { return true; } logger.debug('Sending Gotify notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, }); try { const endpoint = `${settings.options.url}/message?token=${settings.options.token}`; const notificationPayload = this.getNotificationPayload(type, payload); await axios.post(endpoint, notificationPayload); return true; } catch (e) { logger.error('Error sending Gotify notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e?.response?.data, }); return false; } } } export default GotifyAgent; ================================================ FILE: server/lib/notifications/agents/ntfy.ts ================================================ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import type { NotificationAgentNtfy } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios from 'axios'; import { Notification, hasNotificationType } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; class NtfyAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentNtfy { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.ntfy; } private buildPayload(type: Notification, payload: NotificationPayload) { const settings = getSettings(); const { applicationUrl } = settings.main; const { embedPoster } = settings.notifications.agents.ntfy; const topic = this.getSettings().options.topic; const priority = this.getSettings().options.priority ?? 3; const title = payload.event ? `${payload.event} - ${payload.subject}` : payload.subject; let message = payload.message ?? ''; if (payload.request) { message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`; let status = ''; switch (type) { case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; case Notification.MEDIA_APPROVED: case Notification.MEDIA_AUTO_APPROVED: status = 'Processing'; break; case Notification.MEDIA_AVAILABLE: status = 'Available'; break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; case Notification.MEDIA_FAILED: status = 'Failed'; break; } if (status) { message += `\nRequest Status: ${status}`; } } else if (payload.comment) { message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`; } else if (payload.issue) { message += `\n\nReported By: ${payload.issue.createdBy.displayName}`; message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`; message += `\nIssue Status: ${ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' }`; } for (const extra of payload.extra ?? []) { message += `\n\n**${extra.name}**\n${extra.value}`; } const attach = embedPoster ? payload.image : undefined; let click; if (applicationUrl && payload.media) { click = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; } return { topic, priority, title, message, attach, click, }; } public shouldSend(): boolean { const settings = this.getSettings(); if (settings.enabled && settings.options.url && settings.options.topic) { return true; } return false; } public async send( type: Notification, payload: NotificationPayload ): Promise { const settings = this.getSettings(); if ( !payload.notifySystem || !hasNotificationType(type, settings.types ?? 0) ) { return true; } logger.debug('Sending ntfy notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, }); try { let authHeader; if ( settings.options.authMethodUsernamePassword && settings.options.username && settings.options.password ) { const encodedAuth = Buffer.from( `${settings.options.username}:${settings.options.password}` ).toString('base64'); authHeader = `Basic ${encodedAuth}`; } else if (settings.options.authMethodToken) { authHeader = `Bearer ${settings.options.token}`; } await axios.post( settings.options.url, this.buildPayload(type, payload), authHeader ? { headers: { Authorization: authHeader, }, } : undefined ); return true; } catch (e) { logger.error('Error sending ntfy notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e?.response?.data, }); return false; } } } export default NtfyAgent; ================================================ FILE: server/lib/notifications/agents/pushbullet.ts ================================================ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import { MediaStatus } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { NotificationAgentPushbullet } from '@server/lib/settings'; import { NotificationAgentKey, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios from 'axios'; import { Notification, hasNotificationType, shouldSendAdminNotification, } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; interface PushbulletPayload { type: string; title: string; body: string; channel_tag?: string; } class PushbulletAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentPushbullet { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.pushbullet; } public shouldSend(): boolean { return true; } private getNotificationPayload( type: Notification, payload: NotificationPayload ): PushbulletPayload { const title = payload.event ? `${payload.event} - ${payload.subject}` : payload.subject; let body = payload.message ?? ''; if (payload.request) { body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`; let status = ''; switch (type) { case Notification.MEDIA_AUTO_REQUESTED: status = payload.media?.status === MediaStatus.PENDING ? 'Pending Approval' : 'Processing'; break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; case Notification.MEDIA_APPROVED: case Notification.MEDIA_AUTO_APPROVED: status = 'Processing'; break; case Notification.MEDIA_AVAILABLE: status = 'Available'; break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; case Notification.MEDIA_FAILED: status = 'Failed'; break; } if (status) { body += `\nRequest Status: ${status}`; } } else if (payload.comment) { body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`; } else if (payload.issue) { body += `\n\nReported By: ${payload.issue.createdBy.displayName}`; body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`; body += `\nIssue Status: ${ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' }`; } for (const extra of payload.extra ?? []) { body += `\n${extra.name}: ${extra.value}`; } return { type: 'note', title, body, }; } public async send( type: Notification, payload: NotificationPayload ): Promise { const settings = this.getSettings(); const endpoint = 'https://api.pushbullet.com/v2/pushes'; const notificationPayload = this.getNotificationPayload(type, payload); // Send system notification if ( payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.enabled && settings.options.accessToken ) { logger.debug('Sending Pushbullet notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, }); try { await axios.post( endpoint, { ...notificationPayload, channel_tag: settings.options.channelTag }, { headers: { 'Access-Token': settings.options.accessToken, }, } ); } catch (e) { logger.error('Error sending Pushbullet notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e.response?.data, }); return false; } } if (payload.notifyUser) { if ( payload.notifyUser.settings?.hasNotificationType( NotificationAgentKey.PUSHBULLET, type ) && payload.notifyUser.settings?.pushbulletAccessToken && payload.notifyUser.settings.pushbulletAccessToken !== settings.options.accessToken ) { logger.debug('Sending Pushbullet notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, }); try { await axios.post(endpoint, notificationPayload, { headers: { 'Access-Token': payload.notifyUser.settings.pushbulletAccessToken, }, }); } catch (e) { logger.error('Error sending Pushbullet notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e.response?.data, }); return false; } } } if (payload.notifyAdmin) { const userRepository = getRepository(User); const users = await userRepository.find(); await Promise.all( users .filter( (user) => user.settings?.hasNotificationType( NotificationAgentKey.PUSHBULLET, type ) && shouldSendAdminNotification(type, user, payload) ) .map(async (user) => { if ( user.settings?.pushbulletAccessToken && (settings.options.channelTag || user.settings.pushbulletAccessToken !== settings.options.accessToken) ) { logger.debug('Sending Pushbullet notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, }); try { await axios.post(endpoint, notificationPayload, { headers: { 'Access-Token': user.settings.pushbulletAccessToken, }, }); } catch (e) { logger.error('Error sending Pushbullet notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e.response?.data, }); return false; } } }) ); } return true; } } export default PushbulletAgent; ================================================ FILE: server/lib/notifications/agents/pushover.ts ================================================ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import { MediaStatus } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { NotificationAgentPushover } from '@server/lib/settings'; import { NotificationAgentKey, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios from 'axios'; import { Notification, hasNotificationType, shouldSendAdminNotification, } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; interface PushoverImagePayload { attachment_base64: string; attachment_type: string; } interface PushoverPayload extends PushoverImagePayload { token: string; user: string; title: string; message: string; url: string; url_title: string; priority: number; html: number; } class PushoverAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentPushover { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.pushover; } public shouldSend(): boolean { const settings = this.getSettings(); if ( settings.enabled && settings.options.accessToken && settings.options.userToken ) { return true; } return false; } private async getImagePayload( imageUrl: string ): Promise> { try { const response = await axios.get(imageUrl, { responseType: 'arraybuffer', }); const base64 = Buffer.from(response.data, 'binary').toString('base64'); const contentType = ( response.headers['Content-Type'] || response.headers['content-type'] )?.toString(); return { attachment_base64: base64, attachment_type: contentType, }; } catch (e) { logger.error('Error getting image payload', { label: 'Notifications', errorMessage: e.message, response: e.response?.data, }); return {}; } } private async getNotificationPayload( type: Notification, payload: NotificationPayload ): Promise> { const settings = getSettings(); const { applicationUrl, applicationTitle } = settings.main; const { embedPoster } = settings.notifications.agents.pushover; const title = payload.event ?? payload.subject; let message = payload.event ? `${payload.subject}` : ''; let priority = 0; if (payload.message) { message += `${message ? '\n' : ''}${payload.message}`; } if (payload.request) { message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`; let status = ''; switch (type) { case Notification.MEDIA_AUTO_REQUESTED: status = payload.media?.status === MediaStatus.PENDING ? 'Pending Approval' : 'Processing'; break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; case Notification.MEDIA_APPROVED: case Notification.MEDIA_AUTO_APPROVED: status = 'Processing'; break; case Notification.MEDIA_AVAILABLE: status = 'Available'; break; case Notification.MEDIA_DECLINED: status = 'Declined'; priority = 1; break; case Notification.MEDIA_FAILED: status = 'Failed'; priority = 1; break; } if (status) { message += `\nRequest Status: ${status}`; } } else if (payload.comment) { message += `\n\nComment from ${payload.comment.user.displayName}: ${payload.comment.message}`; } else if (payload.issue) { message += `\n\nReported By: ${payload.issue.createdBy.displayName}`; message += `\nIssue Type: ${ IssueTypeName[payload.issue.issueType] }`; message += `\nIssue Status: ${ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' }`; if (type === Notification.ISSUE_CREATED) { priority = 1; } } for (const extra of payload.extra ?? []) { message += `\n${extra.name}: ${extra.value}`; } const url = applicationUrl ? payload.issue ? `${applicationUrl}/issues/${payload.issue.id}` : payload.media ? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` : undefined : undefined; const url_title = url ? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}` : undefined; let attachment_base64; let attachment_type; if (embedPoster && payload.image) { const imagePayload = await this.getImagePayload(payload.image); if (imagePayload.attachment_base64 && imagePayload.attachment_type) { attachment_base64 = imagePayload.attachment_base64; attachment_type = imagePayload.attachment_type; } } return { title, message, url, url_title, priority, html: 1, attachment_base64, attachment_type, }; } public async send( type: Notification, payload: NotificationPayload ): Promise { const settings = this.getSettings(); const endpoint = 'https://api.pushover.net/1/messages.json'; const notificationPayload = await this.getNotificationPayload( type, payload ); // Send system notification if ( payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.enabled && settings.options.accessToken && settings.options.userToken ) { logger.debug('Sending Pushover notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, }); try { await axios.post(endpoint, { ...notificationPayload, token: settings.options.accessToken, user: settings.options.userToken, sound: settings.options.sound, } as PushoverPayload); } catch (e) { logger.error('Error sending Pushover notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e.response?.data, }); return false; } } if (payload.notifyUser) { if ( payload.notifyUser.settings?.hasNotificationType( NotificationAgentKey.PUSHOVER, type ) && payload.notifyUser.settings.pushoverApplicationToken && payload.notifyUser.settings.pushoverUserKey && (payload.notifyUser.settings.pushoverApplicationToken !== settings.options.accessToken || payload.notifyUser.settings.pushoverUserKey !== settings.options.userToken) ) { logger.debug('Sending Pushover notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, }); try { await axios.post(endpoint, { ...notificationPayload, token: payload.notifyUser.settings.pushoverApplicationToken, user: payload.notifyUser.settings.pushoverUserKey, sound: payload.notifyUser.settings.pushoverSound, } as PushoverPayload); } catch (e) { logger.error('Error sending Pushover notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e.response?.data, }); return false; } } } if (payload.notifyAdmin) { const userRepository = getRepository(User); const users = await userRepository.find(); await Promise.all( users .filter( (user) => user.settings?.hasNotificationType( NotificationAgentKey.PUSHOVER, type ) && shouldSendAdminNotification(type, user, payload) ) .map(async (user) => { if ( user.settings?.pushoverApplicationToken && user.settings?.pushoverUserKey && user.settings.pushoverApplicationToken !== settings.options.accessToken && user.settings.pushoverUserKey !== settings.options.userToken ) { logger.debug('Sending Pushover notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, }); try { await axios.post(endpoint, { ...notificationPayload, token: user.settings.pushoverApplicationToken, user: user.settings.pushoverUserKey, } as PushoverPayload); } catch (e) { logger.error('Error sending Pushover notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e.response?.data, }); return false; } } }) ); } return true; } } export default PushoverAgent; ================================================ FILE: server/lib/notifications/agents/slack.ts ================================================ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import type { NotificationAgentSlack } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios from 'axios'; import { Notification, hasNotificationType } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; interface EmbedField { type: 'plain_text' | 'mrkdwn'; text: string; } interface TextItem { type: 'plain_text' | 'mrkdwn'; text: string; emoji?: boolean; } interface Element { type: 'button'; text?: TextItem; action_id: string; url?: string; value?: string; style?: 'primary' | 'danger'; } interface EmbedBlock { type: 'header' | 'actions' | 'section' | 'context'; block_id?: 'section789'; text?: TextItem; fields?: EmbedField[]; accessory?: { type: 'image'; image_url: string; alt_text: string; }; elements?: (Element | TextItem)[]; } interface SlackBlockEmbed { text: string; blocks: EmbedBlock[]; } class SlackAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentSlack { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.slack; } public buildEmbed( type: Notification, payload: NotificationPayload ): SlackBlockEmbed { const settings = getSettings(); const { applicationUrl, applicationTitle } = settings.main; const { embedPoster } = settings.notifications.agents.slack; const fields: EmbedField[] = []; if (payload.request) { fields.push({ type: 'mrkdwn', text: `*Requested By*\n${payload.request.requestedBy.displayName}`, }); let status = ''; switch (type) { case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; case Notification.MEDIA_APPROVED: case Notification.MEDIA_AUTO_APPROVED: status = 'Processing'; break; case Notification.MEDIA_AVAILABLE: status = 'Available'; break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; case Notification.MEDIA_FAILED: status = 'Failed'; break; } if (status) { fields.push({ type: 'mrkdwn', text: `*Request Status*\n${status}`, }); } } else if (payload.comment) { fields.push({ type: 'mrkdwn', text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`, }); } else if (payload.issue) { fields.push( { type: 'mrkdwn', text: `*Reported By*\n${payload.issue.createdBy.displayName}`, }, { type: 'mrkdwn', text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`, }, { type: 'mrkdwn', text: `*Issue Status*\n${ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' }`, } ); } for (const extra of payload.extra ?? []) { fields.push({ type: 'mrkdwn', text: `*${extra.name}*\n${extra.value}`, }); } const blocks: EmbedBlock[] = []; if (payload.event) { blocks.push({ type: 'context', elements: [ { type: 'mrkdwn', text: `*${payload.event}*`, }, ], }); } blocks.push({ type: 'header', text: { type: 'plain_text', text: payload.subject, }, }); if (payload.message) { blocks.push({ type: 'section', text: { type: 'mrkdwn', text: payload.message, }, accessory: embedPoster && payload.image ? { type: 'image', image_url: payload.image, alt_text: payload.subject, } : undefined, }); } if (fields.length > 0) { blocks.push({ type: 'section', fields, }); } const url = applicationUrl ? payload.issue ? `${applicationUrl}/issues/${payload.issue.id}` : payload.media ? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` : undefined : undefined; if (url) { blocks.push({ type: 'actions', elements: [ { action_id: 'open-in-seerr', type: 'button', url, text: { type: 'plain_text', text: `View ${ payload.issue ? 'Issue' : 'Media' } in ${applicationTitle}`, }, }, ], }); } return { text: payload.event ?? payload.subject, blocks, }; } public shouldSend(): boolean { const settings = this.getSettings(); if (settings.enabled && settings.options.webhookUrl) { return true; } return false; } public async send( type: Notification, payload: NotificationPayload ): Promise { const settings = this.getSettings(); if ( !payload.notifySystem || !hasNotificationType(type, settings.types ?? 0) ) { return true; } logger.debug('Sending Slack notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, }); try { await axios.post( settings.options.webhookUrl, this.buildEmbed(type, payload) ); return true; } catch (e) { logger.error('Error sending Slack notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e?.response?.data, }); return false; } } } export default SlackAgent; ================================================ FILE: server/lib/notifications/agents/telegram.ts ================================================ import { IssueStatus, IssueTypeName } from '@server/constants/issue'; import { MediaStatus } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { NotificationAgentTelegram } from '@server/lib/settings'; import { NotificationAgentKey, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios from 'axios'; import { Notification, hasNotificationType, shouldSendAdminNotification, } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; interface TelegramMessagePayload { text: string; parse_mode: string; chat_id: string; message_thread_id: string; disable_notification: boolean; } interface TelegramPhotoPayload { photo: string; caption: string; parse_mode: string; chat_id: string; message_thread_id: string; disable_notification: boolean; } class TelegramAgent extends BaseAgent implements NotificationAgent { private baseUrl = 'https://api.telegram.org/'; protected getSettings(): NotificationAgentTelegram { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.telegram; } public shouldSend(): boolean { const settings = this.getSettings(); if (settings.enabled && settings.options.botAPI) { return true; } return false; } private escapeText(text: string | undefined): string { return text ? text.replace(/[_*[\]()~>#+=|{}.!-]/gi, (x) => '\\' + x) : ''; } private getNotificationPayload( type: Notification, payload: NotificationPayload ): Partial { const settings = getSettings(); const { applicationUrl, applicationTitle } = settings.main; const { embedPoster } = settings.notifications.agents.telegram; /* eslint-disable no-useless-escape */ let message = `\*${this.escapeText( payload.event ? `${payload.event} - ${payload.subject}` : payload.subject )}\*`; if (payload.message) { message += `\n${this.escapeText(payload.message)}`; } if (payload.request) { message += `\n\n\*Requested By:\* ${this.escapeText( payload.request?.requestedBy.displayName )}`; let status = ''; switch (type) { case Notification.MEDIA_AUTO_REQUESTED: status = payload.media?.status === MediaStatus.PENDING ? 'Pending Approval' : 'Processing'; break; case Notification.MEDIA_PENDING: status = 'Pending Approval'; break; case Notification.MEDIA_APPROVED: case Notification.MEDIA_AUTO_APPROVED: status = 'Processing'; break; case Notification.MEDIA_AVAILABLE: status = 'Available'; break; case Notification.MEDIA_DECLINED: status = 'Declined'; break; case Notification.MEDIA_FAILED: status = 'Failed'; break; } if (status) { message += `\n\*Request Status:\* ${status}`; } } else if (payload.comment) { message += `\n\n\*Comment from ${this.escapeText( payload.comment.user.displayName )}:\* ${this.escapeText(payload.comment.message)}`; } else if (payload.issue) { message += `\n\n\*Reported By:\* ${this.escapeText( payload.issue.createdBy.displayName )}`; message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`; message += `\n\*Issue Status:\* ${ payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' }`; } for (const extra of payload.extra ?? []) { message += `\n\*${extra.name}:\* ${extra.value}`; } const url = applicationUrl ? payload.issue ? `${applicationUrl}/issues/${payload.issue.id}` : payload.media ? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` : undefined : undefined; if (url) { message += `\n\n\[View ${ payload.issue ? 'Issue' : 'Media' } in ${this.escapeText(applicationTitle)}\]\(${url}\)`; } /* eslint-enable */ return embedPoster && payload.image ? { photo: payload.image, caption: message, parse_mode: 'MarkdownV2', } : { text: message, parse_mode: 'MarkdownV2', }; } public async send( type: Notification, payload: NotificationPayload ): Promise { const settings = this.getSettings(); const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${ settings.embedPoster && payload.image ? 'sendPhoto' : 'sendMessage' }`; const notificationPayload = this.getNotificationPayload(type, payload); // Send system notification if ( payload.notifySystem && hasNotificationType(type, settings.types ?? 0) && settings.options.chatId ) { logger.debug('Sending Telegram notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, }); try { await axios.post(endpoint, { ...notificationPayload, chat_id: settings.options.chatId, message_thread_id: settings.options.messageThreadId, disable_notification: !!settings.options.sendSilently, } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { logger.error('Error sending Telegram notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e?.response?.data, }); return false; } } if (payload.notifyUser) { if ( payload.notifyUser.settings?.hasNotificationType( NotificationAgentKey.TELEGRAM, type ) && payload.notifyUser.settings?.telegramChatId && payload.notifyUser.settings.telegramChatId !== settings.options.chatId ) { logger.debug('Sending Telegram notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, }); try { await axios.post(endpoint, { ...notificationPayload, chat_id: payload.notifyUser.settings.telegramChatId, message_thread_id: payload.notifyUser.settings.telegramMessageThreadId, disable_notification: !!payload.notifyUser.settings.telegramSendSilently, } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { logger.error('Error sending Telegram notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e?.response?.data, }); return false; } } } if (payload.notifyAdmin) { const userRepository = getRepository(User); const users = await userRepository.find(); await Promise.all( users .filter( (user) => user.settings?.hasNotificationType( NotificationAgentKey.TELEGRAM, type ) && shouldSendAdminNotification(type, user, payload) ) .map(async (user) => { if ( user.settings?.telegramChatId && user.settings.telegramChatId !== settings.options.chatId ) { logger.debug('Sending Telegram notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, }); try { await axios.post(endpoint, { ...notificationPayload, chat_id: user.settings.telegramChatId, message_thread_id: user.settings.telegramMessageThreadId, disable_notification: !!user.settings?.telegramSendSilently, } as TelegramMessagePayload | TelegramPhotoPayload); } catch (e) { logger.error('Error sending Telegram notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e?.response?.data, }); return false; } } }) ); } return true; } } export default TelegramAgent; ================================================ FILE: server/lib/notifications/agents/webhook.ts ================================================ import { IssueStatus, IssueType } from '@server/constants/issue'; import { MediaStatus } from '@server/constants/media'; import type { NotificationAgentWebhook } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios from 'axios'; import { get } from 'lodash'; import { Notification, hasNotificationType } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; type KeyMapFunction = ( payload: NotificationPayload, type: Notification ) => string; const KeyMap: Record = { notification_type: (_payload, type) => Notification[type], event: 'event', subject: 'subject', message: 'message', image: 'image', notifyuser_username: 'notifyUser.displayName', notifyuser_email: 'notifyUser.email', notifyuser_avatar: 'notifyUser.avatar', notifyuser_settings_discordId: 'notifyUser.settings.discordId', notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId', media_tmdbid: 'media.tmdbId', media_tvdbid: 'media.tvdbId', media_type: 'media.mediaType', media_jellyfinMediaId: (payload) => payload.media?.jellyfinMediaId ?? payload.media?.jellyfinMediaId4k ?? '', media_status: (payload) => payload.media ? MediaStatus[payload.media.status] : '', media_status4k: (payload) => payload.media ? MediaStatus[payload.media.status4k] : '', request_id: 'request.id', requestedBy_jellyfinUserId: 'request.requestedBy.jellyfinUserId', requestedBy_username: 'request.requestedBy.displayName', requestedBy_email: 'request.requestedBy.email', requestedBy_avatar: 'request.requestedBy.avatar', requestedBy_settings_discordId: 'request.requestedBy.settings.discordId', requestedBy_settings_telegramChatId: 'request.requestedBy.settings.telegramChatId', issue_id: 'issue.id', issue_type: (payload) => payload.issue ? IssueType[payload.issue.issueType] : '', issue_status: (payload) => payload.issue ? IssueStatus[payload.issue.status] : '', reportedBy_username: 'issue.createdBy.displayName', reportedBy_email: 'issue.createdBy.email', reportedBy_avatar: 'issue.createdBy.avatar', reportedBy_settings_discordId: 'issue.createdBy.settings.discordId', reportedBy_settings_telegramChatId: 'issue.createdBy.settings.telegramChatId', comment_message: 'comment.message', commentedBy_username: 'comment.user.displayName', commentedBy_email: 'comment.user.email', commentedBy_avatar: 'comment.user.avatar', commentedBy_settings_discordId: 'comment.user.settings.discordId', commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId', }; class WebhookAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentWebhook { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.webhook; } private parseKeys( finalPayload: Record, payload: NotificationPayload, type: Notification ): Record { Object.keys(finalPayload).forEach((key) => { if (key === '{{extra}}') { finalPayload.extra = payload.extra ?? []; delete finalPayload[key]; key = 'extra'; } else if (key === '{{media}}') { if (payload.media) { finalPayload.media = finalPayload[key]; } else { finalPayload.media = null; } delete finalPayload[key]; key = 'media'; } else if (key === '{{request}}') { if (payload.request) { finalPayload.request = finalPayload[key]; } else { finalPayload.request = null; } delete finalPayload[key]; key = 'request'; } else if (key === '{{issue}}') { if (payload.issue) { finalPayload.issue = finalPayload[key]; } else { finalPayload.issue = null; } delete finalPayload[key]; key = 'issue'; } else if (key === '{{comment}}') { if (payload.comment) { finalPayload.comment = finalPayload[key]; } else { finalPayload.comment = null; } delete finalPayload[key]; key = 'comment'; } if (typeof finalPayload[key] === 'string') { Object.keys(KeyMap).forEach((keymapKey) => { const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap]; finalPayload[key] = (finalPayload[key] as string).replace( `{{${keymapKey}}}`, typeof keymapValue === 'function' ? keymapValue(payload, type) : (get(payload, keymapValue) ?? '') ); }); } else if (finalPayload[key] && typeof finalPayload[key] === 'object') { finalPayload[key] = this.parseKeys( finalPayload[key] as Record, payload, type ); } }); return finalPayload; } private buildPayload(type: Notification, payload: NotificationPayload) { const payloadString = Buffer.from( this.getSettings().options.jsonPayload, 'base64' ).toString('utf8'); const parsedJSON = JSON.parse(JSON.parse(payloadString)); return this.parseKeys(parsedJSON, payload, type); } public shouldSend(): boolean { const settings = this.getSettings(); if (settings.enabled && settings.options.webhookUrl) { return true; } return false; } public async send( type: Notification, payload: NotificationPayload ): Promise { const settings = this.getSettings(); if ( !payload.notifySystem || !hasNotificationType(type, settings.types ?? 0) ) { return true; } logger.debug('Sending webhook notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, }); let webhookUrl = settings.options.webhookUrl; if (settings.options.supportVariables) { Object.keys(KeyMap).forEach((keymapKey) => { const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap]; const variableValue = type === Notification.TEST_NOTIFICATION ? 'test' : typeof keymapValue === 'function' ? keymapValue(payload, type) : get(payload, keymapValue) || 'test'; webhookUrl = webhookUrl.replace( new RegExp(`{{${keymapKey}}}`, 'g'), encodeURIComponent(variableValue) ); }); } try { const headers: Record = {}; if (settings.options.authHeader) { headers.Authorization = settings.options.authHeader; } if ( settings.options.customHeaders && settings.options.customHeaders.length > 0 ) { settings.options.customHeaders.forEach((header) => { const key = header.key?.trim(); const value = header.value?.trim(); if (key && value) { // Don't override Authorization header if it's already set via authHeader if ( key.toLowerCase() !== 'authorization' || !settings.options.authHeader ) { headers[key] = value; } } }); } await axios.post( webhookUrl, this.buildPayload(type, payload), Object.keys(headers).length > 0 ? { headers } : undefined ); return true; } catch (e) { logger.error('Error sending webhook notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, response: e?.response?.data, }); return false; } } } export default WebhookAgent; ================================================ FILE: server/lib/notifications/agents/webpush.ts ================================================ import { IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaRequestStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import MediaRequest from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; import type { NotificationAgentConfig } from '@server/lib/settings'; import { NotificationAgentKey, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import webpush from 'web-push'; import { Notification, shouldSendAdminNotification } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; interface PushNotificationPayload { notificationType: string; subject: string; message?: string; image?: string; actionUrl?: string; actionUrlTitle?: string; requestId?: number; pendingRequestsCount?: number; isAdmin?: boolean; } interface WebPushError extends Error { statusCode?: number; status?: number; body?: string | unknown; response?: { body?: string | unknown; }; } class WebPushAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentConfig { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.webpush; } private getNotificationPayload( type: Notification, payload: NotificationPayload ): PushNotificationPayload { const { embedPoster } = getSettings().notifications.agents.webpush; const mediaType = payload.media ? payload.media.mediaType === MediaType.MOVIE ? 'movie' : 'series' : undefined; const is4k = payload.request?.is4k; const issueType = payload.issue ? payload.issue.issueType !== IssueType.OTHER ? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue` : 'issue' : undefined; let message: string | undefined; switch (type) { case Notification.TEST_NOTIFICATION: message = payload.message; break; case Notification.MEDIA_AUTO_REQUESTED: message = `Automatically submitted a new ${ is4k ? '4K ' : '' }${mediaType} request.`; break; case Notification.MEDIA_APPROVED: message = `Your ${ is4k ? '4K ' : '' }${mediaType} request has been approved.`; break; case Notification.MEDIA_AUTO_APPROVED: message = `Automatically approved a new ${ is4k ? '4K ' : '' }${mediaType} request from ${ payload.request?.requestedBy.displayName }.`; break; case Notification.MEDIA_AVAILABLE: message = `Your ${ is4k ? '4K ' : '' }${mediaType} request is now available!`; break; case Notification.MEDIA_DECLINED: message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined.`; break; case Notification.MEDIA_FAILED: message = `Failed to process ${is4k ? '4K ' : ''}${mediaType} request.`; break; case Notification.MEDIA_PENDING: message = `Approval required for a new ${ is4k ? '4K ' : '' }${mediaType} request from ${ payload.request?.requestedBy.displayName }.`; break; case Notification.ISSUE_CREATED: message = `A new ${issueType} was reported by ${payload.issue?.createdBy.displayName}.`; break; case Notification.ISSUE_COMMENT: message = `${payload.comment?.user.displayName} commented on the ${issueType}.`; break; case Notification.ISSUE_RESOLVED: message = `The ${issueType} was marked as resolved by ${payload.issue?.modifiedBy?.displayName}!`; break; case Notification.ISSUE_REOPENED: message = `The ${issueType} was reopened by ${payload.issue?.modifiedBy?.displayName}.`; break; default: return { notificationType: Notification[type], subject: 'Unknown', }; } const actionUrl = payload.issue ? `/issues/${payload.issue.id}` : payload.media ? `/${payload.media.mediaType}/${payload.media.tmdbId}` : undefined; const actionUrlTitle = actionUrl ? `View ${payload.issue ? 'Issue' : 'Media'}` : undefined; return { notificationType: Notification[type], subject: payload.subject, message, image: embedPoster ? payload.image : undefined, requestId: payload.request?.id, actionUrl, actionUrlTitle, pendingRequestsCount: payload.pendingRequestsCount, isAdmin: payload.isAdmin, }; } public shouldSend(): boolean { if (this.getSettings().enabled) { return true; } return false; } public async send( type: Notification, payload: NotificationPayload ): Promise { const userRepository = getRepository(User); const userPushSubRepository = getRepository(UserPushSubscription); const settings = getSettings(); const pushSubs: UserPushSubscription[] = []; const mainUser = await userRepository.findOne({ where: { id: 1 } }); const requestRepository = getRepository(MediaRequest); const pendingRequests = await requestRepository.find({ where: { status: MediaRequestStatus.PENDING }, }); const webPushNotification = async ( pushSub: UserPushSubscription, notificationPayload: Buffer ) => { logger.debug('Sending web push notification', { label: 'Notifications', recipient: pushSub.user.displayName, type: Notification[type], subject: payload.subject, }); try { await webpush.sendNotification( { endpoint: pushSub.endpoint, keys: { auth: pushSub.auth, p256dh: pushSub.p256dh, }, }, notificationPayload ); } catch (e) { const webPushError = e as WebPushError; const statusCode = webPushError.statusCode || webPushError.status; const errorMessage = webPushError.message || String(e); // RFC 8030: 410/404 are permanent failures, others are transient const isPermanentFailure = statusCode === 410 || statusCode === 404; logger.error( isPermanentFailure ? 'Error sending web push notification; removing invalid subscription' : 'Error sending web push notification (transient error, keeping subscription)', { label: 'Notifications', recipient: pushSub.user.displayName, type: Notification[type], subject: payload.subject, errorMessage, statusCode: statusCode || 'unknown', } ); if (isPermanentFailure) { await userPushSubRepository.remove(pushSub); } } }; if ( payload.notifyUser && // Check if user has webpush notifications enabled and fallback to true if undefined // since web push should default to true (payload.notifyUser.settings?.hasNotificationType( NotificationAgentKey.WEBPUSH, type ) ?? true) ) { const notifySubs = await userPushSubRepository.find({ where: { user: { id: payload.notifyUser.id } }, }); pushSubs.push(...notifySubs); } if ( payload.notifyAdmin || type === Notification.MEDIA_APPROVED || type === Notification.MEDIA_DECLINED ) { const users = await userRepository.find(); const manageUsers = users.filter( (user) => // Check if user has webpush notifications enabled and fallback to true if undefined // since web push should default to true (user.settings?.hasNotificationType( NotificationAgentKey.WEBPUSH, type ) ?? true) && shouldSendAdminNotification(type, user, payload) ); const allSubs = manageUsers.length > 0 ? await userPushSubRepository .createQueryBuilder('pushSub') .leftJoinAndSelect('pushSub.user', 'user') .where('pushSub.userId IN (:...users)', { users: manageUsers.map((user) => user.id), }) .getMany() : []; // We only want to send the custom notification when type is approved or declined // Otherwise, default to the normal notification if ( type === Notification.MEDIA_APPROVED || type === Notification.MEDIA_DECLINED ) { if (mainUser && allSubs.length > 0) { webpush.setVapidDetails( `mailto:${mainUser.email}`, settings.vapidPublic, settings.vapidPrivate ); // Custom payload only for updating the app badge const notificationBadgePayload = Buffer.from( JSON.stringify( this.getNotificationPayload(type, { subject: payload.subject, notifySystem: false, notifyAdmin: true, isAdmin: true, pendingRequestsCount: pendingRequests.length, }) ), 'utf-8' ); await Promise.all( allSubs.map(async (sub) => { webPushNotification(sub, notificationBadgePayload); }) ); } } else { pushSubs.push(...allSubs); } } if (mainUser && pushSubs.length > 0) { webpush.setVapidDetails( `mailto:${mainUser.email}`, settings.vapidPublic, settings.vapidPrivate ); if (type === Notification.MEDIA_PENDING) { payload = { ...payload, pendingRequestsCount: pendingRequests.length }; } const notificationPayload = Buffer.from( JSON.stringify(this.getNotificationPayload(type, payload)), 'utf-8' ); await Promise.all( pushSubs.map(async (sub) => { webPushNotification(sub, notificationPayload); }) ); } return true; } } export default WebPushAgent; ================================================ FILE: server/lib/notifications/index.ts ================================================ import type { User } from '@server/entity/User'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import type { NotificationAgent, NotificationPayload } from './agents/agent'; export enum Notification { NONE = 0, MEDIA_PENDING = 2, MEDIA_APPROVED = 4, MEDIA_AVAILABLE = 8, MEDIA_FAILED = 16, TEST_NOTIFICATION = 32, MEDIA_DECLINED = 64, MEDIA_AUTO_APPROVED = 128, ISSUE_CREATED = 256, ISSUE_COMMENT = 512, ISSUE_RESOLVED = 1024, ISSUE_REOPENED = 2048, MEDIA_AUTO_REQUESTED = 4096, } export const hasNotificationType = ( types: Notification | Notification[], value: number ): boolean => { let total: number; // If we are not checking any notifications, bail out and return true if (types === 0) { return true; } if (Array.isArray(types)) { // Combine all notification values into one total = types.reduce((a, v) => a + v, 0); } else { total = types; } // Test notifications don't need to be enabled if (!(value & Notification.TEST_NOTIFICATION)) { value += Notification.TEST_NOTIFICATION; } return !!(value & total); }; export const getAdminPermission = (type: Notification): Permission => { switch (type) { case Notification.MEDIA_PENDING: case Notification.MEDIA_APPROVED: case Notification.MEDIA_AVAILABLE: case Notification.MEDIA_FAILED: case Notification.MEDIA_DECLINED: case Notification.MEDIA_AUTO_APPROVED: return Permission.MANAGE_REQUESTS; case Notification.ISSUE_CREATED: case Notification.ISSUE_COMMENT: case Notification.ISSUE_RESOLVED: case Notification.ISSUE_REOPENED: return Permission.MANAGE_ISSUES; default: return Permission.ADMIN; } }; export const shouldSendAdminNotification = ( type: Notification, user: User, payload: NotificationPayload ): boolean => { return ( user.id !== payload.notifyUser?.id && user.hasPermission(getAdminPermission(type)) && // Check if the user submitted this request (on behalf of themself OR another user) (type !== Notification.MEDIA_AUTO_APPROVED || user.id !== (payload.request?.modifiedBy ?? payload.request?.requestedBy)?.id) && // Check if the user created this issue (type !== Notification.ISSUE_CREATED || user.id !== payload.issue?.createdBy.id) && // Check if the user submitted this issue comment (type !== Notification.ISSUE_COMMENT || user.id !== payload.comment?.user.id) && // Check if the user resolved/reopened this issue ((type !== Notification.ISSUE_RESOLVED && type !== Notification.ISSUE_REOPENED) || user.id !== payload.issue?.modifiedBy?.id) ); }; class NotificationManager { private activeAgents: NotificationAgent[] = []; public registerAgents = (agents: NotificationAgent[]): void => { this.activeAgents = [...this.activeAgents, ...agents]; logger.info('Registered notification agents', { label: 'Notifications' }); }; public sendNotification( type: Notification, payload: NotificationPayload ): void { logger.info(`Sending notification(s) for ${Notification[type]}`, { label: 'Notifications', subject: payload.subject, }); this.activeAgents.forEach((agent) => { if (agent.shouldSend()) { agent.send(type, payload); } }); } } const notificationManager = new NotificationManager(); export default notificationManager; ================================================ FILE: server/lib/overseerrMerge.ts ================================================ import { MediaServerType } from '@server/constants/server'; import dataSource, { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import Settings from '@server/lib/settings'; import logger from '@server/logger'; import type { MigrationInterface, MixedList, QueryRunner } from 'typeorm'; const checkOverseerrMerge = async (): Promise => { // Load settings without running migrations const settings = await new Settings().load(undefined, true); if (settings.main.mediaServerType) { return false; // The application has already been migrated } const jellyseerMigrations = [ [1613379909641, 'AddJellyfinUserParams1613379909641'], [1613412948344, 'ServerTypeEnum1613412948344'], [1613670041760, 'AddJellyfinDeviceId1613670041760'], [1682608634546, 'AddWatchlists1682608634546'], [1699901142442, 'AddBlacklist1699901142442'], [1727907530757, 'AddUserSettingsStreamingRegion1727907530757'], [1734287582736, 'AddTelegramMessageThreadId1734287582736'], [1734805733535, 'AddOverrideRules1734805733535'], [1737320080282, 'AddBlacklistTagsColumn1737320080282'], [1743023610704, 'UpdateWebPush1743023610704'], [1743107645301, 'AddUserAvatarCacheFields1743107645301'], [1745492372230, 'UpdateWebPush1745492372230'], ]; // Open the database connection to get the migrations and close it afterwards const dbConnection = await dataSource.initialize(); const migrations = dbConnection.migrations; await dbConnection.destroy(); // We have to replace Jellyseerr migrations not working with Overseerr with a custom one try { // Filter out the Jellyseerr migrations and replace them with the Seerr migration // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const newMigrations: MixedList = migrations ?.filter( (migration) => !jellyseerMigrations .map(([, name]) => name) .includes(migration.name as string) || migration.name === 'UpdateWebPush1745492372230' ) .map((migration) => migration.name === 'UpdateWebPush1745492372230' ? SeerrMigration1759769291608 : migration.constructor ); dataSource.setOptions({ ...dataSource.options, migrations: newMigrations, }); } catch (error) { logger.error('Failed to load migrations for Overseerr merge', { label: 'Seerr Migration', error: error.message, }); process.exit(1); } // Reopen the database connection with the updated migrations await dataSource.initialize(); // Add fake migration record to prevent running the already existing Overseerr migration again try { await dbConnection.query( `INSERT INTO migrations (timestamp,name) VALUES ${jellyseerMigrations .map(([timestamp, name]) => `(${timestamp}, '${name}')`) .join(', ')} ` ); } catch (error) { logger.error('Failed to insert migration records', { label: 'Seerr Migration', error: error.message, }); process.exit(1); } // Manually run the migration to update the database schema if (process.env.NODE_ENV === 'production') { await dbConnection.query('PRAGMA foreign_keys=OFF'); await dbConnection.runMigrations(); await dbConnection.query('PRAGMA foreign_keys=ON'); } // MediaStatus.Blacklisted was added before MediaStatus.Deleted in Jellyseerr try { const mediaRepository = getRepository(Media); const mediaToUpdate = await mediaRepository.find({ where: { status: 6 } }); for (const media of mediaToUpdate) { media.status = 7; await mediaRepository.save(media); } const media4kToUpdate = await mediaRepository.find({ where: { status4k: 6 }, }); for (const media of media4kToUpdate) { media.status4k = 7; await mediaRepository.save(media); } } catch (error) { logger.error('Failed to update Media status from Blacklisted to Deleted', { label: 'Seerr Migration', error: error.message, }); process.exit(1); } // Set media server type to Plex (default for Overseerr) settings.main.mediaServerType = MediaServerType.PLEX; // Replace default Overseerr values with Seerr values if (settings.main.applicationTitle === 'Overseerr') { settings.main.applicationTitle = 'Seerr'; } if (settings.notifications.agents.email.options.senderName === 'Overseerr') { settings.notifications.agents.email.options.senderName = 'Seerr'; } // Save the updated settings try { await settings.save(); } catch (error) { logger.error('Failed to save updated settings for Overseerr merge', { label: 'Seerr Migration', error: error.message, }); process.exit(1); } logger.info('Yeah! Overseerr to Seerr migration completed successfully!', { label: 'Seerr Migration', }); return true; }; /** * Overseerr to Jellyseerr migration * Generated by TypeORM */ class SeerrMigration1759769291608 implements MigrationInterface { name = 'SeerrMigration1759769291608'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar DEFAULT (NULL), "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query( `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP))` ); await queryRunner.query( `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))` ); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "discoverRegion" varchar, "streamingRegion" varchar, "telegramMessageThreadId" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "jellyfinAuthToken" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); await queryRunner.query( `CREATE TABLE "temporary_season_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "season_request"` ); await queryRunner.query(`DROP TABLE "season_request"`); await queryRunner.query( `ALTER TABLE "temporary_season_request" RENAME TO "season_request"` ); await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); await queryRunner.query( `CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k" FROM "season"` ); await queryRunner.query(`DROP TABLE "season"`); await queryRunner.query( `ALTER TABLE "temporary_season" RENAME TO "season"` ); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "jellyfinAuthToken" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinUserId", "jellyfinDeviceId", "jellyfinAuthToken", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinUserId", "jellyfinDeviceId", "jellyfinAuthToken", "avatarETag", "avatarVersion" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); await queryRunner.query( `CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "issueId" integer, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"` ); await queryRunner.query(`DROP TABLE "issue_comment"`); await queryRunner.query( `ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"` ); await queryRunner.query( `CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"` ); await queryRunner.query(`DROP TABLE "issue"`); await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`); await queryRunner.query( `CREATE TABLE "temporary_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 (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP))` ); await queryRunner.query( `INSERT INTO "temporary_discover_slider"("id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt") SELECT "id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt" FROM "discover_slider"` ); await queryRunner.query(`DROP TABLE "discover_slider"`); await queryRunner.query( `ALTER TABLE "temporary_discover_slider" RENAME TO "discover_slider"` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"` ); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); await queryRunner.query( `CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"` ); await queryRunner.query(`DROP TABLE "watchlist"`); await queryRunner.query( `ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"` ); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); await queryRunner.query( `ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"` ); await queryRunner.query( `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))` ); await queryRunner.query( `INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"` ); await queryRunner.query(`DROP TABLE "temporary_watchlist"`); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` ); await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))` ); await queryRunner.query( `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` ); await queryRunner.query(`DROP TABLE "temporary_blacklist"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "discover_slider" RENAME TO "temporary_discover_slider"` ); await queryRunner.query( `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')))` ); await queryRunner.query( `INSERT INTO "discover_slider"("id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt") SELECT "id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt" FROM "temporary_discover_slider"` ); await queryRunner.query(`DROP TABLE "temporary_discover_slider"`); await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`); await queryRunner.query( `CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"` ); await queryRunner.query(`DROP TABLE "temporary_issue"`); await queryRunner.query( `ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"` ); await queryRunner.query( `CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"` ); await queryRunner.query(`DROP TABLE "temporary_issue_comment"`); await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "jellyfinAuthToken" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinUserId", "jellyfinDeviceId", "jellyfinAuthToken", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinUserId", "jellyfinDeviceId", "jellyfinAuthToken", "avatarETag", "avatarVersion" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar DEFAULT (NULL), "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "season" RENAME TO "temporary_season"` ); await queryRunner.query( `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, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k" FROM "temporary_season"` ); await queryRunner.query(`DROP TABLE "temporary_season"`); await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); await queryRunner.query( `ALTER TABLE "season_request" RENAME TO "temporary_season_request"` ); await queryRunner.query( `CREATE TABLE "season_request" ("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')), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "temporary_season_request"` ); await queryRunner.query(`DROP TABLE "temporary_season_request"`); await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); await queryRunner.query(`DROP TABLE "watchlist"`); await queryRunner.query(`DROP TABLE "override_rule"`); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar DEFAULT (NULL), "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); } } export default checkOverseerrMerge; ================================================ FILE: server/lib/permissions.ts ================================================ export enum Permission { NONE = 0, ADMIN = 2, MANAGE_SETTINGS = 4, MANAGE_USERS = 8, MANAGE_REQUESTS = 16, REQUEST = 32, VOTE = 64, AUTO_APPROVE = 128, AUTO_APPROVE_MOVIE = 256, AUTO_APPROVE_TV = 512, REQUEST_4K = 1024, REQUEST_4K_MOVIE = 2048, REQUEST_4K_TV = 4096, REQUEST_ADVANCED = 8192, REQUEST_VIEW = 16384, AUTO_APPROVE_4K = 32768, AUTO_APPROVE_4K_MOVIE = 65536, AUTO_APPROVE_4K_TV = 131072, REQUEST_MOVIE = 262144, REQUEST_TV = 524288, MANAGE_ISSUES = 1048576, VIEW_ISSUES = 2097152, CREATE_ISSUES = 4194304, AUTO_REQUEST = 8388608, AUTO_REQUEST_MOVIE = 16777216, AUTO_REQUEST_TV = 33554432, RECENT_VIEW = 67108864, WATCHLIST_VIEW = 134217728, MANAGE_BLOCKLIST = 268435456, VIEW_BLOCKLIST = 1073741824, } export interface PermissionCheckOptions { type: 'and' | 'or'; } /** * Takes a Permission and the users permission value and determines * if the user has access to the permission provided. If the user has * the admin permission, true will always be returned from this check! * * @param permissions Single permission or array of permissions * @param value users current permission value * @param options Extra options to control permission check behavior (mainly for arrays) */ export const hasPermission = ( permissions: Permission | Permission[], value: number, options: PermissionCheckOptions = { type: 'and' } ): boolean => { let total = 0; // If we are not checking any permissions, bail out and return true if (permissions === 0) { return true; } if (Array.isArray(permissions)) { if (value & Permission.ADMIN) { return true; } switch (options.type) { case 'and': return permissions.every((permission) => !!(value & permission)); case 'or': return permissions.some((permission) => !!(value & permission)); } } else { total = permissions; } return !!(value & Permission.ADMIN) || !!(value & total); }; ================================================ FILE: server/lib/refreshToken.ts ================================================ import PlexTvAPI from '@server/api/plextv'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import logger from '@server/logger'; class RefreshToken { public async run() { const userRepository = getRepository(User); const users = await userRepository .createQueryBuilder('user') .addSelect('user.plexToken') .where("user.plexToken != ''") .getMany(); for (const user of users) { await this.refreshUserToken(user); } } private async refreshUserToken(user: User) { if (!user.plexToken) { logger.warn('Skipping user refresh token for user without plex token', { label: 'Plex Refresh Token', user: user.displayName, }); return; } const plexTvApi = new PlexTvAPI(user.plexToken); plexTvApi.pingToken(); } } const refreshToken = new RefreshToken(); export default refreshToken; ================================================ FILE: server/lib/scanners/baseScanner.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import Season from '@server/entity/Season'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import AsyncLock from '@server/utils/asyncLock'; import { randomUUID } from 'crypto'; // Default scan rates (can be overidden) const BUNDLE_SIZE = 20; const UPDATE_RATE = 4 * 1000; export type StatusBase = { running: boolean; progress: number; total: number; }; export interface RunnableScanner { run: () => Promise; status: () => T & StatusBase; } export interface MediaIds { tmdbId: number; imdbId?: string; tvdbId?: number; isHama?: boolean; } interface ProcessOptions { is4k?: boolean; mediaAddedAt?: Date; ratingKey?: string; jellyfinMediaId?: string; imdbId?: string; serviceId?: number; externalServiceId?: number; externalServiceSlug?: string; title?: string; processing?: boolean; } export interface ProcessableSeason { seasonNumber: number; totalEpisodes: number; episodes: number; episodes4k: number; is4kOverride?: boolean; processing?: boolean; } class BaseScanner { private bundleSize; private updateRate; protected progress = 0; protected items: T[] = []; protected totalSize?: number = 0; protected scannerName: string; protected enable4kMovie = false; protected enable4kShow = false; protected sessionId: string; protected running = false; readonly asyncLock = new AsyncLock(); readonly tmdb = new TheMovieDb(); protected constructor( scannerName: string, { updateRate, bundleSize, }: { updateRate?: number; bundleSize?: number; } = {} ) { this.scannerName = scannerName; this.bundleSize = bundleSize ?? BUNDLE_SIZE; this.updateRate = updateRate ?? UPDATE_RATE; } private async getExisting(tmdbId: number, mediaType: MediaType) { const mediaRepository = getRepository(Media); const existing = await mediaRepository.findOne({ where: { tmdbId: tmdbId, mediaType }, }); return existing; } protected async processMovie( tmdbId: number, { is4k = false, mediaAddedAt, ratingKey, jellyfinMediaId, imdbId, serviceId, externalServiceId, externalServiceSlug, processing = false, title = 'Unknown Title', }: ProcessOptions = {} ): Promise { const mediaRepository = getRepository(Media); await this.asyncLock.dispatch(tmdbId, async () => { const existing = await this.getExisting(tmdbId, MediaType.MOVIE); if (existing) { let changedExisting = false; if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) { existing[is4k ? 'status4k' : 'status'] = !processing ? MediaStatus.AVAILABLE : existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED ? MediaStatus.DELETED : MediaStatus.PROCESSING; if (mediaAddedAt) { existing.mediaAddedAt = mediaAddedAt; } changedExisting = true; } if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) { existing.mediaAddedAt = mediaAddedAt; changedExisting = true; } if ( ratingKey && existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey ) { existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey; changedExisting = true; } if ( jellyfinMediaId && existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !== jellyfinMediaId ) { existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] = jellyfinMediaId; changedExisting = true; } if (imdbId && !existing.imdbId) { existing.imdbId = imdbId; changedExisting = true; } if ( serviceId !== undefined && existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId ) { existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId; changedExisting = true; } if ( externalServiceId !== undefined && existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !== externalServiceId ) { existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] = externalServiceId; changedExisting = true; } if ( externalServiceSlug !== undefined && existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== externalServiceSlug ) { existing[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = externalServiceSlug; changedExisting = true; } if (changedExisting) { await mediaRepository.save(existing); this.log( `Media for ${title} exists. Changes were detected and the title will be updated.`, 'info' ); } else { this.log(`Title already exists and no changes detected for ${title}`); } } else { const newMedia = new Media(); newMedia.tmdbId = tmdbId; newMedia.imdbId = imdbId; newMedia.status = !is4k && !processing ? MediaStatus.AVAILABLE : !is4k && processing ? MediaStatus.PROCESSING : MediaStatus.UNKNOWN; newMedia.status4k = is4k && this.enable4kMovie && !processing ? MediaStatus.AVAILABLE : is4k && this.enable4kMovie && processing ? MediaStatus.PROCESSING : MediaStatus.UNKNOWN; newMedia.mediaType = MediaType.MOVIE; newMedia.serviceId = !is4k ? serviceId : undefined; newMedia.serviceId4k = is4k ? serviceId : undefined; newMedia.externalServiceId = !is4k ? externalServiceId : undefined; newMedia.externalServiceId4k = is4k ? externalServiceId : undefined; newMedia.externalServiceSlug = !is4k ? externalServiceSlug : undefined; newMedia.externalServiceSlug4k = is4k ? externalServiceSlug : undefined; if (mediaAddedAt) { newMedia.mediaAddedAt = mediaAddedAt; } if (ratingKey) { newMedia.ratingKey = !is4k ? ratingKey : undefined; newMedia.ratingKey4k = is4k && this.enable4kMovie ? ratingKey : undefined; } if (jellyfinMediaId) { newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined; newMedia.jellyfinMediaId4k = is4k && this.enable4kMovie ? jellyfinMediaId : undefined; } await mediaRepository.save(newMedia); this.log(`Saved new media: ${title}`); } }); } /** * processShow takes a TMDB ID and an array of ProcessableSeasons, which * should include the total episodes a sesaon has + the total available * episodes that each season currently has. Unlike processMovie, this method * does not take an `is4k` option. We handle both the 4k _and_ non 4k status * in one method. * * Note: If 4k is not enable, ProcessableSeasons should combine their episode counts * into the normal episodes properties and avoid using the 4k properties. */ protected async processShow( tmdbId: number, tvdbId: number | undefined, seasons: ProcessableSeason[], { mediaAddedAt, ratingKey, jellyfinMediaId, serviceId, externalServiceId, externalServiceSlug, is4k = false, title = 'Unknown Title', }: ProcessOptions = {} ): Promise { const mediaRepository = getRepository(Media); await this.asyncLock.dispatch(tmdbId, async () => { const media = await this.getExisting(tmdbId, MediaType.TV); const newSeasons: Season[] = []; const currentStandardSeasonsAvailable = ( media?.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE ) ?? [] ).length; const current4kSeasonsAvailable = ( media?.seasons.filter( (season) => season.status4k === MediaStatus.AVAILABLE ) ?? [] ).length; for (const season of seasons) { const existingSeason = media?.seasons.find( (es) => es.seasonNumber === season.seasonNumber ); // We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts if (media && season.episodes > 0 && media.ratingKey !== ratingKey) { media.ratingKey = ratingKey; } if ( media && season.episodes4k > 0 && this.enable4kShow && media.ratingKey4k !== ratingKey ) { media.ratingKey4k = ratingKey; } if ( media && season.episodes > 0 && media.jellyfinMediaId !== jellyfinMediaId ) { media.jellyfinMediaId = jellyfinMediaId; } if ( media && season.episodes4k > 0 && this.enable4kShow && media.jellyfinMediaId4k !== jellyfinMediaId ) { media.jellyfinMediaId4k = jellyfinMediaId; } if (existingSeason) { // Here we update seasons if they already exist. // If the season is already marked as available, we // force it to stay available (to avoid competing scanners) existingSeason.status = (season.totalEpisodes === season.episodes && season.episodes > 0) || existingSeason.status === MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE : season.episodes > 0 ? MediaStatus.PARTIALLY_AVAILABLE : !season.is4kOverride && season.processing && existingSeason.status !== MediaStatus.DELETED ? MediaStatus.PROCESSING : !season.is4kOverride && !season.processing && season.episodes === 0 && existingSeason.status === MediaStatus.PROCESSING ? MediaStatus.UNKNOWN : existingSeason.status; // Same thing here, except we only do updates if 4k is enabled existingSeason.status4k = (this.enable4kShow && season.episodes4k === season.totalEpisodes && season.episodes4k > 0) || existingSeason.status4k === MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE : this.enable4kShow && season.episodes4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE : season.is4kOverride && season.processing && existingSeason.status4k !== MediaStatus.DELETED ? MediaStatus.PROCESSING : season.is4kOverride && !season.processing && season.episodes4k === 0 && existingSeason.status4k === MediaStatus.PROCESSING ? MediaStatus.UNKNOWN : existingSeason.status4k; } else { newSeasons.push( new Season({ seasonNumber: season.seasonNumber, status: season.totalEpisodes === season.episodes && season.episodes > 0 ? MediaStatus.AVAILABLE : season.episodes > 0 ? MediaStatus.PARTIALLY_AVAILABLE : !season.is4kOverride && season.processing ? MediaStatus.PROCESSING : MediaStatus.UNKNOWN, status4k: this.enable4kShow && season.totalEpisodes === season.episodes4k && season.episodes4k > 0 ? MediaStatus.AVAILABLE : this.enable4kShow && season.episodes4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE : season.is4kOverride && season.processing ? MediaStatus.PROCESSING : MediaStatus.UNKNOWN, }) ); } } if (media) { media.seasons = [...media.seasons, ...newSeasons]; const newStandardSeasonsAvailable = ( media.seasons.filter( (season) => season.status === MediaStatus.AVAILABLE ) ?? [] ).length; const new4kSeasonsAvailable = ( media.seasons.filter( (season) => season.status4k === MediaStatus.AVAILABLE ) ?? [] ).length; // If at least one new season has become available, update // the lastSeasonChange field so we can trigger notifications if (newStandardSeasonsAvailable > currentStandardSeasonsAvailable) { this.log( `Detected ${ newStandardSeasonsAvailable - currentStandardSeasonsAvailable } new standard season(s) for ${title}`, 'debug' ); media.lastSeasonChange = new Date(); if (mediaAddedAt) { media.mediaAddedAt = mediaAddedAt; } } if (new4kSeasonsAvailable > current4kSeasonsAvailable) { this.log( `Detected ${ new4kSeasonsAvailable - current4kSeasonsAvailable } new 4K season(s) for ${title}`, 'debug' ); media.lastSeasonChange = new Date(); } if (!media.mediaAddedAt && mediaAddedAt) { media.mediaAddedAt = mediaAddedAt; } if (serviceId !== undefined) { media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId; } if (externalServiceId !== undefined) { media[is4k ? 'externalServiceId4k' : 'externalServiceId'] = externalServiceId; } if (externalServiceSlug !== undefined) { media[is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = externalServiceSlug; } const nonSpecialSeasons = media.seasons.filter( (s) => s.seasonNumber !== 0 ); // Check the actual season objects instead scanner input // to determine overall availability status const isAllStandardSeasonsAvailable = nonSpecialSeasons.length > 0 && nonSpecialSeasons.every((s) => s.status === MediaStatus.AVAILABLE); const isAll4kSeasonsAvailable = nonSpecialSeasons.length > 0 && nonSpecialSeasons.every((s) => s.status4k === MediaStatus.AVAILABLE); media.status = isAllStandardSeasonsAvailable ? MediaStatus.AVAILABLE : media.seasons.some( (season) => season.status === MediaStatus.PARTIALLY_AVAILABLE || season.status === MediaStatus.AVAILABLE ) ? MediaStatus.PARTIALLY_AVAILABLE : (!seasons.length && media.status !== MediaStatus.DELETED) || media.seasons.some( (season) => season.status === MediaStatus.PROCESSING ) ? MediaStatus.PROCESSING : media.status === MediaStatus.DELETED ? MediaStatus.DELETED : MediaStatus.UNKNOWN; media.status4k = isAll4kSeasonsAvailable && this.enable4kShow ? MediaStatus.AVAILABLE : this.enable4kShow && media.seasons.some( (season) => season.status4k === MediaStatus.PARTIALLY_AVAILABLE || season.status4k === MediaStatus.AVAILABLE ) ? MediaStatus.PARTIALLY_AVAILABLE : (!seasons.length && media.status4k !== MediaStatus.DELETED) || media.seasons.some( (season) => season.status4k === MediaStatus.PROCESSING ) ? MediaStatus.PROCESSING : media.status4k === MediaStatus.DELETED ? MediaStatus.DELETED : MediaStatus.UNKNOWN; await mediaRepository.save(media); this.log(`Updating existing title: ${title}`); } else { // For new media, check actual newSeasons objects instead of scanner // input to determine overall availability status const nonSpecialNewSeasons = newSeasons.filter( (s) => s.seasonNumber !== 0 ); const isAllStandardSeasonsAvailable = nonSpecialNewSeasons.length > 0 && nonSpecialNewSeasons.every((s) => s.status === MediaStatus.AVAILABLE); const isAll4kSeasonsAvailable = nonSpecialNewSeasons.length > 0 && nonSpecialNewSeasons.every( (s) => s.status4k === MediaStatus.AVAILABLE ); const newMedia = new Media({ mediaType: MediaType.TV, seasons: newSeasons, tmdbId, tvdbId, mediaAddedAt, serviceId: !is4k ? serviceId : undefined, serviceId4k: is4k ? serviceId : undefined, externalServiceId: !is4k ? externalServiceId : undefined, externalServiceId4k: is4k ? externalServiceId : undefined, externalServiceSlug: !is4k ? externalServiceSlug : undefined, externalServiceSlug4k: is4k ? externalServiceSlug : undefined, ratingKey: newSeasons.some( (sn) => sn.status === MediaStatus.PARTIALLY_AVAILABLE || sn.status === MediaStatus.AVAILABLE ) ? ratingKey : undefined, ratingKey4k: this.enable4kShow && newSeasons.some( (sn) => sn.status4k === MediaStatus.PARTIALLY_AVAILABLE || sn.status4k === MediaStatus.AVAILABLE ) ? ratingKey : undefined, jellyfinMediaId: newSeasons.some( (sn) => sn.status === MediaStatus.PARTIALLY_AVAILABLE || sn.status === MediaStatus.AVAILABLE ) ? jellyfinMediaId : undefined, jellyfinMediaId4k: this.enable4kShow && newSeasons.some( (sn) => sn.status4k === MediaStatus.PARTIALLY_AVAILABLE || sn.status4k === MediaStatus.AVAILABLE ) ? jellyfinMediaId : undefined, status: isAllStandardSeasonsAvailable ? MediaStatus.AVAILABLE : newSeasons.some( (season) => season.status === MediaStatus.PARTIALLY_AVAILABLE || season.status === MediaStatus.AVAILABLE ) ? MediaStatus.PARTIALLY_AVAILABLE : newSeasons.some( (season) => season.status === MediaStatus.PROCESSING ) ? MediaStatus.PROCESSING : MediaStatus.UNKNOWN, status4k: isAll4kSeasonsAvailable && this.enable4kShow ? MediaStatus.AVAILABLE : this.enable4kShow && newSeasons.some( (season) => season.status4k === MediaStatus.PARTIALLY_AVAILABLE || season.status4k === MediaStatus.AVAILABLE ) ? MediaStatus.PARTIALLY_AVAILABLE : newSeasons.some( (season) => season.status4k === MediaStatus.PROCESSING ) ? MediaStatus.PROCESSING : MediaStatus.UNKNOWN, }); await mediaRepository.save(newMedia); this.log(`Saved ${title}`); } }); } /** * Call startRun from child class whenever a run is starting to * ensure required values are set * * Returns the session ID which is requried for the cleanup method */ protected startRun(): string { const settings = getSettings(); const sessionId = randomUUID(); this.sessionId = sessionId; this.log('Scan starting', 'info', { sessionId }); this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k); if (this.enable4kMovie) { this.log( 'At least one 4K Radarr server was detected. 4K movie detection is now enabled', 'info' ); } this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k); if (this.enable4kShow) { this.log( 'At least one 4K Sonarr server was detected. 4K series detection is now enabled', 'info' ); } this.running = true; return sessionId; } /** * Call at end of run loop to perform cleanup */ protected endRun(sessionId: string): void { if (this.sessionId === sessionId) { this.running = false; } } public cancel(): void { this.running = false; } protected async loop( processFn: (item: T) => Promise, { start = 0, end = this.bundleSize, sessionId, }: { start?: number; end?: number; sessionId?: string; } = {} ): Promise { const slicedItems = this.items.slice(start, end); if (!this.running) { throw new Error('Sync was aborted.'); } if (this.sessionId !== sessionId) { throw new Error('New session was started. Old session aborted.'); } if (start < this.items.length) { this.progress = start; await this.processItems(processFn, slicedItems); await new Promise((resolve, reject) => setTimeout(() => { this.loop(processFn, { start: start + this.bundleSize, end: end + this.bundleSize, sessionId, }) .then(() => resolve()) .catch((e) => reject(new Error(e.message))); }, this.updateRate) ); } } private async processItems( processFn: (items: T) => Promise, items: T[] ) { await Promise.all( items.map(async (item) => { await processFn(item); }) ); } protected log( message: string, level: 'info' | 'error' | 'debug' | 'warn' = 'debug', optional?: Record ): void { logger[level](message, { label: this.scannerName, ...optional }); } get protectedUpdateRate(): number { return this.updateRate; } get protectedBundleSize(): number { return this.bundleSize; } } export default BaseScanner; ================================================ FILE: server/lib/scanners/jellyfin/index.ts ================================================ import animeList from '@server/api/animelist'; import type { JellyfinLibraryItem, JellyfinLibraryItemExtended, } from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin'; import { getMetadataProvider } from '@server/api/metadata'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword, TmdbTvDetails, } from '@server/api/themoviedb/interfaces'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { ProcessableSeason, RunnableScanner, StatusBase, } from '@server/lib/scanners/baseScanner'; import BaseScanner from '@server/lib/scanners/baseScanner'; import type { Library } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { getHostname } from '@server/utils/getHostname'; import { uniqWith } from 'lodash'; interface JellyfinSyncStatus extends StatusBase { currentLibrary: Library; libraries: Library[]; } class JellyfinScanner extends BaseScanner implements RunnableScanner { private jfClient: JellyfinAPI; private libraries: Library[]; private currentLibrary: Library; private isRecentOnly = false; private processedAnidbSeason: Map>; constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { super('Jellyfin Sync'); this.isRecentOnly = isRecentOnly ?? false; } private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{ tmdbId: number; imdbId?: string; metadata: JellyfinLibraryItemExtended; } | null> { let metadata = await this.jfClient.getItemData(jellyfinitem.Id); if (!metadata?.Id) { this.log('No Id metadata for this title. Skipping', 'debug', { jellyfinItemId: jellyfinitem.Id, }); return null; } const anidbId = Number(metadata.ProviderIds.AniDB ?? null); let tmdbId = Number( metadata.ProviderIds.Tmdb || metadata.ProviderIds.TheMovieDb || null ); let imdbId = metadata.ProviderIds.Imdb; // We use anidb only if we have the anidbId and nothing else if (anidbId && !imdbId && !tmdbId) { const result = animeList.getFromAnidbId(anidbId); tmdbId = Number(result?.tmdbId ?? null); imdbId = result?.imdbId; } if (imdbId && !tmdbId) { const tmdbMovie = await this.tmdb.getMediaByImdbId({ imdbId: imdbId, }); tmdbId = tmdbMovie.id; } if (!tmdbId) { throw new Error('Unable to find TMDb ID'); } // With AniDB we can have mixed libraries with movies in a "show" library // We take the first episode of the first season (the movie) and use it to // get more information, like the MediaSource if (anidbId && metadata.Type === 'Series') { const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find( (md) => { return md.IndexNumber === 1; } ); if (!season) { this.log('No season found for anidb movie', 'debug', { jellyfinitem, }); return null; } const episodes = await this.jfClient.getEpisodes( jellyfinitem.Id, season.Id ); if (!episodes[0]) { this.log('No episode found for anidb movie', 'debug', { jellyfinitem, }); return null; } metadata = await this.jfClient.getItemData(episodes[0].Id); if (!metadata) { this.log('No metadata found for anidb movie', 'debug', { jellyfinitem, }); return null; } } return { tmdbId, imdbId, metadata }; } private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) { try { const extracted = await this.extractMovieIds(jellyfinitem); if (!extracted) return; const { tmdbId, imdbId, metadata } = extracted; const has4k = metadata.MediaSources?.some((MediaSource) => { return MediaSource.MediaStreams.filter( (MediaStream) => MediaStream.Type === 'Video' ).some((MediaStream) => { return (MediaStream.Width ?? 0) > 2000; }); }); const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => { return MediaSource.MediaStreams.filter( (MediaStream) => MediaStream.Type === 'Video' ).some((MediaStream) => { return (MediaStream.Width ?? 0) <= 2000; }); }); const mediaAddedAt = metadata.DateCreated ? new Date(metadata.DateCreated) : undefined; if (hasOtherResolution || (!this.enable4kMovie && has4k)) { await this.processMovie(tmdbId, { is4k: false, mediaAddedAt, jellyfinMediaId: metadata.Id, imdbId, title: metadata.Name, }); } if (has4k && this.enable4kMovie) { await this.processMovie(tmdbId, { is4k: true, mediaAddedAt, jellyfinMediaId: metadata.Id, imdbId, title: metadata.Name, }); } } catch (e) { this.log( `Failed to process Jellyfin item, id: ${jellyfinitem.Id}`, 'error', { errorMessage: e.message, jellyfinitem, } ); } } private async getTvShow({ tmdbId, tvdbId, }: { tmdbId?: number; tvdbId?: number; }): Promise { let tvShow; if (tmdbId) { tvShow = await this.tmdb.getTvShow({ tvId: Number(tmdbId), }); } else if (tvdbId) { tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId), }); } else { throw new Error('No ID provided'); } const metadataProvider = tvShow.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID ) ? await getMetadataProvider('anime') : await getMetadataProvider('tv'); if (!(metadataProvider instanceof TheMovieDb)) { tvShow = await metadataProvider.getTvShow({ tvId: Number(tmdbId), }); } return tvShow; } private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) { let tvShow: TmdbTvDetails | null = null; try { const Id = jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id; const metadata = await this.jfClient.getItemData(Id); if (!metadata?.Id) { this.log('No Id metadata for this title. Skipping', 'debug', { jellyfinItemId: jellyfinitem.Id, }); return; } if (metadata.ProviderIds.Tmdb || metadata.ProviderIds.TheMovieDb) { try { tvShow = await this.getTvShow({ tmdbId: Number( metadata.ProviderIds.Tmdb || metadata.ProviderIds.TheMovieDb ), }); } catch { this.log('Unable to find TMDb ID for this title.', 'debug', { jellyfinitem, }); } } if (!tvShow && metadata.ProviderIds.Tvdb) { try { tvShow = await this.getTvShow({ tvdbId: Number(metadata.ProviderIds.Tvdb), }); } catch { this.log('Unable to find TVDb ID for this title.', 'debug', { jellyfinitem, }); } } let tvdbSeasonFromAnidb: number | undefined; if (!tvShow && metadata.ProviderIds.AniDB) { const anidbId = Number(metadata.ProviderIds.AniDB); const result = animeList.getFromAnidbId(anidbId); tvdbSeasonFromAnidb = result?.tvdbSeason; if (result?.tvdbId) { try { tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: result.tvdbId, }); } catch { this.log('Unable to find AniDB ID for this title.', 'debug', { jellyfinitem, }); } } // With AniDB we can have mixed libraries with movies in a "show" library else if (result?.imdbId || result?.tmdbId) { await this.processJellyfinMovie(jellyfinitem); return; } } if (tvShow) { const seasons = tvShow.seasons; const jellyfinSeasons = await this.jfClient.getSeasons(Id); const processableSeasons: ProcessableSeason[] = []; const settings = getSettings(); const filteredSeasons = settings.main.enableSpecialEpisodes ? seasons : seasons.filter((sn) => sn.season_number !== 0); for (const season of filteredSeasons) { const matchedJellyfinSeason = jellyfinSeasons.find((md) => { if (tvdbSeasonFromAnidb) { // In AniDB we don't have the concept of seasons, // we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials). // We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and // md.IndexNumber === 1 to be sure to find the correct season on jellyfin return ( tvdbSeasonFromAnidb === season.season_number && md.IndexNumber === 1 ); } else { return Number(md.IndexNumber) === season.season_number; } }); // Check if we found the matching season and it has all the available episodes if (matchedJellyfinSeason) { let totalStandard = 0; let total4k = 0; if (!this.enable4kShow) { const episodes = await this.jfClient.getEpisodes( Id, matchedJellyfinSeason.Id ); for (const episode of episodes) { let episodeCount = 1; // count number of combined episodes if ( episode.IndexNumber !== undefined && episode.IndexNumberEnd !== undefined ) { episodeCount = episode.IndexNumberEnd - episode.IndexNumber + 1; } totalStandard += episodeCount; } } else { // 4K detection enabled - request media info to check resolution const episodes = await this.jfClient.getEpisodes( Id, matchedJellyfinSeason.Id, { includeMediaInfo: true } ); for (const episode of episodes) { let episodeCount = 1; // count number of combined episodes if ( episode.IndexNumber !== undefined && episode.IndexNumberEnd !== undefined ) { episodeCount = episode.IndexNumberEnd - episode.IndexNumber + 1; } const has4k = episode.MediaSources?.some((MediaSource) => MediaSource.MediaStreams.some( (MediaStream) => MediaStream.Type === 'Video' && (MediaStream.Width ?? 0) > 2000 ) ); const hasStandard = episode.MediaSources?.some((MediaSource) => MediaSource.MediaStreams.some( (MediaStream) => MediaStream.Type === 'Video' && (MediaStream.Width ?? 0) <= 2000 ) ); // Count in both if episode has both versions // TODO: Make this more robust in the future // Currently, this detection is based solely on file resolution, not which // Radarr/Sonarr instance the file came from. If a 4K request results in // 1080p files (no 4K release available yet), those files will be counted // as "standard" even though they're in the 4K library. This can cause // non-4K users to see content as "available" when they can't access it. // See issue https://github.com/seerr-team/seerr/issues/1744 for details. if (hasStandard) totalStandard += episodeCount; if (has4k) total4k += episodeCount; } } // With AniDB we can have multiple shows for one season, so we need to save // the episode from all the jellyfin entries to get the total if (tvdbSeasonFromAnidb) { let show = this.processedAnidbSeason.get(tvShow.id); if (!show) { show = new Map([[season.season_number, totalStandard]]); this.processedAnidbSeason.set(tvShow.id, show); } else { const currentCount = show.get(season.season_number) ?? 0; const newCount = currentCount + totalStandard; show.set(season.season_number, newCount); totalStandard = newCount; } } processableSeasons.push({ seasonNumber: season.season_number, totalEpisodes: season.episode_count, episodes: totalStandard, episodes4k: total4k, }); } else { processableSeasons.push({ seasonNumber: season.season_number, totalEpisodes: season.episode_count, episodes: 0, episodes4k: 0, }); } } await this.processShow( tvShow.id, tvShow.external_ids?.tvdb_id, processableSeasons, { mediaAddedAt: metadata.DateCreated ? new Date(metadata.DateCreated) : undefined, jellyfinMediaId: Id, title: tvShow.name, } ); } else { this.log( `No information found for the show: ${metadata.Name}`, 'debug', { jellyfinitem, } ); } } catch (e) { this.log( `Failed to process Jellyfin item. Id: ${ jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id }`, 'error', { errorMessage: e.message, jellyfinitem } ); } } private async processItem(item: JellyfinLibraryItem): Promise { if (item.Type === 'Movie') { await this.processJellyfinMovie(item); } else if (item.Type === 'Series') { await this.processJellyfinShow(item); } } public async run(): Promise { const settings = getSettings(); if ( settings.main.mediaServerType != MediaServerType.JELLYFIN && settings.main.mediaServerType != MediaServerType.EMBY ) { return; } const sessionId = this.startRun(); try { const userRepository = getRepository(User); const admin = await userRepository.findOne({ where: { id: 1 }, select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], order: { id: 'ASC' }, }); if (!admin) { return this.log('No admin configured. Jellyfin sync skipped.', 'warn'); } this.jfClient = new JellyfinAPI( getHostname(), settings.jellyfin.apiKey, admin.jellyfinDeviceId ); this.jfClient.setUserId(admin.jellyfinUserId ?? ''); this.libraries = settings.jellyfin.libraries.filter( (library) => library.enabled ); await animeList.sync(); if (this.isRecentOnly) { for (const library of this.libraries) { this.currentLibrary = library; // Reset AniDB season tracking per library this.processedAnidbSeason = new Map(); this.log( `Beginning to process recently added for library: ${library.name}`, 'info' ); const libraryItems = await this.jfClient.getRecentlyAdded(library.id); // Bundle items up by rating keys this.items = uniqWith(libraryItems, (mediaA, mediaB) => { if (mediaA.SeriesId && mediaB.SeriesId) { return mediaA.SeriesId === mediaB.SeriesId; } if (mediaA.SeasonId && mediaB.SeasonId) { return mediaA.SeasonId === mediaB.SeasonId; } return mediaA.Id === mediaB.Id; }); await this.loop(this.processItem.bind(this), { sessionId }); } } else { for (const library of this.libraries) { this.currentLibrary = library; // Reset AniDB season tracking per library this.processedAnidbSeason = new Map(); this.log(`Beginning to process library: ${library.name}`, 'info'); this.items = await this.jfClient.getLibraryContents(library.id); await this.loop(this.processItem.bind(this), { sessionId }); } } this.log( this.isRecentOnly ? 'Recently Added Scan Complete' : 'Full Scan Complete', 'info' ); } catch (e) { this.log('Sync interrupted', 'error', { errorMessage: e.message }); } finally { this.endRun(sessionId); } } public status(): JellyfinSyncStatus { return { running: this.running, progress: this.progress, total: this.items.length, currentLibrary: this.currentLibrary, libraries: this.libraries, }; } } export const jellyfinFullScanner = new JellyfinScanner(); export const jellyfinRecentScanner = new JellyfinScanner({ isRecentOnly: true, }); ================================================ FILE: server/lib/scanners/plex/index.ts ================================================ import animeList from '@server/api/animelist'; import { getMetadataProvider } from '@server/api/metadata'; import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword, TmdbTvDetails, } from '@server/api/themoviedb/interfaces'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import cacheManager from '@server/lib/cache'; import type { MediaIds, ProcessableSeason, RunnableScanner, StatusBase, } from '@server/lib/scanners/baseScanner'; import BaseScanner from '@server/lib/scanners/baseScanner'; import type { Library } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { uniqWith } from 'lodash'; const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/); const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/); const plexRegex = new RegExp(/plex:\/\//); // Hama agent uses ASS naming, see details here: // https://github.com/ZeroQI/Absolute-Series-Scanner/blob/master/README.md#forcing-the-movieseries-id const hamaTvdbRegex = new RegExp(/hama:\/\/tvdb[0-9]?-([0-9]+)/); const hamaAnidbRegex = new RegExp(/hama:\/\/anidb[0-9]?-([0-9]+)/); const HAMA_AGENT = 'com.plexapp.agents.hama'; type SyncStatus = StatusBase & { currentLibrary: Library; libraries: Library[]; }; class PlexScanner extends BaseScanner implements RunnableScanner { private plexClient: PlexAPI; private libraries: Library[]; private currentLibrary: Library; private isRecentOnly = false; public constructor(isRecentOnly = false) { super('Plex Scan', { bundleSize: 50 }); this.isRecentOnly = isRecentOnly; } public status(): SyncStatus { return { running: this.running, progress: this.progress, total: this.totalSize ?? 0, currentLibrary: this.currentLibrary, libraries: this.libraries, }; } public async run(): Promise { const settings = getSettings(); const sessionId = this.startRun(); try { const userRepository = getRepository(User); const admin = await userRepository.findOne({ select: { id: true, plexToken: true }, where: { id: 1 }, }); if (!admin) { return this.log('No admin configured. Plex scan skipped.', 'warn'); } this.plexClient = new PlexAPI({ plexToken: admin.plexToken }); this.libraries = settings.plex.libraries.filter( (library) => library.enabled ); const hasHama = await this.hasHamaAgent(); if (hasHama) { await animeList.sync(); } if (this.isRecentOnly) { for (const library of this.libraries) { this.currentLibrary = library; this.log( `Beginning to process recently added for library: ${library.name}`, 'info', { lastScan: library.lastScan } ); const libraryItems = await this.plexClient.getRecentlyAdded( library.id, library.lastScan ? { // We remove 10 minutes from the last scan as a buffer addedAt: library.lastScan - 1000 * 60 * 10, } : undefined, library.type ); // Bundle items up by rating keys this.items = uniqWith(libraryItems, (mediaA, mediaB) => { if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) { return ( mediaA.grandparentRatingKey === mediaB.grandparentRatingKey ); } if (mediaA.parentRatingKey && mediaB.parentRatingKey) { return mediaA.parentRatingKey === mediaB.parentRatingKey; } return mediaA.ratingKey === mediaB.ratingKey; }); await this.loop(this.processItem.bind(this), { sessionId }); // After run completes, update last scan time const newLibraries = settings.plex.libraries.map((lib) => { if (lib.id === library.id) { return { ...lib, lastScan: Date.now(), }; } return lib; }); settings.plex.libraries = newLibraries; await settings.save(); } } else { for (const library of this.libraries) { this.currentLibrary = library; this.log(`Beginning to process library: ${library.name}`, 'info'); await this.paginateLibrary(library, { sessionId }); } } this.log( this.isRecentOnly ? 'Recently Added Scan Complete' : 'Full Scan Complete', 'info' ); } catch (e) { this.log('Scan interrupted', 'error', { errorMessage: e.message, }); } finally { this.endRun(sessionId); } } private async paginateLibrary( library: Library, { start = 0, sessionId }: { start?: number; sessionId: string } ) { if (!this.running) { throw new Error('Sync was aborted.'); } if (this.sessionId !== sessionId) { throw new Error('New session was started. Old session aborted.'); } const response = await this.plexClient.getLibraryContents(library.id, { size: this.protectedBundleSize, offset: start, }); this.progress = start; this.totalSize = response.totalSize; if (response.items.length === 0) { return; } await Promise.all( response.items.map(async (item) => { await this.processItem(item); }) ); if (response.items.length < this.protectedBundleSize) { return; } await new Promise((resolve, reject) => setTimeout(() => { this.paginateLibrary(library, { start: start + this.protectedBundleSize, sessionId, }) .then(() => resolve()) .catch((e) => reject(new Error(e.message))); }, this.protectedUpdateRate) ); } private async processItem(plexitem: PlexLibraryItem) { try { if (plexitem.type === 'movie') { await this.processPlexMovie(plexitem); } else if ( plexitem.type === 'show' || plexitem.type === 'episode' || plexitem.type === 'season' ) { await this.processPlexShow(plexitem); } } catch (e) { this.log('Failed to process Plex media', 'error', { errorMessage: e.message, title: plexitem.title, }); } } private async processPlexMovie(plexitem: PlexLibraryItem) { const mediaIds = await this.getMediaIds(plexitem); const has4k = plexitem.Media.some( (media) => media.videoResolution === '4k' ); await this.processMovie(mediaIds.tmdbId, { is4k: has4k && this.enable4kMovie, mediaAddedAt: new Date(plexitem.addedAt * 1000), ratingKey: plexitem.ratingKey, title: plexitem.title, }); } private async processPlexMovieByTmdbId( plexitem: PlexMetadata, tmdbId: number ) { const has4k = plexitem.Media.some( (media) => media.videoResolution === '4k' ); await this.processMovie(tmdbId, { is4k: has4k && this.enable4kMovie, mediaAddedAt: new Date(plexitem.addedAt * 1000), ratingKey: plexitem.ratingKey, title: plexitem.title, }); } private async getTvShow({ tmdbId, tvdbId, }: { tmdbId?: number; tvdbId?: number; }): Promise { let tvShow; if (tmdbId) { tvShow = await this.tmdb.getTvShow({ tvId: Number(tmdbId), }); } else if (tvdbId) { tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: Number(tvdbId), }); } else { throw new Error('No ID provided'); } const metadataProvider = tvShow.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID ) ? await getMetadataProvider('anime') : await getMetadataProvider('tv'); if (!(metadataProvider instanceof TheMovieDb)) { tvShow = await metadataProvider.getTvShow({ tvId: Number(tmdbId), }); } return tvShow; } private async processPlexShow(plexitem: PlexLibraryItem) { const ratingKey = plexitem.grandparentRatingKey ?? plexitem.parentRatingKey ?? plexitem.ratingKey; const metadata = await this.plexClient.getMetadata(ratingKey, { includeChildren: true, }); const mediaIds = await this.getMediaIds(metadata); // If the media is from HAMA, and doesn't have a TVDb ID, we will treat it // as a special HAMA movie if (mediaIds.tmdbId && !mediaIds.tvdbId && mediaIds.isHama) { this.processHamaMovie(metadata, mediaIds.tmdbId); return; } // If the media is from HAMA and we have a TVDb ID, we will attempt // to process any specials that may exist if (mediaIds.tvdbId && mediaIds.isHama) { await this.processHamaSpecials(metadata, mediaIds.tvdbId); } const tvShow = await this.getTvShow({ tmdbId: mediaIds.tmdbId, }); const seasons = tvShow.seasons; const processableSeasons: ProcessableSeason[] = []; const settings = getSettings(); const filteredSeasons = settings.main.enableSpecialEpisodes ? seasons : seasons.filter((sn) => sn.season_number !== 0); for (const season of filteredSeasons) { const matchedPlexSeason = metadata.Children?.Metadata.find( (md) => Number(md.index) === season.season_number ); if (matchedPlexSeason) { // If we have a matched Plex season, get its children metadata so we can check details const episodes = await this.plexClient.getChildrenMetadata( matchedPlexSeason.ratingKey ); // Total episodes that are in standard definition (not 4k) const totalStandard = episodes.filter((episode) => !this.enable4kShow ? true : episode.Media.some((media) => media.videoResolution !== '4k') ).length; // Total episodes that are in 4k const total4k = this.enable4kShow ? episodes.filter((episode) => episode.Media.some((media) => media.videoResolution === '4k') ).length : 0; processableSeasons.push({ seasonNumber: season.season_number, episodes: totalStandard, episodes4k: total4k, totalEpisodes: season.episode_count, }); } else { processableSeasons.push({ seasonNumber: season.season_number, episodes: 0, episodes4k: 0, totalEpisodes: season.episode_count, }); } } await this.processShow( mediaIds.tmdbId, mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id, processableSeasons, { mediaAddedAt: new Date(metadata.addedAt * 1000), ratingKey: ratingKey, title: metadata.title, } ); } private async getMediaIds(plexitem: PlexLibraryItem): Promise { let mediaIds: Partial = {}; // Check if item is using new plex movie/tv agent if (plexitem.guid.match(plexRegex)) { const guidCache = cacheManager.getCache('plexguid'); const cachedGuids = guidCache.data.get(plexitem.ratingKey); if (cachedGuids) { this.log('GUIDs are cached. Skipping metadata request.', 'debug', { mediaIds: cachedGuids, title: plexitem.title, }); mediaIds = cachedGuids; } const metadata = plexitem.Guid && plexitem.Guid.length > 0 ? plexitem : await this.plexClient.getMetadata(plexitem.ratingKey); // If there is no Guid field at all, then we bail if (!metadata.Guid) { throw new Error( 'No Guid metadata for this title. Skipping. (Try refreshing the metadata in Plex for this media!)' ); } // Map all IDs to MediaId object metadata.Guid.forEach((ref) => { if (ref.id.match(imdbRegex)) { mediaIds.imdbId = ref.id.match(imdbRegex)?.[1] ?? undefined; } else if (ref.id.match(tmdbRegex)) { const tmdbMatch = ref.id.match(tmdbRegex)?.[1]; mediaIds.tmdbId = Number(tmdbMatch); } else if (ref.id.match(tvdbRegex)) { const tvdbMatch = ref.id.match(tvdbRegex)?.[1]; mediaIds.tvdbId = Number(tvdbMatch); } }); // If we got an IMDb ID, but no TMDB ID, lookup the TMDB ID with the IMDb ID if (mediaIds.imdbId && !mediaIds.tmdbId) { const tmdbMedia = await this.tmdb.getMediaByImdbId({ imdbId: mediaIds.imdbId, }); mediaIds.tmdbId = tmdbMedia.id; } if (mediaIds.tvdbId && !mediaIds.tmdbId) { const show = await this.tmdb.getShowByTvdbId({ tvdbId: mediaIds.tvdbId, }); mediaIds.tmdbId = show.id; } // Cache GUIDs guidCache.data.set(plexitem.ratingKey, mediaIds); // Check if the agent is IMDb } else if (plexitem.guid.match(imdbRegex)) { const imdbMatch = plexitem.guid.match(imdbRegex); if (imdbMatch) { mediaIds.imdbId = imdbMatch[1]; const tmdbMedia = await this.tmdb.getMediaByImdbId({ imdbId: mediaIds.imdbId, }); mediaIds.tmdbId = tmdbMedia.id; } // Check if the agent is TMDB } else if (plexitem.guid.match(tmdbRegex)) { const tmdbMatch = plexitem.guid.match(tmdbRegex); if (tmdbMatch) { mediaIds.tmdbId = Number(tmdbMatch[1]); } // Check if the agent is TVDb } else if (plexitem.guid.match(tvdbRegex)) { const matchedtvdb = plexitem.guid.match(tvdbRegex); // If we can find a tvdb Id, use it to get the full tmdb show details if (matchedtvdb) { const show = await this.tmdb.getShowByTvdbId({ tvdbId: Number(matchedtvdb[1]), }); mediaIds.tvdbId = Number(matchedtvdb[1]); mediaIds.tmdbId = show.id; } // Check if the agent (for shows) is TMDB } else if (plexitem.guid.match(tmdbShowRegex)) { const matchedtmdb = plexitem.guid.match(tmdbShowRegex); if (matchedtmdb) { mediaIds.tmdbId = Number(matchedtmdb[1]); } // Check for HAMA (with TVDb guid) } else if (plexitem.guid.match(hamaTvdbRegex)) { const matchedtvdb = plexitem.guid.match(hamaTvdbRegex); if (matchedtvdb) { const show = await this.tmdb.getShowByTvdbId({ tvdbId: Number(matchedtvdb[1]), }); mediaIds.tvdbId = Number(matchedtvdb[1]); mediaIds.tmdbId = show.id; // Set isHama to true, so we can know to add special processing to this item mediaIds.isHama = true; } // Check for HAMA (with anidb guid) } else if (plexitem.guid.match(hamaAnidbRegex)) { const matchedhama = plexitem.guid.match(hamaAnidbRegex); if (!animeList.isLoaded()) { this.log( `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`, 'warn', { title: plexitem.title } ); } else if (matchedhama) { const anidbId = Number(matchedhama[1]); const result = animeList.getFromAnidbId(anidbId); let tvShow: TmdbTvDetails | null = null; // Set isHama to true, so we can know to add special processing to this item mediaIds.isHama = true; // First try to lookup the show by TVDb ID if (result?.tvdbId) { const extResponse = await this.tmdb.getByExternalId({ externalId: result.tvdbId, type: 'tvdb', }); if (extResponse.tv_results[0]) { tvShow = await this.tmdb.getTvShow({ tvId: extResponse.tv_results[0].id, }); mediaIds.tvdbId = result.tvdbId; mediaIds.tmdbId = tvShow.id; } else { this.log( `Missing TVDB ${result.tvdbId} entry in TMDB for AniDB ${anidbId}` ); } } if (!tvShow) { // if lookup of tvshow above failed, then try movie with tmdbid/imdbid // note - some tv shows have imdbid set too, that's why this need to go second if (result?.tmdbId) { mediaIds.tmdbId = result.tmdbId; mediaIds.imdbId = result?.imdbId; } else if (result?.imdbId) { const tmdbMovie = await this.tmdb.getMediaByImdbId({ imdbId: result.imdbId, }); mediaIds.tmdbId = tmdbMovie.id; mediaIds.imdbId = result.imdbId; } } } } if (!mediaIds.tmdbId) { throw new Error('Unable to find TMDB ID'); } // We check above if we have the TMDB ID, so we can safely assert the type below return mediaIds as MediaIds; } // movies with hama agent actually are tv shows with at least one episode in it // try to get first episode of any season - cannot hardcode season or episode number // because sometimes user can have it in other season/ep than s01e01 private async processHamaMovie(metadata: PlexMetadata, tmdbId: number) { const season = metadata.Children?.Metadata[0]; if (season) { const episodes = await this.plexClient.getChildrenMetadata( season.ratingKey ); if (episodes) { await this.processPlexMovieByTmdbId(episodes[0], tmdbId); } } } // this adds all movie episodes from specials season for Hama agent private async processHamaSpecials(metadata: PlexMetadata, tvdbId: number) { const specials = metadata.Children?.Metadata.find( (md) => Number(md.index) === 0 ); if (specials) { const episodes = await this.plexClient.getChildrenMetadata( specials.ratingKey ); if (episodes) { for (const episode of episodes) { const special = animeList.getSpecialEpisode(tvdbId, episode.index); if (special) { if (special.tmdbId) { await this.processPlexMovieByTmdbId(episode, special.tmdbId); } else if (special.imdbId) { const tmdbMovie = await this.tmdb.getMediaByImdbId({ imdbId: special.imdbId, }); await this.processPlexMovieByTmdbId(episode, tmdbMovie.id); } } } } } } // checks if any of this.libraries has Hama agent set in Plex private async hasHamaAgent() { const plexLibraries = await this.plexClient.getLibraries(); return this.libraries.some((library) => plexLibraries.some( (plexLibrary) => plexLibrary.agent === HAMA_AGENT && library.id === plexLibrary.key ) ); } } export const plexFullScanner = new PlexScanner(); export const plexRecentScanner = new PlexScanner(true); ================================================ FILE: server/lib/scanners/radarr/index.ts ================================================ import type { RadarrMovie } from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr'; import type { RunnableScanner, StatusBase, } from '@server/lib/scanners/baseScanner'; import BaseScanner from '@server/lib/scanners/baseScanner'; import type { RadarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { uniqWith } from 'lodash'; type SyncStatus = StatusBase & { currentServer: RadarrSettings; servers: RadarrSettings[]; }; class RadarrScanner extends BaseScanner implements RunnableScanner { private servers: RadarrSettings[]; private currentServer: RadarrSettings; private radarrApi: RadarrAPI; constructor() { super('Radarr Scan', { bundleSize: 50 }); } public status(): SyncStatus { return { running: this.running, progress: this.progress, total: this.items.length, currentServer: this.currentServer, servers: this.servers, }; } public async run(): Promise { const settings = getSettings(); const sessionId = this.startRun(); try { this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => { return ( radarrA.hostname === radarrB.hostname && radarrA.port === radarrB.port && radarrA.baseUrl === radarrB.baseUrl ); }); for (const server of this.servers) { this.currentServer = server; if (server.syncEnabled) { this.log( `Beginning to process Radarr server: ${server.name}`, 'info' ); this.radarrApi = new RadarrAPI({ apiKey: server.apiKey, url: RadarrAPI.buildUrl(server, '/api/v3'), }); this.items = await this.radarrApi.getMovies(); await this.loop(this.processRadarrMovie.bind(this), { sessionId }); } else { this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`); } } this.log('Radarr scan complete', 'info'); } catch (e) { this.log('Scan interrupted', 'error', { errorMessage: e.message }); } finally { this.endRun(sessionId); } } private async processRadarrMovie(radarrMovie: RadarrMovie): Promise { if (!radarrMovie.monitored && !radarrMovie.hasFile) { this.log( 'Title is unmonitored and has not been downloaded. Skipping item.', 'debug', { title: radarrMovie.title, } ); return; } try { const server4k = this.enable4kMovie && this.currentServer.is4k; await this.processMovie(radarrMovie.tmdbId, { is4k: server4k, serviceId: this.currentServer.id, externalServiceId: radarrMovie.id, externalServiceSlug: radarrMovie.titleSlug, title: radarrMovie.title, processing: !radarrMovie.hasFile, }); } catch (e) { this.log('Failed to process Radarr media', 'error', { errorMessage: e.message, title: radarrMovie.title, }); } } } export const radarrScanner = new RadarrScanner(); ================================================ FILE: server/lib/scanners/sonarr/index.ts ================================================ import { getMetadataProvider } from '@server/api/metadata'; import type { SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword, TmdbTvDetails, } from '@server/api/themoviedb/interfaces'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import type { ProcessableSeason, RunnableScanner, StatusBase, } from '@server/lib/scanners/baseScanner'; import BaseScanner from '@server/lib/scanners/baseScanner'; import type { SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { uniqWith } from 'lodash'; type SyncStatus = StatusBase & { currentServer: SonarrSettings; servers: SonarrSettings[]; }; class SonarrScanner extends BaseScanner implements RunnableScanner { private servers: SonarrSettings[]; private currentServer: SonarrSettings; private sonarrApi: SonarrAPI; constructor() { super('Sonarr Scan', { bundleSize: 50 }); } public status(): SyncStatus { return { running: this.running, progress: this.progress, total: this.items.length, currentServer: this.currentServer, servers: this.servers, }; } public async run(): Promise { const settings = getSettings(); const sessionId = this.startRun(); try { this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => { return ( sonarrA.hostname === sonarrB.hostname && sonarrA.port === sonarrB.port && sonarrA.baseUrl === sonarrB.baseUrl ); }); for (const server of this.servers) { this.currentServer = server; if (server.syncEnabled) { this.log( `Beginning to process Sonarr server: ${server.name}`, 'info' ); this.sonarrApi = new SonarrAPI({ apiKey: server.apiKey, url: SonarrAPI.buildUrl(server, '/api/v3'), }); this.items = await this.sonarrApi.getSeries(); await this.loop(this.processSonarrSeries.bind(this), { sessionId }); } else { this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`); } } this.log('Sonarr scan complete', 'info'); } catch (e) { this.log('Scan interrupted', 'error', { errorMessage: e.message }); } finally { this.endRun(sessionId); } } private async processSonarrSeries(sonarrSeries: SonarrSeries) { try { const mediaRepository = getRepository(Media); const server4k = this.enable4kShow && this.currentServer.is4k; const processableSeasons: ProcessableSeason[] = []; let tvShow: TmdbTvDetails; const media = await mediaRepository.findOne({ where: { tvdbId: sonarrSeries.tvdbId }, }); if (!media || !media.tmdbId) { tvShow = await this.tmdb.getShowByTvdbId({ tvdbId: sonarrSeries.tvdbId, }); } else { tvShow = await this.tmdb.getTvShow({ tvId: media.tmdbId }); } const tmdbId = tvShow.id; const metadataProvider = tvShow.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID ) ? await getMetadataProvider('anime') : await getMetadataProvider('tv'); if (!(metadataProvider instanceof TheMovieDb)) { tvShow = await metadataProvider.getTvShow({ tvId: tmdbId }); } const settings = getSettings(); const filteredSeasons = sonarrSeries.seasons.filter( (sn) => tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) && (!settings.main.enableSpecialEpisodes ? sn.seasonNumber !== 0 : true) ); for (const season of filteredSeasons) { const totalAvailableEpisodes = season.statistics?.episodeFileCount ?? 0; processableSeasons.push({ seasonNumber: season.seasonNumber, episodes: !server4k ? totalAvailableEpisodes : 0, episodes4k: server4k ? totalAvailableEpisodes : 0, totalEpisodes: season.statistics?.totalEpisodeCount ?? 0, processing: season.monitored && totalAvailableEpisodes === 0, is4kOverride: server4k, }); } await this.processShow(tmdbId, sonarrSeries.tvdbId, processableSeasons, { serviceId: this.currentServer.id, externalServiceId: sonarrSeries.id, externalServiceSlug: sonarrSeries.titleSlug, title: sonarrSeries.title, is4k: server4k, }); } catch (e) { this.log('Failed to process Sonarr media', 'error', { errorMessage: e.message, title: sonarrSeries.title, }); } } } export const sonarrScanner = new SonarrScanner(); ================================================ FILE: server/lib/search.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import type { TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, TmdbPersonResult, TmdbSearchMovieResponse, TmdbSearchMultiResponse, TmdbSearchTvResponse, TmdbTvDetails, TmdbTvResult, } from '@server/api/themoviedb/interfaces'; import { mapMovieDetailsToResult, mapPersonDetailsToResult, mapTvDetailsToResult, } from '@server/models/Search'; import { isMovie, isMovieDetails, isTvDetails, } from '@server/utils/typeHelpers'; interface SearchProvider { pattern: RegExp; search: ({ id, language, query, }: { id: string; language?: string; query?: string; }) => Promise; } const searchProviders: SearchProvider[] = []; export const findSearchProvider = ( query: string ): SearchProvider | undefined => { return searchProviders.find((provider) => provider.pattern.test(query)); }; searchProviders.push({ pattern: new RegExp(/(?<=tmdb:)\d+/), search: async ({ id, language }) => { const tmdb = new TheMovieDb(); const moviePromise = tmdb.getMovie({ movieId: parseInt(id), language }); const tvShowPromise = tmdb.getTvShow({ tvId: parseInt(id), language }); const personPromise = tmdb.getPerson({ personId: parseInt(id), language }); const responses = await Promise.allSettled([ moviePromise, tvShowPromise, personPromise, ]); const successfulResponses = responses.filter( (r) => r.status === 'fulfilled' ) as ( | PromiseFulfilledResult | PromiseFulfilledResult | PromiseFulfilledResult )[]; const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = []; if (successfulResponses.length) { results.push( ...successfulResponses.map((r) => { if (isMovieDetails(r.value)) { return mapMovieDetailsToResult(r.value); } else if (isTvDetails(r.value)) { return mapTvDetailsToResult(r.value); } else { return mapPersonDetailsToResult(r.value); } }) ); } return { page: 1, total_pages: 1, total_results: results.length, results, }; }, }); searchProviders.push({ pattern: new RegExp(/(?<=imdb:)(tt|nm)\d+/), search: async ({ id, language }) => { const tmdb = new TheMovieDb(); const responses = await tmdb.getByExternalId({ externalId: id, type: 'imdb', language, }); const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = []; // set the media_type here since searching by external id doesn't return it results.push( ...(responses.movie_results.map((movie) => ({ ...movie, media_type: 'movie', })) as TmdbMovieResult[]), ...(responses.tv_results.map((tv) => ({ ...tv, media_type: 'tv', })) as TmdbTvResult[]), ...(responses.person_results.map((person) => ({ ...person, media_type: 'person', })) as TmdbPersonResult[]) ); return { page: 1, total_pages: 1, total_results: results.length, results, }; }, }); searchProviders.push({ pattern: new RegExp(/(?<=tvdb:)\d+/), search: async ({ id, language }) => { const tmdb = new TheMovieDb(); const responses = await tmdb.getByExternalId({ externalId: parseInt(id), type: 'tvdb', language, }); const results: (TmdbMovieResult | TmdbTvResult | TmdbPersonResult)[] = []; // set the media_type here since searching by external id doesn't return it results.push( ...(responses.movie_results.map((movie) => ({ ...movie, media_type: 'movie', })) as TmdbMovieResult[]), ...(responses.tv_results.map((tv) => ({ ...tv, media_type: 'tv', })) as TmdbTvResult[]), ...(responses.person_results.map((person) => ({ ...person, media_type: 'person', })) as TmdbPersonResult[]) ); return { page: 1, total_pages: 1, total_results: results.length, results, }; }, }); searchProviders.push({ pattern: new RegExp(/(?<=year:)\d{4}/), search: async ({ id: year, query }) => { const tmdb = new TheMovieDb(); const moviesPromise = tmdb.searchMovies({ query: query?.replace(new RegExp(/year:\d{4}/), '') ?? '', year: parseInt(year), }); const tvShowsPromise = tmdb.searchTvShows({ query: query?.replace(new RegExp(/year:\d{4}/), '') ?? '', year: parseInt(year), }); const responses = await Promise.allSettled([moviesPromise, tvShowsPromise]); const successfulResponses = responses.filter( (r) => r.status === 'fulfilled' ) as ( | PromiseFulfilledResult | PromiseFulfilledResult )[]; const results: (TmdbMovieResult | TmdbTvResult)[] = []; if (successfulResponses.length) { successfulResponses.forEach((response) => { response.value.results.forEach((result) => // set the media_type here since the search endpoints don't return it results.push( isMovie(result) ? { ...result, media_type: 'movie' } : { ...result, media_type: 'tv' } ) ); }); } return { page: 1, total_pages: 1, total_results: results.length, results, }; }, }); ================================================ FILE: server/lib/settings/index.ts ================================================ import { MediaServerType } from '@server/constants/server'; import { Permission } from '@server/lib/permissions'; import { runMigrations } from '@server/lib/settings/migrator'; import { randomUUID } from 'crypto'; import fs from 'fs/promises'; import { mergeWith } from 'lodash'; import path from 'path'; import webpush from 'web-push'; // Prevents stale array entries when incoming data has fewer elements const mergeSettings = (current: T, incoming: Partial): T => mergeWith({}, current, incoming, (_objValue, srcValue) => Array.isArray(srcValue) ? srcValue : undefined ) as T; export interface Library { id: string; name: string; enabled: boolean; type: 'show' | 'movie'; lastScan?: number; } export interface Region { iso_3166_1: string; english_name: string; name?: string; } export interface Language { iso_639_1: string; english_name: string; name: string; } export interface PlexSettings { name: string; machineId?: string; ip: string; port: number; useSsl?: boolean; libraries: Library[]; webAppUrl?: string; } export interface JellyfinSettings { name: string; ip: string; port: number; useSsl?: boolean; urlBase?: string; externalHostname?: string; jellyfinForgotPasswordUrl?: string; libraries: Library[]; serverId: string; apiKey: string; } export interface TautulliSettings { hostname?: string; port?: number; useSsl?: boolean; urlBase?: string; apiKey?: string; externalUrl?: string; } export interface DVRSettings { id: number; name: string; hostname: string; port: number; apiKey: string; useSsl: boolean; baseUrl?: string; activeProfileId: number; activeProfileName: string; activeDirectory: string; tags: number[]; is4k: boolean; isDefault: boolean; externalUrl?: string; syncEnabled: boolean; preventSearch: boolean; tagRequests: boolean; overrideRule: number[]; } export interface RadarrSettings extends DVRSettings { minimumAvailability: string; } export interface SonarrSettings extends DVRSettings { seriesType: 'standard' | 'daily' | 'anime'; animeSeriesType: 'standard' | 'daily' | 'anime'; activeAnimeProfileId?: number; activeAnimeProfileName?: string; activeAnimeDirectory?: string; activeAnimeLanguageProfileId?: number; activeLanguageProfileId?: number; animeTags?: number[]; enableSeasonFolders: boolean; monitorNewItems: 'all' | 'none'; } interface Quota { quotaLimit?: number; quotaDays?: number; } export enum MetadataProviderType { TMDB = 'tmdb', TVDB = 'tvdb', } export interface MetadataSettings { tv: MetadataProviderType; anime: MetadataProviderType; } export interface ProxySettings { enabled: boolean; hostname: string; port: number; useSsl: boolean; user: string; password: string; bypassFilter: string; bypassLocalAddresses: boolean; } export interface MainSettings { apiKey: string; applicationTitle: string; applicationUrl: string; cacheImages: boolean; defaultPermissions: number; defaultQuotas: { movie: Quota; tv: Quota; }; hideAvailable: boolean; hideBlocklisted: boolean; localLogin: boolean; mediaServerLogin: boolean; newPlexLogin: boolean; discoverRegion: string; streamingRegion: string; originalLanguage: string; blocklistedTags: string; blocklistedTagsLimit: number; mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; locale: string; youtubeUrl: string; } export interface ProxySettings { enabled: boolean; hostname: string; port: number; useSsl: boolean; user: string; password: string; bypassFilter: string; bypassLocalAddresses: boolean; } export interface DnsCacheSettings { enabled: boolean; forceMinTtl?: number; forceMaxTtl?: number; } export interface NetworkSettings { csrfProtection: boolean; forceIpv4First: boolean; trustProxy: boolean; proxy: ProxySettings; dnsCache: DnsCacheSettings; apiRequestTimeout: number; } interface PublicSettings { initialized: boolean; } interface FullPublicSettings extends PublicSettings { applicationTitle: string; applicationUrl: string; hideAvailable: boolean; hideBlocklisted: boolean; localLogin: boolean; mediaServerLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; discoverRegion: string; streamingRegion: string; originalLanguage: string; mediaServerType: number; jellyfinExternalHost?: string; jellyfinForgotPasswordUrl?: string; jellyfinServerName?: string; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; locale: string; emailEnabled: boolean; userEmailRequired: boolean; newPlexLogin: boolean; youtubeUrl: string; } export interface NotificationAgentConfig { enabled: boolean; embedPoster: boolean; types?: number; options: Record; } export interface NotificationAgentDiscord extends NotificationAgentConfig { options: { botUsername?: string; botAvatarUrl?: string; webhookUrl: string; webhookRoleId?: string; enableMentions: boolean; }; } export interface NotificationAgentSlack extends NotificationAgentConfig { options: { webhookUrl: string; }; } export interface NotificationAgentEmail extends NotificationAgentConfig { options: { userEmailRequired: boolean; emailFrom: string; smtpHost: string; smtpPort: number; secure: boolean; ignoreTls: boolean; requireTls: boolean; authUser?: string; authPass?: string; allowSelfSigned: boolean; senderName: string; pgpPrivateKey?: string; pgpPassword?: string; }; } export interface NotificationAgentTelegram extends NotificationAgentConfig { options: { botUsername?: string; botAPI: string; chatId: string; messageThreadId: string; sendSilently: boolean; }; } export interface NotificationAgentPushbullet extends NotificationAgentConfig { options: { accessToken: string; channelTag?: string; }; } export interface NotificationAgentPushover extends NotificationAgentConfig { options: { accessToken: string; userToken: string; sound: string; }; } export interface NotificationAgentWebhook extends NotificationAgentConfig { options: { webhookUrl: string; jsonPayload: string; authHeader?: string; customHeaders?: { key: string; value: string }[]; supportVariables?: boolean; }; } export interface NotificationAgentGotify extends NotificationAgentConfig { options: { url: string; token: string; priority: number; }; } export interface NotificationAgentNtfy extends NotificationAgentConfig { options: { url: string; topic: string; authMethodUsernamePassword?: boolean; username?: string; password?: string; authMethodToken?: boolean; token?: string; priority?: number; }; } export enum NotificationAgentKey { DISCORD = 'discord', EMAIL = 'email', GOTIFY = 'gotify', NTFY = 'ntfy', PUSHBULLET = 'pushbullet', PUSHOVER = 'pushover', SLACK = 'slack', TELEGRAM = 'telegram', WEBHOOK = 'webhook', WEBPUSH = 'webpush', } interface NotificationAgents { discord: NotificationAgentDiscord; email: NotificationAgentEmail; gotify: NotificationAgentGotify; ntfy: NotificationAgentNtfy; pushbullet: NotificationAgentPushbullet; pushover: NotificationAgentPushover; slack: NotificationAgentSlack; telegram: NotificationAgentTelegram; webhook: NotificationAgentWebhook; webpush: NotificationAgentConfig; } interface NotificationSettings { agents: NotificationAgents; } interface JobSettings { schedule: string; } export type JobId = | 'plex-recently-added-scan' | 'plex-full-scan' | 'plex-watchlist-sync' | 'plex-refresh-token' | 'radarr-scan' | 'sonarr-scan' | 'download-sync' | 'download-sync-reset' | 'jellyfin-recently-added-scan' | 'jellyfin-full-scan' | 'image-cache-cleanup' | 'availability-sync' | 'process-blocklisted-tags'; export interface AllSettings { clientId: string; vapidPublic: string; vapidPrivate: string; main: MainSettings; plex: PlexSettings; jellyfin: JellyfinSettings; tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; public: PublicSettings; notifications: NotificationSettings; jobs: Record; network: NetworkSettings; metadataSettings: MetadataSettings; migrations: string[]; } const SETTINGS_PATH = process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/settings.json` : path.join(__dirname, '../../../config/settings.json'); class Settings { private data: AllSettings; private saveLock: Promise = Promise.resolve(); constructor(initialSettings?: AllSettings) { this.data = { clientId: randomUUID(), vapidPrivate: '', vapidPublic: '', main: { apiKey: '', applicationTitle: 'Seerr', applicationUrl: '', cacheImages: false, defaultPermissions: Permission.REQUEST, defaultQuotas: { movie: {}, tv: {}, }, hideAvailable: false, hideBlocklisted: false, localLogin: true, mediaServerLogin: true, newPlexLogin: true, discoverRegion: '', streamingRegion: '', originalLanguage: '', blocklistedTags: '', blocklistedTagsLimit: 50, mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, locale: 'en', youtubeUrl: '', }, plex: { name: '', ip: '', port: 32400, useSsl: false, libraries: [], }, jellyfin: { name: '', ip: '', port: 8096, useSsl: false, urlBase: '', externalHostname: '', jellyfinForgotPasswordUrl: '', libraries: [], serverId: '', apiKey: '', }, tautulli: {}, metadataSettings: { tv: MetadataProviderType.TMDB, anime: MetadataProviderType.TMDB, }, radarr: [], sonarr: [], public: { initialized: false, }, notifications: { agents: { email: { enabled: false, embedPoster: true, options: { userEmailRequired: false, emailFrom: '', smtpHost: '', smtpPort: 587, secure: false, ignoreTls: false, requireTls: false, allowSelfSigned: false, senderName: 'Seerr', }, }, discord: { enabled: false, embedPoster: true, types: 0, options: { webhookUrl: '', webhookRoleId: '', enableMentions: true, }, }, slack: { enabled: false, embedPoster: true, types: 0, options: { webhookUrl: '', }, }, telegram: { enabled: false, embedPoster: true, types: 0, options: { botAPI: '', chatId: '', messageThreadId: '', sendSilently: false, }, }, pushbullet: { enabled: false, embedPoster: false, types: 0, options: { accessToken: '', }, }, pushover: { enabled: false, embedPoster: true, types: 0, options: { accessToken: '', userToken: '', sound: '', }, }, webhook: { enabled: false, embedPoster: true, types: 0, options: { webhookUrl: '', jsonPayload: 'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', }, }, webpush: { enabled: false, embedPoster: true, options: {}, }, gotify: { enabled: false, embedPoster: false, types: 0, options: { url: '', token: '', priority: 0, }, }, ntfy: { enabled: false, embedPoster: true, types: 0, options: { url: '', topic: '', priority: 3, }, }, }, }, jobs: { 'plex-recently-added-scan': { schedule: '0 */5 * * * *', }, 'plex-full-scan': { schedule: '0 0 3 * * *', }, 'plex-watchlist-sync': { schedule: '0 */3 * * * *', }, 'plex-refresh-token': { schedule: '0 0 5 * * *', }, 'radarr-scan': { schedule: '0 0 4 * * *', }, 'sonarr-scan': { schedule: '0 30 4 * * *', }, 'availability-sync': { schedule: '0 0 5 * * *', }, 'download-sync': { schedule: '0 * * * * *', }, 'download-sync-reset': { schedule: '0 0 1 * * *', }, 'jellyfin-recently-added-scan': { schedule: '0 */5 * * * *', }, 'jellyfin-full-scan': { schedule: '0 0 3 * * *', }, 'image-cache-cleanup': { schedule: '0 0 5 * * *', }, 'process-blocklisted-tags': { schedule: '0 30 1 */7 * *', }, }, network: { csrfProtection: false, forceIpv4First: false, trustProxy: false, proxy: { enabled: false, hostname: '', port: 8080, useSsl: false, user: '', password: '', bypassFilter: '', bypassLocalAddresses: true, }, dnsCache: { enabled: false, forceMinTtl: 0, forceMaxTtl: -1, }, apiRequestTimeout: 10000, }, migrations: [], }; if (initialSettings) { this.data = mergeSettings(this.data, initialSettings); } } get main(): MainSettings { return this.data.main; } set main(data: MainSettings) { this.data.main = mergeSettings(this.data.main, data); } get plex(): PlexSettings { return this.data.plex; } set plex(data: PlexSettings) { this.data.plex = mergeSettings(this.data.plex, data); } get jellyfin(): JellyfinSettings { return this.data.jellyfin; } set jellyfin(data: JellyfinSettings) { this.data.jellyfin = mergeSettings(this.data.jellyfin, data); } get tautulli(): TautulliSettings { return this.data.tautulli; } set tautulli(data: TautulliSettings) { this.data.tautulli = mergeSettings(this.data.tautulli, data); } get metadataSettings(): MetadataSettings { return this.data.metadataSettings; } set metadataSettings(data: MetadataSettings) { this.data.metadataSettings = mergeSettings( this.data.metadataSettings, data ); } get radarr(): RadarrSettings[] { return this.data.radarr; } set radarr(data: RadarrSettings[]) { this.data.radarr = data; } get sonarr(): SonarrSettings[] { return this.data.sonarr; } set sonarr(data: SonarrSettings[]) { this.data.sonarr = data; } get public(): PublicSettings { return this.data.public; } set public(data: PublicSettings) { this.data.public = mergeSettings(this.data.public, data); } get fullPublicSettings(): FullPublicSettings { return { ...this.data.public, applicationTitle: this.data.main.applicationTitle, applicationUrl: this.data.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, hideBlocklisted: this.data.main.hideBlocklisted, localLogin: this.data.main.localLogin, mediaServerLogin: this.data.main.mediaServerLogin, jellyfinExternalHost: this.data.jellyfin.externalHostname, jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl, movie4kEnabled: this.data.radarr.some( (radarr) => radarr.is4k && radarr.isDefault ), series4kEnabled: this.data.sonarr.some( (sonarr) => sonarr.is4k && sonarr.isDefault ), discoverRegion: this.data.main.discoverRegion, streamingRegion: this.data.main.streamingRegion, originalLanguage: this.data.main.originalLanguage, mediaServerType: this.main.mediaServerType, partialRequestsEnabled: this.data.main.partialRequestsEnabled, enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, cacheImages: this.data.main.cacheImages, vapidPublic: this.vapidPublic, enablePushRegistration: this.data.notifications.agents.webpush.enabled, locale: this.data.main.locale, emailEnabled: this.data.notifications.agents.email.enabled, userEmailRequired: this.data.notifications.agents.email.options.userEmailRequired, newPlexLogin: this.data.main.newPlexLogin, youtubeUrl: this.data.main.youtubeUrl, }; } get notifications(): NotificationSettings { return this.data.notifications; } set notifications(data: NotificationSettings) { this.data.notifications = mergeSettings(this.data.notifications, data); } get jobs(): Record { return this.data.jobs; } set jobs(data: Record) { this.data.jobs = mergeSettings(this.data.jobs, data); } get network(): NetworkSettings { return this.data.network; } set network(data: NetworkSettings) { this.data.network = mergeSettings(this.data.network, data); } get migrations(): string[] { return this.data.migrations; } set migrations(data: string[]) { this.data.migrations = data; } get clientId(): string { return this.data.clientId; } get vapidPublic(): string { return this.data.vapidPublic; } get vapidPrivate(): string { return this.data.vapidPrivate; } public async regenerateApiKey(): Promise { this.main.apiKey = this.generateApiKey(); await this.save(); return this.main; } private generateApiKey(): string { if (process.env.API_KEY) { return process.env.API_KEY; } else { return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64'); } } /** * Settings Load * * This will load settings from file unless an optional argument of the object structure * is passed in. * @param overrideSettings If passed in, will override all existing settings with these * @param raw If true, will load the settings without running migrations or generating missing * values */ public async load( overrideSettings?: AllSettings, raw = false ): Promise { if (overrideSettings) { this.data = overrideSettings; return this; } let data; try { data = await fs.readFile(SETTINGS_PATH, 'utf-8'); } catch { await this.save(); } if (data && !raw) { const parsedJson = JSON.parse(data); const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); this.data = mergeSettings(this.data, migratedData); } else if (data) { this.data = JSON.parse(data); } // generate keys and ids if it's missing let change = false; if (!this.data.main.apiKey) { this.data.main.apiKey = this.generateApiKey(); change = true; } else if (process.env.API_KEY) { if (this.main.apiKey != process.env.API_KEY) { this.main.apiKey = process.env.API_KEY; } } if (!this.data.clientId) { this.data.clientId = randomUUID(); change = true; } if (!this.data.vapidPublic || !this.data.vapidPrivate) { const vapidKeys = webpush.generateVAPIDKeys(); this.data.vapidPrivate = vapidKeys.privateKey; this.data.vapidPublic = vapidKeys.publicKey; change = true; } if (change) { await this.save(); } return this; } public async save(): Promise { const savePromise = this.saveLock.then(async () => { const tmp = SETTINGS_PATH + '.tmp'; await fs.writeFile(tmp, JSON.stringify(this.data, undefined, ' ')); await fs.rename(tmp, SETTINGS_PATH); }); this.saveLock = savePromise.catch(() => { // Keep the chain alive so future saves aren't blocked by past failures }); return savePromise; } } let settings: Settings | undefined; export const getSettings = (initialSettings?: AllSettings): Settings => { if (!settings) { settings = new Settings(initialSettings); } return settings; }; export default Settings; ================================================ FILE: server/lib/settings/migrations/0001_migrate_hostname.ts ================================================ import type { AllSettings } from '@server/lib/settings'; const migrateHostname = (settings: any): AllSettings => { if (settings.jellyfin?.hostname) { const { hostname } = settings.jellyfin; const protocolMatch = hostname.match(/^(https?):\/\//i); const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https'; const remainingUrl = hostname.replace(/^(https?):\/\//i, ''); const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/); delete settings.jellyfin.hostname; if (urlMatch) { const [, ip, , port, urlBase] = urlMatch; settings.jellyfin = { ...settings.jellyfin, ip, port: port || (useSsl ? 443 : 80), useSsl, urlBase: urlBase ? urlBase.replace(/\/$/, '') : '', }; } } return settings; }; export default migrateHostname; ================================================ FILE: server/lib/settings/migrations/0002_migrate_apitokens.ts ================================================ import JellyfinAPI from '@server/api/jellyfin'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { AllSettings } from '@server/lib/settings'; import { getHostname } from '@server/utils/getHostname'; const migrateApiTokens = async (settings: any): Promise => { const mediaServerType = settings.main.mediaServerType; if ( !settings.jellyfin?.apiKey && (mediaServerType === MediaServerType.JELLYFIN || mediaServerType === MediaServerType.EMBY) ) { const userRepository = getRepository(User); const admin = await userRepository.findOne({ where: { id: 1 }, select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'], order: { id: 'ASC' }, }); if (!admin) { return settings; } const jellyfinClient = new JellyfinAPI( getHostname(settings.jellyfin), admin.jellyfinAuthToken, admin.jellyfinDeviceId ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); try { const apiKey = await jellyfinClient.createApiToken('Seerr'); settings.jellyfin.apiKey = apiKey; } catch { throw new Error( "Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue." ); } } return settings; }; export default migrateApiTokens; ================================================ FILE: server/lib/settings/migrations/0003_emby_media_server_type.ts ================================================ import { MediaServerType } from '@server/constants/server'; import type { AllSettings } from '@server/lib/settings'; const migrateHostname = (settings: any): AllSettings => { const oldMediaServerType = settings.main.mediaServerType; if ( oldMediaServerType === MediaServerType.JELLYFIN && process.env.JELLYFIN_TYPE === 'emby' ) { settings.main.mediaServerType = MediaServerType.EMBY; } return settings; }; export default migrateHostname; ================================================ FILE: server/lib/settings/migrations/0004_migrate_region_setting.ts ================================================ import type { AllSettings } from '@server/lib/settings'; const migrateRegionSetting = (settings: any): AllSettings => { if ( settings.main.discoverRegion !== undefined && settings.main.streamingRegion !== undefined ) { return settings; } const oldRegion = settings.main.region; if (oldRegion) { settings.main.discoverRegion = oldRegion; settings.main.streamingRegion = oldRegion; } else { settings.main.discoverRegion = ''; settings.main.streamingRegion = 'US'; } delete settings.main.region; return settings; }; export default migrateRegionSetting; ================================================ FILE: server/lib/settings/migrations/0005_migrate_network_settings.ts ================================================ import type { AllSettings } from '@server/lib/settings'; const migrateNetworkSettings = (settings: any): AllSettings => { if (settings.network) { return settings; } const newSettings = { ...settings }; newSettings.network = { ...settings.network, csrfProtection: settings.main.csrfProtection ?? false, trustProxy: settings.main.trustProxy ?? false, forceIpv4First: settings.main.forceIpv4First ?? false, proxy: settings.main.proxy ?? { enabled: false, hostname: '', port: 8080, useSsl: false, user: '', password: '', bypassFilter: '', bypassLocalAddresses: true, }, }; delete settings.main.csrfProtection; delete settings.main.trustProxy; delete settings.main.forceIpv4First; delete settings.main.proxy; return newSettings; }; export default migrateNetworkSettings; ================================================ FILE: server/lib/settings/migrations/0006_remove_lunasea.ts ================================================ import type { AllSettings } from '@server/lib/settings'; const removeLunaSeaSetting = (settings: any): AllSettings => { if ( settings.notifications && settings.notifications.agents && settings.notifications.agents.lunasea ) { delete settings.notifications.agents.lunasea; } return settings; }; export default removeLunaSeaSetting; ================================================ FILE: server/lib/settings/migrations/0007_migrate_arr_tags.ts ================================================ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { AllSettings } from '@server/lib/settings'; const migrationArrTags = async (settings: any): Promise => { if ( Array.isArray(settings.migrations) && settings.migrations.includes('0007_migrate_arr_tags') ) { return settings; } const userRepository = getRepository(User); const users = await userRepository.find(); let errorOccurred = false; for (const radarrSettings of settings.radarr || []) { if (!radarrSettings.tagRequests) { continue; } try { const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); const radarrTags = await radarr.getTags(); for (const user of users) { const userTag = radarrTags.find( (v) => v.label.startsWith(user.id + ' - ') || v.label.startsWith(user.id + '-') ); if (!userTag) { continue; } await radarr.renameTag({ id: userTag.id, label: user.id + '-' + user.displayName .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/gi, '') .replace(/-+/g, '-') .replace(/^-|-$/g, ''), }); } } catch (error) { console.error( `Unable to rename Radarr tags to the new format. Please check your Radarr connection settings for the instance "${radarrSettings.name}".`, error.message ); errorOccurred = true; } } for (const sonarrSettings of settings.sonarr || []) { if (!sonarrSettings.tagRequests) { continue; } try { const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); const sonarrTags = await sonarr.getTags(); for (const user of users) { const userTag = sonarrTags.find( (v) => v.label.startsWith(user.id + ' - ') || v.label.startsWith(user.id + '-') ); if (!userTag) { continue; } await sonarr.renameTag({ id: userTag.id, label: user.id + '-' + user.displayName .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/gi, '') .replace(/-+/g, '-') .replace(/^-|-$/g, ''), }); } } catch (error) { console.error( `Unable to rename Sonarr tags to the new format. Please check your Sonarr connection settings for the instance "${sonarrSettings.name}".`, error.message ); errorOccurred = true; } } if (!errorOccurred) { if (!Array.isArray(settings.migrations)) { settings.migrations = []; } settings.migrations.push('0007_migrate_arr_tags'); } return settings; }; export default migrationArrTags; ================================================ FILE: server/lib/settings/migrations/0008_migrate_blacklist_to_blocklist.ts ================================================ import type { AllSettings } from '@server/lib/settings'; const migrateBlacklistToBlocklist = (settings: any): AllSettings => { if ( Array.isArray(settings.migrations) && settings.migrations.includes('0008_migrate_blacklist_to_blocklist') ) { return settings; } if (settings.main?.hideBlacklisted !== undefined) { settings.main.hideBlocklisted = settings.main.hideBlacklisted; delete settings.main.hideBlacklisted; } if (settings.main?.blacklistedTags !== undefined) { settings.main.blocklistedTags = settings.main.blacklistedTags; delete settings.main.blacklistedTags; } if (settings.main?.blacklistedTagsLimit !== undefined) { settings.main.blocklistedTagsLimit = settings.main.blacklistedTagsLimit; delete settings.main.blacklistedTagsLimit; } if (settings.jobs?.['process-blacklisted-tags']) { settings.jobs['process-blocklisted-tags'] = settings.jobs['process-blacklisted-tags']; delete settings.jobs['process-blacklisted-tags']; } if (!Array.isArray(settings.migrations)) { settings.migrations = []; } settings.migrations.push('0008_migrate_blacklist_to_blocklist'); return settings; }; export default migrateBlacklistToBlocklist; ================================================ FILE: server/lib/settings/migrator.ts ================================================ import type { AllSettings } from '@server/lib/settings'; import logger from '@server/logger'; import fs from 'fs/promises'; import path from 'path'; const migrationsDir = path.join(__dirname, 'migrations'); export const runMigrations = async ( settings: AllSettings, SETTINGS_PATH: string ): Promise => { let migrated = settings; try { // we read old backup and create a backup of currents settings const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json'); let oldBackup: string | null = null; try { oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8'); } catch { /* empty */ } await fs.writeFile(BACKUP_PATH, JSON.stringify(settings, undefined, ' ')); const migrations = (await fs.readdir(migrationsDir)).filter( (file) => file.endsWith('.js') || file.endsWith('.ts') ); const settingsBefore = JSON.stringify(migrated); for (const migration of migrations) { try { logger.debug(`Checking migration '${migration}'...`, { label: 'Settings Migrator', }); const { default: migrationFn } = await import( path.join(migrationsDir, migration) ); const newSettings = await migrationFn(structuredClone(migrated)); if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) { logger.debug(`Migration '${migration}' has been applied.`, { label: 'Settings Migrator', }); } migrated = newSettings; } catch (e) { // we stop Seerr if the migration failed logger.error( `Error while running migration '${migration}': ${e.message}\n${e.stack}`, { label: 'Settings Migrator', } ); logger.error( 'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.', { label: 'Settings Migrator', } ); process.exit(); } } const settingsAfter = JSON.stringify(migrated); if (settingsBefore !== settingsAfter) { // a migration occured // we check that the new config will be saved await fs.writeFile( SETTINGS_PATH, JSON.stringify(migrated, undefined, ' ') ); const fileSaved = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); if (JSON.stringify(fileSaved) !== settingsAfter) { // something went wrong while saving file throw new Error('Unable to save settings after migration.'); } } else if (oldBackup) { // no migration occured // we save the old backup (to avoid settings.json and settings.old.json being the same) await fs.writeFile(BACKUP_PATH, oldBackup.toString()); } } catch (e) { // we stop Seerr if the migration failed logger.error( `Something went wrong while running settings migrations: ${e.message}`, { label: 'Settings Migrator', } ); logger.error( 'A common cause for this issue is a permission error of your configuration folder.', { label: 'Settings Migrator', } ); process.exit(); } return migrated; }; ================================================ FILE: server/lib/watchlistsync.ts ================================================ import PlexTvAPI from '@server/api/plextv'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { BlocklistedMediaError, DuplicateMediaRequestError, MediaRequest, NoSeasonsAvailableError, QuotaRestrictedError, RequestPermissionError, } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import logger from '@server/logger'; import { Permission } from './permissions'; class WatchlistSync { public async syncWatchlist() { const userRepository = getRepository(User); // Get users who actually have plex tokens const users = await userRepository .createQueryBuilder('user') .addSelect('user.plexToken') .leftJoinAndSelect('user.settings', 'settings') .where("user.plexToken != ''") .getMany(); for (const user of users) { await this.syncUserWatchlist(user); } } private async syncUserWatchlist(user: User) { if (!user.plexToken) { logger.warn('Skipping user watchlist sync for user without plex token', { label: 'Plex Watchlist Sync', user: user.displayName, }); return; } if ( !user.hasPermission( [ Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE, Permission.AUTO_REQUEST_TV, ], { type: 'or' } ) ) { return; } if ( !user.settings?.watchlistSyncMovies && !user.settings?.watchlistSyncTv ) { // Skip sync if user settings have it disabled return; } const plexTvApi = new PlexTvAPI(user.plexToken); const response = await plexTvApi.getWatchlist({ size: 20 }); const mediaItems = await Media.getRelatedMedia( user, response.items.map((i) => ({ tmdbId: i.tmdbId, mediaType: i.type === 'show' ? MediaType.TV : MediaType.MOVIE, })) ); const watchlistTmdbIds = response.items.map((i) => i.tmdbId); const requestRepository = getRepository(MediaRequest); const existingAutoRequests = await requestRepository .createQueryBuilder('request') .leftJoinAndSelect('request.media', 'media') .where('request.requestedBy = :userId', { userId: user.id }) .andWhere('request.isAutoRequest = true') .andWhere('media.tmdbId IN (:...tmdbIds)', { tmdbIds: watchlistTmdbIds }) .getMany(); const autoRequestedTmdbIds = new Set( existingAutoRequests .filter((r) => r.media != null) .map((r) => `${r.media.mediaType}:${r.media.tmdbId}`) ); const unavailableItems = response.items.filter((i) => { const itemMediaType = i.type === 'show' ? MediaType.TV : MediaType.MOVIE; return ( !autoRequestedTmdbIds.has(`${itemMediaType}:${i.tmdbId}`) && !mediaItems.find( (m) => m.tmdbId === i.tmdbId && m.mediaType === itemMediaType && (m.status === MediaStatus.BLOCKLISTED || (itemMediaType === MediaType.MOVIE && m.status !== MediaStatus.UNKNOWN) || (itemMediaType === MediaType.TV && m.status === MediaStatus.AVAILABLE)) ) ); }); for (const mediaItem of unavailableItems) { try { logger.info("Creating media request from user's Plex Watchlist", { label: 'Watchlist Sync', userId: user.id, mediaTitle: mediaItem.title, }); if (mediaItem.type === 'show' && !mediaItem.tvdbId) { throw new Error('Missing TVDB ID from Plex Metadata'); } // Check if they have auto-request permissons and watchlist sync // enabled for the media type if ( ((!user.hasPermission( [Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE], { type: 'or' } ) || !user.settings?.watchlistSyncMovies) && mediaItem.type === 'movie') || ((!user.hasPermission( [Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV], { type: 'or' } ) || !user.settings?.watchlistSyncTv) && mediaItem.type === 'show') ) { continue; } await MediaRequest.request( { mediaId: mediaItem.tmdbId, mediaType: mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE, seasons: mediaItem.type === 'show' ? 'all' : undefined, tvdbId: mediaItem.tvdbId, is4k: false, }, user, { isAutoRequest: true } ); } catch (e) { if (!(e instanceof Error)) { continue; } switch (e.constructor) { // During watchlist sync, these errors aren't necessarily // a problem with Seerr. Since we are auto syncing these constantly, it's // possible they are unexpectedly at their quota limit, for example. So we'll // instead log these as debug messages. case RequestPermissionError: case DuplicateMediaRequestError: case QuotaRestrictedError: case NoSeasonsAvailableError: logger.debug('Failed to create media request from watchlist', { label: 'Watchlist Sync', userId: user.id, mediaTitle: mediaItem.title, errorMessage: e.message, }); break; // Blocklisted media should be silently ignored during watchlist sync to avoid spam case BlocklistedMediaError: break; default: logger.error('Failed to create media request from watchlist', { label: 'Watchlist Sync', userId: user.id, mediaTitle: mediaItem.title, errorMessage: e.message, }); } } } } } const watchlistSync = new WatchlistSync(); export default watchlistSync; ================================================ FILE: server/logger.ts ================================================ import path from 'path'; import * as winston from 'winston'; import 'winston-daily-rotate-file'; const hformat = winston.format.printf( ({ level, label, message, timestamp, ...metadata }) => { let msg = `${timestamp} [${level}]${ label ? `[${label}]` : '' }: ${message} `; if (Object.keys(metadata).length > 0) { msg += JSON.stringify(metadata); } return msg; } ); const logger = winston.createLogger({ level: process.env.LOG_LEVEL?.toLowerCase() || 'debug', format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), hformat ), transports: [ new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.splat(), winston.format.timestamp(), hformat ), }), new winston.transports.DailyRotateFile({ filename: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/logs/seerr-%DATE%.log` : path.join(__dirname, '../config/logs/seerr-%DATE%.log'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', maxFiles: '7d', createSymlink: true, symlinkName: 'seerr.log', }), new winston.transports.DailyRotateFile({ filename: process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/logs/.machinelogs-%DATE%.json` : path.join(__dirname, '../config/logs/.machinelogs-%DATE%.json'), datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', maxFiles: '1d', createSymlink: true, symlinkName: '.machinelogs.json', format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), winston.format.json() ), }), ], }); export default logger; ================================================ FILE: server/middleware/auth.ts ================================================ import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { Permission, PermissionCheckOptions, } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; export const checkUser: Middleware = async (req, _res, next) => { const settings = getSettings(); let user: User | undefined | null; if (req.header('X-API-Key') === settings.main.apiKey) { const userRepository = getRepository(User); let userId = 1; // Work on original administrator account // If a User ID is provided, we will act on that user's behalf if (req.header('X-API-User')) { userId = Number(req.header('X-API-User')); } user = await userRepository.findOne({ where: { id: userId } }); } else if (req.session?.userId) { const userRepository = getRepository(User); user = await userRepository.findOne({ where: { id: req.session.userId }, }); } if (user) { req.user = user; } req.locale = user?.settings?.locale ? user.settings.locale : settings.main.locale; next(); }; export const isAuthenticated = ( permissions?: Permission | Permission[], options?: PermissionCheckOptions ): Middleware => { const authMiddleware: Middleware = (req, res, next) => { if (!req.user || !req.user.hasPermission(permissions ?? 0, options)) { res.status(403).json({ status: 403, error: 'You do not have permission to access this endpoint', }); } else { next(); } }; return authMiddleware; }; ================================================ FILE: server/middleware/clearcookies.ts ================================================ const clearCookies: Middleware = (_req, res, next) => { res.removeHeader('Set-Cookie'); next(); }; export default clearCookies; ================================================ FILE: server/middleware/deprecation.ts ================================================ import logger from '@server/logger'; import type { NextFunction, Request, Response } from 'express'; interface DeprecationOptions { oldPath: string; newPath: string; sunsetDate?: string; documentationUrl?: string; } /** * Mark an API route as deprecated. * @see https://datatracker.ietf.org/doc/html/rfc8594 */ export const deprecatedRoute = ({ oldPath, newPath, sunsetDate, documentationUrl, }: DeprecationOptions) => { return (req: Request, res: Response, next: NextFunction) => { logger.warn( `Deprecated API endpoint accessed: ${oldPath} → use ${newPath} instead`, { label: 'API Deprecation', ip: req.ip, userAgent: req.get('User-Agent'), method: req.method, path: req.originalUrl, } ); res.setHeader('Deprecation', 'true'); const links: string[] = [`<${newPath}>; rel="successor-version"`]; if (documentationUrl) { links.push(`<${documentationUrl}>; rel="deprecation"`); } res.setHeader('Link', links.join(', ')); if (sunsetDate) { res.setHeader('Sunset', new Date(sunsetDate).toUTCString()); } next(); }; }; export default deprecatedRoute; ================================================ FILE: server/migration/postgres/1734786061496-InitialMigration.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class InitialMigration1734786061496 implements MigrationInterface { name = 'InitialMigration1734786061496'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "blacklist" ("id" SERIAL NOT NULL, "mediaType" character varying NOT NULL, "title" character varying, "tmdbId" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "PK_04dc42a96bf0914cda31b579702" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query( `CREATE TABLE "season_request" ("id" SERIAL NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "requestId" integer, CONSTRAINT "PK_4811e502081543bf620f1fa4328" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" SERIAL NOT NULL, "status" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "type" character varying NOT NULL, "is4k" boolean NOT NULL DEFAULT false, "serverId" integer, "profileId" integer, "rootFolder" character varying, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT false, "mediaId" integer NOT NULL, "requestedById" integer, "modifiedById" integer, CONSTRAINT "PK_f8334500e8e12db87536558c66c" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "season" ("id" SERIAL NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "mediaId" integer NOT NULL, CONSTRAINT "PK_8ac0d081dbdb7ab02d166bcda9f" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "media" ("id" SERIAL NOT NULL, "mediaType" character varying NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" character varying, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "lastSeasonChange" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "mediaAddedAt" TIMESTAMP WITH TIME ZONE DEFAULT now(), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" character varying, "externalServiceSlug4k" character varying, "ratingKey" character varying, "ratingKey4k" character varying, "jellyfinMediaId" character varying, "jellyfinMediaId4k" character varying, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "PK_f4e0fcac36e050de337b670d8bd" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE TABLE "watchlist" ("id" SERIAL NOT NULL, "ratingKey" character varying NOT NULL, "mediaType" character varying NOT NULL, "title" character varying NOT NULL, "tmdbId" integer NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "requestedById" integer, "mediaId" integer NOT NULL, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "PK_0c8c0dbcc8d379117138e71ad5b" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" SERIAL NOT NULL, "endpoint" character varying NOT NULL, "p256dh" character varying NOT NULL, "auth" character varying NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "PK_397020e7be9a4086cc798e0bb63" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" SERIAL NOT NULL, "locale" character varying NOT NULL DEFAULT '', "discoverRegion" character varying, "streamingRegion" character varying, "originalLanguage" character varying, "pgpKey" character varying, "discordId" character varying, "pushbulletAccessToken" character varying, "pushoverApplicationToken" character varying, "pushoverUserKey" character varying, "pushoverSound" character varying, "telegramChatId" character varying, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "PK_00f004f5922a0744d174530d639" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "user" ("id" SERIAL NOT NULL, "email" character varying NOT NULL, "plexUsername" character varying, "jellyfinUsername" character varying, "username" character varying, "password" character varying, "resetPasswordGuid" character varying, "recoveryLinkExpirationDate" date, "userType" integer NOT NULL DEFAULT '1', "plexId" integer, "jellyfinUserId" character varying, "jellyfinDeviceId" character varying, "jellyfinAuthToken" character varying, "plexToken" character varying, "permissions" integer NOT NULL DEFAULT '0', "avatar" character varying NOT NULL, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "issue_comment" ("id" SERIAL NOT NULL, "message" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, "issueId" integer, CONSTRAINT "PK_2ad05784e2ae661fa409e5e0248" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "issue" ("id" SERIAL NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "problemSeason" integer NOT NULL DEFAULT '0', "problemEpisode" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "PK_f80e086c249b9f3f3ff2fd321b7" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "discover_slider" ("id" SERIAL NOT NULL, "type" integer NOT NULL, "order" integer NOT NULL, "isBuiltIn" boolean NOT NULL DEFAULT false, "enabled" boolean NOT NULL DEFAULT true, "title" character varying, "data" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_20a71a098d04bae448e4d51db23" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE TABLE "session" ("expiredAt" bigint NOT NULL, "id" character varying(255) NOT NULL, "json" text NOT NULL, CONSTRAINT "PK_f55da76ac1c3ac420f444d2ff11" PRIMARY KEY ("id"))` ); await queryRunner.query( `CREATE INDEX "IDX_28c5d1d16da7908c97c9bc2f74" ON "session" ("expiredAt") ` ); await queryRunner.query( `ALTER TABLE "blacklist" ADD CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "blacklist" ADD CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "season_request" ADD CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "media_request" ADD CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "media_request" ADD CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "watchlist" ADD CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" ADD CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "user_settings" ADD CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "issue_comment" ADD CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "issue_comment" ADD CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "issue" ADD CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "issue" ADD CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "issue" ADD CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "issue" DROP CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5"` ); await queryRunner.query( `ALTER TABLE "issue" DROP CONSTRAINT "FK_10b17b49d1ee77e7184216001e0"` ); await queryRunner.query( `ALTER TABLE "issue" DROP CONSTRAINT "FK_276e20d053f3cff1645803c95d8"` ); await queryRunner.query( `ALTER TABLE "issue_comment" DROP CONSTRAINT "FK_180710fead1c94ca499c57a7d42"` ); await queryRunner.query( `ALTER TABLE "issue_comment" DROP CONSTRAINT "FK_707b033c2d0653f75213614789d"` ); await queryRunner.query( `ALTER TABLE "user_settings" DROP CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78"` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" DROP CONSTRAINT "FK_03f7958328e311761b0de675fbe"` ); await queryRunner.query( `ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"` ); await queryRunner.query( `ALTER TABLE "watchlist" DROP CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7"` ); await queryRunner.query( `ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"` ); await queryRunner.query( `ALTER TABLE "media_request" DROP CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15"` ); await queryRunner.query( `ALTER TABLE "media_request" DROP CONSTRAINT "FK_6997bee94720f1ecb7f31137095"` ); await queryRunner.query( `ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"` ); await queryRunner.query( `ALTER TABLE "season_request" DROP CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a"` ); await queryRunner.query( `ALTER TABLE "blacklist" DROP CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99"` ); await queryRunner.query( `ALTER TABLE "blacklist" DROP CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e"` ); await queryRunner.query( `DROP INDEX "public"."IDX_28c5d1d16da7908c97c9bc2f74"` ); await queryRunner.query(`DROP TABLE "session"`); await queryRunner.query(`DROP TABLE "discover_slider"`); await queryRunner.query(`DROP TABLE "issue"`); await queryRunner.query(`DROP TABLE "issue_comment"`); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `DROP INDEX "public"."IDX_939f205946256cc0d2a1ac51a8"` ); await queryRunner.query(`DROP TABLE "watchlist"`); await queryRunner.query( `DROP INDEX "public"."IDX_7ff2d11f6a83cb52386eaebe74"` ); await queryRunner.query( `DROP INDEX "public"."IDX_41a289eb1fa489c1bc6f38d9c3"` ); await queryRunner.query( `DROP INDEX "public"."IDX_7157aad07c73f6a6ae3bbd5ef5"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`DROP TABLE "season"`); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query(`DROP TABLE "season_request"`); await queryRunner.query( `DROP INDEX "public"."IDX_6bbafa28411e6046421991ea21"` ); await queryRunner.query(`DROP TABLE "blacklist"`); } } ================================================ FILE: server/migration/postgres/1734786596045-AddTelegramMessageThreadId.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTelegramMessageThreadId1734786596045 implements MigrationInterface { name = 'AddTelegramMessageThreadId1734786596045'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" ADD "telegramMessageThreadId" character varying` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" DROP COLUMN "telegramMessageThreadId"` ); } } ================================================ FILE: server/migration/postgres/1734805738349-AddOverrideRules.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddOverrideRules1734805738349 implements MigrationInterface { name = 'AddOverrideRules1734805738349'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "override_rule" ("id" SERIAL NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" character varying, "genre" character varying, "language" character varying, "keywords" character varying, "profileId" integer, "rootFolder" character varying, "tags" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_657f810c7b20a4fce45aee8f182" PRIMARY KEY ("id"))` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "override_rule"`); } } ================================================ FILE: server/migration/postgres/1734809898562-FixNullFields.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class FixNullFields1734809898562 implements MigrationInterface { name = 'FixNullFields1734809898562'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"` ); await queryRunner.query( `ALTER TABLE "watchlist" ALTER COLUMN "mediaId" DROP NOT NULL` ); await queryRunner.query( `ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"` ); await queryRunner.query( `ALTER TABLE "media_request" ALTER COLUMN "mediaId" DROP NOT NULL` ); await queryRunner.query( `ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"` ); await queryRunner.query( `ALTER TABLE "season" ALTER COLUMN "mediaId" DROP NOT NULL` ); await queryRunner.query( `ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "season" DROP CONSTRAINT "FK_087099b39600be695591da9a49c"` ); await queryRunner.query( `ALTER TABLE "media_request" DROP CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0"` ); await queryRunner.query( `ALTER TABLE "watchlist" DROP CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc"` ); await queryRunner.query( `ALTER TABLE "season" ALTER COLUMN "mediaId" SET NOT NULL` ); await queryRunner.query( `ALTER TABLE "season" ADD CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "media_request" ALTER COLUMN "mediaId" SET NOT NULL` ); await queryRunner.query( `ALTER TABLE "media_request" ADD CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "watchlist" ALTER COLUMN "mediaId" SET NOT NULL` ); await queryRunner.query( `ALTER TABLE "watchlist" ADD CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); } } ================================================ FILE: server/migration/postgres/1737320080282-AddBlacklistTagsColumn.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface { name = 'AddBlacklistTagsColumn1737320080282'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blacklist" ADD blacklistedTags character varying` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blacklist" DROP COLUMN blacklistedTags` ); } } ================================================ FILE: server/migration/postgres/1743023615532-UpdateWebPush.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateWebPush1743023615532 implements MigrationInterface { name = 'UpdateWebPush1743023615532'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_push_subscription" ADD "userAgent" character varying` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" ADD "createdAt" TIMESTAMP DEFAULT now()` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth")` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" DROP COLUMN "createdAt"` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" DROP COLUMN "userAgent"` ); } } ================================================ FILE: server/migration/postgres/1743107707465-AddUserAvatarCacheFields.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserAvatarCacheFields1743107707465 implements MigrationInterface { name = 'AddUserAvatarCacheFields1743107707465'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user" ADD "avatarETag" character varying` ); await queryRunner.query( `ALTER TABLE "user" ADD "avatarVersion" character varying` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarVersion"`); await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarETag"`); } } ================================================ FILE: server/migration/postgres/1745492376568-UpdateWebPush.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateWebPush1745492376568 implements MigrationInterface { name = 'UpdateWebPush1745492376568'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blacklist" RENAME COLUMN "blacklistedtags" TO "blacklistedTags"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blacklist" RENAME COLUMN "blacklistedTags" TO "blacklistedtags"` ); } } ================================================ FILE: server/migration/postgres/1746811308203-FixIssueTimestamps.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class FixIssueTimestamps1746811308203 implements MigrationInterface { name = 'FixIssueTimestamps1746811308203'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "watchlist" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "watchlist" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "override_rule" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "override_rule" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "season_request" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "season_request" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "media_request" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "media_request" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "user_push_subscription" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "user" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "user" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "blacklist" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "season" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "season" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "media" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "media" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "issue" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "issue" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "issue_comment" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "issue_comment" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "discover_slider" ALTER COLUMN "createdAt" TYPE TIMESTAMP WITH TIME ZONE USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "discover_slider" ALTER COLUMN "updatedAt" TYPE TIMESTAMP WITH TIME ZONE USING "updatedAt" AT TIME ZONE 'UTC' `); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE "discover_slider" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "discover_slider" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "issue_comment" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "issue_comment" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "issue" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "issue" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "media" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "media" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "season" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "season" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "blacklist" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "user" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "user" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "user_push_subscription" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "media_request" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "media_request" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "season_request" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "season_request" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "override_rule" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "override_rule" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "watchlist" ALTER COLUMN "updatedAt" TYPE TIMESTAMP USING "updatedAt" AT TIME ZONE 'UTC' `); await queryRunner.query(` ALTER TABLE "watchlist" ALTER COLUMN "createdAt" TYPE TIMESTAMP USING "createdAt" AT TIME ZONE 'UTC' `); } } ================================================ FILE: server/migration/postgres/1765233385034-AddUniqueConstraintToPushSubscription.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface { name = 'AddUniqueConstraintToPushSubscription1765233385034'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` DELETE FROM "user_push_subscription" WHERE id NOT IN ( SELECT MAX(id) FROM "user_push_subscription" GROUP BY "endpoint", "userId" ) `); await queryRunner.query( `ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"` ); } } ================================================ FILE: server/migration/postgres/1770627987304-AddPerformanceIndexes.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPerformanceIndexes1770627987304 implements MigrationInterface { name = 'AddPerformanceIndexes1770627987304'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE INDEX "IDX_4c696e8ed36ae34fe18abe59d2" ON "media_request" ("status") ` ); await queryRunner.query( `CREATE INDEX "IDX_c730c2d67f271a372c39a07b7e" ON "media" ("status") ` ); await queryRunner.query( `CREATE INDEX "IDX_5d6218de4f547909391a5c1347" ON "media" ("status4k") ` ); await queryRunner.query( `CREATE INDEX "IDX_f8233358694d1677a67899b90a" ON "media" ("tmdbId", "mediaType") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `DROP INDEX "public"."IDX_f8233358694d1677a67899b90a"` ); await queryRunner.query( `DROP INDEX "public"."IDX_5d6218de4f547909391a5c1347"` ); await queryRunner.query( `DROP INDEX "public"."IDX_c730c2d67f271a372c39a07b7e"` ); await queryRunner.query( `DROP INDEX "public"."IDX_4c696e8ed36ae34fe18abe59d2"` ); } } ================================================ FILE: server/migration/postgres/1771080196816-RenameBlacklistToBlocklist.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class RenameBlacklistToBlocklist1771080196816 implements MigrationInterface { name = 'RenameBlacklistToBlocklist1771080196816'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "blacklist" RENAME TO "blocklist"`); await queryRunner.query( `ALTER TABLE "blocklist" RENAME COLUMN "blacklistedTags" TO "blocklistedTags"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blocklist" RENAME COLUMN "blocklistedTags" TO "blacklistedTags"` ); await queryRunner.query(`ALTER TABLE "blocklist" RENAME TO "blacklist"`); } } ================================================ FILE: server/migration/postgres/1771259406751-AddForeignKeyIndexes.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddForeignKeyIndexes1771259406751 implements MigrationInterface { name = 'AddForeignKeyIndexes1771259406751'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blocklist" DROP CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e"` ); await queryRunner.query( `ALTER TABLE "blocklist" DROP CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99"` ); await queryRunner.query( `DROP INDEX "public"."IDX_6bbafa28411e6046421991ea21"` ); await queryRunner.query( `CREATE SEQUENCE IF NOT EXISTS "blocklist_id_seq" OWNED BY "blocklist"."id"` ); await queryRunner.query( `ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('"blocklist_id_seq"')` ); await queryRunner.query( `ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT` ); await queryRunner.query( `CREATE INDEX "IDX_ae34e6b153a90672eb9dc4857d" ON "watchlist" ("requestedById") ` ); await queryRunner.query( `CREATE INDEX "IDX_6641da8d831b93dfcb429f8b8b" ON "watchlist" ("mediaId") ` ); await queryRunner.query( `CREATE INDEX "IDX_707b033c2d0653f75213614789" ON "issue_comment" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_180710fead1c94ca499c57a7d4" ON "issue_comment" ("issueId") ` ); await queryRunner.query( `CREATE INDEX "IDX_53d04c07c3f4f54eae372ed665" ON "issue" ("issueType") ` ); await queryRunner.query( `CREATE INDEX "IDX_276e20d053f3cff1645803c95d" ON "issue" ("mediaId") ` ); await queryRunner.query( `CREATE INDEX "IDX_10b17b49d1ee77e7184216001e" ON "issue" ("createdById") ` ); await queryRunner.query( `CREATE INDEX "IDX_da88a1019c850d1a7b143ca02e" ON "issue" ("modifiedById") ` ); await queryRunner.query( `CREATE INDEX "IDX_6f14737e346d6b27d8e50d2157" ON "season_request" ("requestId") ` ); await queryRunner.query( `CREATE INDEX "IDX_a1aa713f41c99e9d10c48da75a" ON "media_request" ("mediaId") ` ); await queryRunner.query( `CREATE INDEX "IDX_6997bee94720f1ecb7f3113709" ON "media_request" ("requestedById") ` ); await queryRunner.query( `CREATE INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1" ON "media_request" ("modifiedById") ` ); await queryRunner.query( `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_087099b39600be695591da9a49" ON "season" ("mediaId") ` ); await queryRunner.query( `ALTER TABLE "blocklist" ADD CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "blocklist" ADD CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blocklist" DROP CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d"` ); await queryRunner.query( `ALTER TABLE "blocklist" DROP CONSTRAINT "FK_356721a49f145aa439c16e6b999"` ); await queryRunner.query( `DROP INDEX "public"."IDX_087099b39600be695591da9a49"` ); await queryRunner.query( `DROP INDEX "public"."IDX_356721a49f145aa439c16e6b99"` ); await queryRunner.query( `DROP INDEX "public"."IDX_09b94c932e84635c5461f3c0a9"` ); await queryRunner.query( `DROP INDEX "public"."IDX_03f7958328e311761b0de675fb"` ); await queryRunner.query( `DROP INDEX "public"."IDX_f4fc4efa14c3ba2b29c4525fa1"` ); await queryRunner.query( `DROP INDEX "public"."IDX_6997bee94720f1ecb7f3113709"` ); await queryRunner.query( `DROP INDEX "public"."IDX_a1aa713f41c99e9d10c48da75a"` ); await queryRunner.query( `DROP INDEX "public"."IDX_6f14737e346d6b27d8e50d2157"` ); await queryRunner.query( `DROP INDEX "public"."IDX_da88a1019c850d1a7b143ca02e"` ); await queryRunner.query( `DROP INDEX "public"."IDX_10b17b49d1ee77e7184216001e"` ); await queryRunner.query( `DROP INDEX "public"."IDX_276e20d053f3cff1645803c95d"` ); await queryRunner.query( `DROP INDEX "public"."IDX_53d04c07c3f4f54eae372ed665"` ); await queryRunner.query( `DROP INDEX "public"."IDX_180710fead1c94ca499c57a7d4"` ); await queryRunner.query( `DROP INDEX "public"."IDX_707b033c2d0653f75213614789"` ); await queryRunner.query( `DROP INDEX "public"."IDX_6641da8d831b93dfcb429f8b8b"` ); await queryRunner.query( `DROP INDEX "public"."IDX_ae34e6b153a90672eb9dc4857d"` ); await queryRunner.query( `ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('blacklist_id_seq')` ); await queryRunner.query( `ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT` ); await queryRunner.query(`DROP SEQUENCE "blocklist_id_seq"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "blocklist" ADD CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); await queryRunner.query( `ALTER TABLE "blocklist" ADD CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` ); } } ================================================ FILE: server/migration/postgres/1771337333450-RecoveryLinkExpirationDateTime.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class RecoveryLinkExpirationDateTime1771337333450 implements MigrationInterface { name = 'RecoveryLinkExpirationDateTime1771337333450'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE TIMESTAMP WITH TIME ZONE` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE date USING ("recoveryLinkExpirationDate"::date)` ); } } ================================================ FILE: server/migration/postgres/1772000000000-FixBlocklistIdDefault.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class FixBlocklistIdDefault1772000000000 implements MigrationInterface { name = 'FixBlocklistIdDefault1772000000000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('public."blocklist_id_seq"'::regclass)` ); await queryRunner.query( `SELECT setval('public."blocklist_id_seq"', COALESCE((SELECT MAX("id") FROM "blocklist"), 0) + 1, false)` ); } public async down(): Promise { // Intentionally left empty: dropping the DEFAULT on blocklist.id would // reintroduce the original bug and break blocklist inserts. } } ================================================ FILE: server/migration/postgres/1772048000333-AddMediaTypeToUniqueConstraints.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaTypeToUniqueConstraints1772048000333 implements MigrationInterface { name = 'AddMediaTypeToUniqueConstraints1772048000333'; public async up(queryRunner: QueryRunner): Promise { // Manually added: TypeORM migration:generate does not detect changes to named unique constraints. await queryRunner.query( `ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_DB"` ); await queryRunner.query( `ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "mediaType", "requestedById")` ); // Auto-generated by TypeORM await queryRunner.query( `CREATE SEQUENCE IF NOT EXISTS "blocklist_id_seq" OWNED BY "blocklist"."id"` ); await queryRunner.query( `ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('"blocklist_id_seq"')` ); await queryRunner.query( `ALTER TABLE "blocklist" DROP CONSTRAINT "UQ_6bbafa28411e6046421991ea21c"` ); await queryRunner.query( `ALTER TABLE "blocklist" ADD CONSTRAINT "UQ_81504e02db89b4c1e3152729fa6" UNIQUE ("tmdbId", "mediaType")` ); } public async down(queryRunner: QueryRunner): Promise { // Manually added: TypeORM migration:generate does not detect changes to named unique constraints. await queryRunner.query( `ALTER TABLE "watchlist" DROP CONSTRAINT "UNIQUE_USER_DB"` ); await queryRunner.query( `ALTER TABLE "watchlist" ADD CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById")` ); // Auto-generated by TypeORM await queryRunner.query( `ALTER TABLE "blocklist" DROP CONSTRAINT "UQ_81504e02db89b4c1e3152729fa6"` ); await queryRunner.query( `ALTER TABLE "blocklist" ADD CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId")` ); await queryRunner.query( `ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT` ); await queryRunner.query(`DROP SEQUENCE "blocklist_id_seq"`); } } ================================================ FILE: server/migration/sqlite/1603944374840-InitialMigration.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class InitialMigration1603944374840 implements MigrationInterface { name = 'InitialMigration1603944374840'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `CREATE TABLE "season_request" ("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')), "requestId" integer)` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer)` ); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "UQ_7ff2d11f6a83cb52386eaebe74b" UNIQUE ("imdbId"))` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE TABLE "session" ("expiredAt" bigint NOT NULL, "id" varchar(255) PRIMARY KEY NOT NULL, "json" text NOT NULL)` ); await queryRunner.query( `CREATE INDEX "IDX_28c5d1d16da7908c97c9bc2f74" ON "session" ("expiredAt") ` ); await queryRunner.query( `CREATE TABLE "temporary_season_request" ("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')), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "season_request"` ); await queryRunner.query(`DROP TABLE "season_request"`); await queryRunner.query( `ALTER TABLE "temporary_season_request" RENAME TO "season_request"` ); await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); await queryRunner.query( `ALTER TABLE "season_request" RENAME TO "temporary_season_request"` ); await queryRunner.query( `CREATE TABLE "season_request" ("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')), "requestId" integer)` ); await queryRunner.query( `INSERT INTO "season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "temporary_season_request"` ); await queryRunner.query(`DROP TABLE "temporary_season_request"`); await queryRunner.query(`DROP INDEX "IDX_28c5d1d16da7908c97c9bc2f74"`); await queryRunner.query(`DROP TABLE "session"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query(`DROP TABLE "season_request"`); await queryRunner.query(`DROP TABLE "user"`); } } ================================================ FILE: server/migration/sqlite/1605085519544-SeasonStatus.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class SeasonStatus1605085519544 implements MigrationInterface { name = 'SeasonStatus1605085519544'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `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)` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "season"`); } } ================================================ FILE: server/migration/sqlite/1606730060700-CascadeMigration.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CascadeMigration1606730060700 implements MigrationInterface { name = 'CascadeMigration1606730060700'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_season_request" ("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')), "requestId" integer)` ); await queryRunner.query( `INSERT INTO "temporary_season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "season_request"` ); await queryRunner.query(`DROP TABLE "season_request"`); await queryRunner.query( `ALTER TABLE "temporary_season_request" RENAME TO "season_request"` ); await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); await queryRunner.query( `CREATE TABLE "temporary_season_request" ("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')), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "season_request"` ); await queryRunner.query(`DROP TABLE "season_request"`); await queryRunner.query( `ALTER TABLE "temporary_season_request" RENAME TO "season_request"` ); await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); await queryRunner.query( `CREATE TABLE "temporary_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, CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "season"` ); await queryRunner.query(`DROP TABLE "season"`); await queryRunner.query( `ALTER TABLE "temporary_season" RENAME TO "season"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "season" RENAME TO "temporary_season"` ); await queryRunner.query( `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)` ); await queryRunner.query( `INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "temporary_season"` ); await queryRunner.query(`DROP TABLE "temporary_season"`); await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); await queryRunner.query( `ALTER TABLE "season_request" RENAME TO "temporary_season_request"` ); await queryRunner.query( `CREATE TABLE "season_request" ("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')), "requestId" integer)` ); await queryRunner.query( `INSERT INTO "season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "temporary_season_request"` ); await queryRunner.query(`DROP TABLE "temporary_season_request"`); await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); await queryRunner.query( `ALTER TABLE "season_request" RENAME TO "temporary_season_request"` ); await queryRunner.query( `CREATE TABLE "season_request" ("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')), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "temporary_season_request"` ); await queryRunner.query(`DROP TABLE "temporary_season_request"`); } } ================================================ FILE: server/migration/sqlite/1607928251245-DropImdbIdConstraint.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; import { TableUnique } from 'typeorm'; export class DropImdbIdConstraint1607928251245 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.dropUniqueConstraint( 'media', 'UQ_7ff2d11f6a83cb52386eaebe74b' ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.createUniqueConstraint( 'media', new TableUnique({ name: 'UQ_7ff2d11f6a83cb52386eaebe74b', columnNames: ['imdbId'], }) ); } } ================================================ FILE: server/migration/sqlite/1608217312474-AddUserRequestDeleteCascades.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserRequestDeleteCascades1608219049304 implements MigrationInterface { name = 'AddUserRequestDeleteCascades1608219049304'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); } } ================================================ FILE: server/migration/sqlite/1608477467935-AddLastSeasonChangeMedia.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLastSeasonChangeMedia1608477467935 implements MigrationInterface { name = 'AddLastSeasonChangeMedia1608477467935'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); } } ================================================ FILE: server/migration/sqlite/1608477467936-ForceDropImdbUniqueConstraint.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class ForceDropImdbUniqueConstraint1608477467935 implements MigrationInterface { name = 'ForceDropImdbUniqueConstraint1608477467936'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); } } ================================================ FILE: server/migration/sqlite/1609236552057-RemoveTmdbIdUniqueConstraint.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class RemoveTmdbIdUniqueConstraint1609236552057 implements MigrationInterface { name = 'RemoveTmdbIdUniqueConstraint1609236552057'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"), CONSTRAINT "UQ_7157aad07c73f6a6ae3bbd5ef5e" UNIQUE ("tmdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); } } ================================================ FILE: server/migration/sqlite/1610070934506-LocalUsers.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class LocalUsers1610070934506 implements MigrationInterface { name = 'LocalUsers1610070934506'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer NOT NULL, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); } } ================================================ FILE: server/migration/sqlite/1610370640747-Add4kStatusFields.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class Add4kStatusFields1610370640747 implements MigrationInterface { name = 'Add4kStatusFields1610370640747'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_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, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "season"` ); await queryRunner.query(`DROP TABLE "season"`); await queryRunner.query( `ALTER TABLE "temporary_season" RENAME TO "season"` ); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "season" RENAME TO "temporary_season"` ); await queryRunner.query( `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, CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "temporary_season"` ); await queryRunner.query(`DROP TABLE "temporary_season"`); } } ================================================ FILE: server/migration/sqlite/1610522845513-AddMediaAddedFieldToMedia.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaAddedFieldToMedia1610522845513 implements MigrationInterface { name = 'AddMediaAddedFieldToMedia1610522845513'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); } } ================================================ FILE: server/migration/sqlite/1611508672722-AddDisplayNameToUser.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddDisplayNameToUser1611508672722 implements MigrationInterface { name = 'AddDisplayNameToUser1611508672722'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "username" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); } } ================================================ FILE: server/migration/sqlite/1611757511674-SonarrRadarrSyncServiceFields.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class SonarrRadarrSyncServiceFields1611757511674 implements MigrationInterface { name = 'SonarrRadarrSyncServiceFields1611757511674'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); } } ================================================ FILE: server/migration/sqlite/1611801511397-AddRatingKeysToMedia.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddRatingKeysToMedia1611801511397 implements MigrationInterface { name = 'AddRatingKeysToMedia1611801511397'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); } } ================================================ FILE: server/migration/sqlite/1612482778137-AddResetPasswordGuidAndExpiryDate.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddResetPasswordGuidAndExpiryDate1612482778137 implements MigrationInterface { name = 'AddResetPasswordGuidAndExpiryDate1612482778137'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); } } ================================================ FILE: server/migration/sqlite/1612571545781-AddLanguageProfileId.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddLanguageProfileId1612571545781 implements MigrationInterface { name = 'AddLanguageProfileId1612571545781'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); } } ================================================ FILE: server/migration/sqlite/1613379909641-AddJellyfinUserParams.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddJellyfinUserParams1613379909641 implements MigrationInterface { name = 'AddJellyfinUserParams1613379909641'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinId" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( 'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "user"' ); await queryRunner.query('DROP TABLE "user"'); await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"'); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query( 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); await queryRunner.query('DROP TABLE "media"'); await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"'); await queryRunner.query( 'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( 'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"' ); await queryRunner.query('DROP TABLE "temporary_user"'); } } ================================================ FILE: server/migration/sqlite/1613412948344-ServerTypeEnum.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class ServerTypeEnum1613412948344 implements MigrationInterface { name = 'ServerTypeEnum1613412948344'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query( 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); await queryRunner.query('DROP TABLE "media"'); await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query( 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); await queryRunner.query('DROP TABLE "media"'); await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); } } ================================================ FILE: server/migration/sqlite/1613615266968-CreateUserSettings.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserSettings1613615266968 implements MigrationInterface { name = 'CreateUserSettings1613615266968'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))` ); await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); await queryRunner.query(`DROP TABLE "user_settings"`); } } ================================================ FILE: server/migration/sqlite/1613670041760-AddJellyfinDeviceId.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddJellyfinDeviceId1613670041760 implements MigrationInterface { name = 'AddJellyfinDeviceId1613670041760'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( 'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( 'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "user"' ); await queryRunner.query('DROP TABLE "user"'); await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"'); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query( 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); await queryRunner.query('DROP TABLE "media"'); await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( 'CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( 'INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "user"' ); await queryRunner.query('DROP TABLE "user"'); await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"'); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query( 'CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "media"' ); await queryRunner.query('DROP TABLE "media"'); await queryRunner.query('ALTER TABLE "temporary_media" RENAME TO "media"'); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"'); await queryRunner.query( 'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( 'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "temporary_user"' ); await queryRunner.query('DROP TABLE "temporary_user"'); await queryRunner.query('DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"'); await queryRunner.query('DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"'); await queryRunner.query('DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"'); await queryRunner.query('ALTER TABLE "media" RENAME TO "temporary_media"'); await queryRunner.query( 'CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaID" varchar, "jellyfinMediaID4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))' ); await queryRunner.query( 'INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k" FROM "temporary_media"' ); await queryRunner.query('DROP TABLE "temporary_media"'); await queryRunner.query( 'CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ' ); await queryRunner.query( 'CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ' ); await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"'); await queryRunner.query( 'CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "updatedAt" datetime NOT NULL DEFAULT (datetime(\'now\')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinId" varchar, "jellyfinAuthToken" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))' ); await queryRunner.query( 'INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken" FROM "temporary_user"' ); await queryRunner.query('DROP TABLE "temporary_user"'); } } ================================================ FILE: server/migration/sqlite/1613955393450-UpdateUserSettingsRegions.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateUserSettingsRegions1613955393450 implements MigrationInterface { name = 'UpdateUserSettingsRegions1613955393450'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1614334195680-AddTelegramSettingsToUserSettings.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTelegramSettingsToUserSettings1614334195680 implements MigrationInterface { name = 'AddTelegramSettingsToUserSettings1614334195680'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1615333940450-AddPGPToUserSettings.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPGPToUserSettings1615333940450 implements MigrationInterface { name = 'AddPGPToUserSettings1615333940450'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently") SELECT "id", "enableNotifications", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1616576677254-AddUserQuotaFields.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserQuotaFields1616576677254 implements MigrationInterface { name = 'AddUserQuotaFields1616576677254'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); } } ================================================ FILE: server/migration/sqlite/1617624225464-CreateTagsFieldonMediaRequest.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateTagsFieldonMediaRequest1617624225464 implements MigrationInterface { name = 'CreateTagsFieldonMediaRequest1617624225464'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); } } ================================================ FILE: server/migration/sqlite/1617730837489-AddUserSettingsNotificationAgentsField.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationAgentsField1617730837489 implements MigrationInterface { name = 'AddUserSettingsNotificationAgentsField1617730837489'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1618912653565-CreateUserPushSubscriptions.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateUserPushSubscriptions1618912653565 implements MigrationInterface { name = 'CreateUserPushSubscriptions1618912653565'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))` ); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query(`DROP TABLE "user_push_subscription"`); } } ================================================ FILE: server/migration/sqlite/1619239659754-AddUserSettingsLocale.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsLocale1619239659754 implements MigrationInterface { name = 'AddUserSettingsLocale1619239659754'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1619339817343-AddUserSettingsNotificationTypes.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsNotificationTypes1619339817343 implements MigrationInterface { name = 'AddUserSettingsNotificationTypes1619339817343'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1634904083966-AddIssues.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddIssues1634904083966 implements MigrationInterface { name = 'AddIssues1634904083966'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)` ); await queryRunner.query( `CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)` ); await queryRunner.query( `CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"` ); await queryRunner.query(`DROP TABLE "issue"`); await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`); await queryRunner.query( `CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"` ); await queryRunner.query(`DROP TABLE "issue_comment"`); await queryRunner.query( `ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"` ); await queryRunner.query( `CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer)` ); await queryRunner.query( `INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"` ); await queryRunner.query(`DROP TABLE "temporary_issue_comment"`); await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`); await queryRunner.query( `CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer)` ); await queryRunner.query( `INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"` ); await queryRunner.query(`DROP TABLE "temporary_issue"`); await queryRunner.query(`DROP TABLE "issue_comment"`); await queryRunner.query(`DROP TABLE "issue"`); } } ================================================ FILE: server/migration/sqlite/1635079863457-AddPushbulletPushoverUserSettings.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPushbulletPushoverUserSettings1635079863457 implements MigrationInterface { name = 'AddPushbulletPushoverUserSettings1635079863457'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1660632269368-AddWatchlistSyncUserSetting.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddWatchlistSyncUserSetting1660632269368 implements MigrationInterface { name = 'AddWatchlistSyncUserSetting1660632269368'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1660714479373-AddMediaRequestIsAutoRequestedField.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaRequestIsAutoRequestedField1660714479373 implements MigrationInterface { name = 'AddMediaRequestIsAutoRequestedField1660714479373'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); } } ================================================ FILE: server/migration/sqlite/1672041273674-AddDiscoverSlider.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddDiscoverSlider1672041273674 implements MigrationInterface { name = 'AddDiscoverSlider1672041273674'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `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')))` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "discover_slider"`); } } ================================================ FILE: server/migration/sqlite/1682608634546-AddWatchlists.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddWatchlists1682608634546 implements MigrationInterface { name = 'AddWatchlists1682608634546'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))` ); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); await queryRunner.query(`DROP TABLE "watchlist"`); } } ================================================ FILE: server/migration/sqlite/1697393491630-AddUserPushoverSound.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserPushoverSound1697393491630 implements MigrationInterface { name = 'AddUserPushoverSound1697393491630'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1699901142442-AddBlacklist.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddBlacklist1699901142442 implements MigrationInterface { name = 'AddBlacklist1699901142442'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')),"userId" integer, "mediaId" integer,CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); } } ================================================ FILE: server/migration/sqlite/1727907530757-AddUserSettingsStreamingRegion.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserSettingsStreamingRegion1727907530757 implements MigrationInterface { name = 'AddUserSettingsStreamingRegion1727907530757'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "discoverRegion" varchar, "streamingRegion" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "region" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1734287582736-AddTelegramMessageThreadId.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddTelegramMessageThreadId1734287582736 implements MigrationInterface { name = 'AddTelegramMessageThreadId1734287582736'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "user_settings"` ); await queryRunner.query(`DROP TABLE "user_settings"`); await queryRunner.query( `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` ); await queryRunner.query( `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId" FROM "temporary_user_settings"` ); await queryRunner.query(`DROP TABLE "temporary_user_settings"`); } } ================================================ FILE: server/migration/sqlite/1734805733535-AddOverrideRules.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddOverrideRules1734805733535 implements MigrationInterface { name = 'AddOverrideRules1734805733535'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP TABLE "override_rule"`); } } ================================================ FILE: server/migration/sqlite/1737320080282-AddBlacklistTagsColumn.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface { name = 'AddBlacklistTagsColumn1737320080282'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"` ); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` ); await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))` ); await queryRunner.query( `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` ); await queryRunner.query(`DROP TABLE "temporary_blacklist"`); } } ================================================ FILE: server/migration/sqlite/1743023610704-UpdateWebPush.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateWebPush1743023610704 implements MigrationInterface { name = 'UpdateWebPush1743023610704'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)` ); await queryRunner.query( `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"` ); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))` ); await queryRunner.query( `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"` ); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); await queryRunner.query( `CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"` ); await queryRunner.query(`DROP TABLE "watchlist"`); await queryRunner.query( `ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"` ); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"` ); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` ); await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))` ); await queryRunner.query( `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` ); await queryRunner.query(`DROP TABLE "temporary_blacklist"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); await queryRunner.query( `ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"` ); await queryRunner.query( `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))` ); await queryRunner.query( `INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"` ); await queryRunner.query(`DROP TABLE "temporary_watchlist"`); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` ); await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)` ); await queryRunner.query( `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` ); await queryRunner.query(`DROP TABLE "temporary_blacklist"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` ); await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))` ); await queryRunner.query( `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` ); await queryRunner.query(`DROP TABLE "temporary_blacklist"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); } } ================================================ FILE: server/migration/sqlite/1743107645301-AddUserAvatarCacheFields.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUserAvatarCacheFields1743107645301 implements MigrationInterface { name = 'AddUserAvatarCacheFields1743107645301'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); } } ================================================ FILE: server/migration/sqlite/1745492372230-UpdateWebPush.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class UpdateWebPush1745492372230 implements MigrationInterface { name = 'UpdateWebPush1745492372230'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"` ); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` ); await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` ); await queryRunner.query(`DROP TABLE "temporary_blacklist"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); } } ================================================ FILE: server/migration/sqlite/1765233385034-AddUniqueConstraintToPushSubscription.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddUniqueConstraintToPushSubscription1765233385034 implements MigrationInterface { name = 'AddUniqueConstraintToPushSubscription1765233385034'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` DELETE FROM "user_push_subscription" WHERE id NOT IN ( SELECT MAX(id) FROM "user_push_subscription" GROUP BY "endpoint", "userId" ) `); await queryRunner.query( `CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`); } } ================================================ FILE: server/migration/sqlite/1770627968781-AddPerformanceIndexes.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddPerformanceIndexes1770627968781 implements MigrationInterface { name = 'AddPerformanceIndexes1770627968781'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `DROP INDEX IF EXISTS "UQ_6427d07d9a171a3a1ab87480005"` ); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_939f205946256cc0d2a1ac51a8"` ); await queryRunner.query( `CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"` ); await queryRunner.query(`DROP TABLE "watchlist"`); await queryRunner.query( `ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"` ); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); await queryRunner.query( `CREATE TABLE "temporary_issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "issueId" integer, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "issue_comment"` ); await queryRunner.query(`DROP TABLE "issue_comment"`); await queryRunner.query( `ALTER TABLE "temporary_issue_comment" RENAME TO "issue_comment"` ); await queryRunner.query( `CREATE TABLE "temporary_issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "issue"` ); await queryRunner.query(`DROP TABLE "issue"`); await queryRunner.query(`ALTER TABLE "temporary_issue" RENAME TO "issue"`); await queryRunner.query( `CREATE TABLE "temporary_override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP))` ); await queryRunner.query( `INSERT INTO "temporary_override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt" FROM "override_rule"` ); await queryRunner.query(`DROP TABLE "override_rule"`); await queryRunner.query( `ALTER TABLE "temporary_override_rule" RENAME TO "override_rule"` ); await queryRunner.query( `CREATE TABLE "temporary_season_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "season_request"` ); await queryRunner.query(`DROP TABLE "season_request"`); await queryRunner.query( `ALTER TABLE "temporary_season_request" RENAME TO "season_request"` ); await queryRunner.query( `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "media_request"` ); await queryRunner.query(`DROP TABLE "media_request"`); await queryRunner.query( `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` ); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_6bbafa28411e6046421991ea21"` ); await queryRunner.query( `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags" FROM "blacklist"` ); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query( `CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k" FROM "season"` ); await queryRunner.query(`DROP TABLE "season"`); await queryRunner.query( `ALTER TABLE "temporary_season" RENAME TO "season"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_7157aad07c73f6a6ae3bbd5ef5"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_41a289eb1fa489c1bc6f38d9c3"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_7ff2d11f6a83cb52386eaebe74"` ); await queryRunner.query( `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"` ); await queryRunner.query(`DROP TABLE "media"`); await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE TABLE "temporary_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 (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP))` ); await queryRunner.query( `INSERT INTO "temporary_discover_slider"("id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt") SELECT "id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt" FROM "discover_slider"` ); await queryRunner.query(`DROP TABLE "discover_slider"`); await queryRunner.query( `ALTER TABLE "temporary_discover_slider" RENAME TO "discover_slider"` ); await queryRunner.query( `CREATE INDEX "IDX_4c696e8ed36ae34fe18abe59d2" ON "media_request" ("status") ` ); await queryRunner.query( `CREATE INDEX "IDX_c730c2d67f271a372c39a07b7e" ON "media" ("status") ` ); await queryRunner.query( `CREATE INDEX "IDX_5d6218de4f547909391a5c1347" ON "media" ("status4k") ` ); await queryRunner.query( `CREATE INDEX "IDX_f8233358694d1677a67899b90a" ON "media" ("tmdbId", "mediaType") ` ); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_f8233358694d1677a67899b90a"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_5d6218de4f547909391a5c1347"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_c730c2d67f271a372c39a07b7e"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_4c696e8ed36ae34fe18abe59d2"` ); await queryRunner.query( `ALTER TABLE "discover_slider" RENAME TO "temporary_discover_slider"` ); await queryRunner.query( `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')))` ); await queryRunner.query( `INSERT INTO "discover_slider"("id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt") SELECT "id", "type", "order", "isBuiltIn", "enabled", "title", "data", "createdAt", "updatedAt" FROM "temporary_discover_slider"` ); await queryRunner.query(`DROP TABLE "temporary_discover_slider"`); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_7ff2d11f6a83cb52386eaebe74"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_41a289eb1fa489c1bc6f38d9c3"` ); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_7157aad07c73f6a6ae3bbd5ef5"` ); await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); await queryRunner.query( `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` ); await queryRunner.query( `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"` ); await queryRunner.query(`DROP TABLE "temporary_media"`); await queryRunner.query( `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "season" RENAME TO "temporary_season"` ); await queryRunner.query( `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, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId", "status4k" FROM "temporary_season"` ); await queryRunner.query(`DROP TABLE "temporary_season"`); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_6bbafa28411e6046421991ea21"` ); await queryRunner.query( `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` ); await queryRunner.query( `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, "blacklistedTags" varchar, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId", "blacklistedTags" FROM "temporary_blacklist"` ); await queryRunner.query(`DROP TABLE "temporary_blacklist"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` ); await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query( `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` ); await queryRunner.query( `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest" FROM "temporary_media_request"` ); await queryRunner.query(`DROP TABLE "temporary_media_request"`); await queryRunner.query( `ALTER TABLE "season_request" RENAME TO "temporary_season_request"` ); await queryRunner.query( `CREATE TABLE "season_request" ("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')), "requestId" integer, CONSTRAINT "FK_6f14737e346d6b27d8e50d2157a" FOREIGN KEY ("requestId") REFERENCES "media_request" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "season_request"("id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "requestId" FROM "temporary_season_request"` ); await queryRunner.query(`DROP TABLE "temporary_season_request"`); await queryRunner.query( `ALTER TABLE "override_rule" RENAME TO "temporary_override_rule"` ); await queryRunner.query( `CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))` ); await queryRunner.query( `INSERT INTO "override_rule"("id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt") SELECT "id", "radarrServiceId", "sonarrServiceId", "users", "genre", "language", "keywords", "profileId", "rootFolder", "tags", "createdAt", "updatedAt" FROM "temporary_override_rule"` ); await queryRunner.query(`DROP TABLE "temporary_override_rule"`); await queryRunner.query(`ALTER TABLE "issue" RENAME TO "temporary_issue"`); await queryRunner.query( `CREATE TABLE "issue" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "issueType" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "problemSeason" integer NOT NULL DEFAULT (0), "problemEpisode" integer NOT NULL DEFAULT (0), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "createdById" integer, "modifiedById" integer, CONSTRAINT "FK_da88a1019c850d1a7b143ca02e5" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_10b17b49d1ee77e7184216001e0" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_276e20d053f3cff1645803c95d8" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "issue"("id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById") SELECT "id", "issueType", "status", "problemSeason", "problemEpisode", "createdAt", "updatedAt", "mediaId", "createdById", "modifiedById" FROM "temporary_issue"` ); await queryRunner.query(`DROP TABLE "temporary_issue"`); await queryRunner.query( `ALTER TABLE "issue_comment" RENAME TO "temporary_issue_comment"` ); await queryRunner.query( `CREATE TABLE "issue_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "message" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "issueId" integer, CONSTRAINT "FK_180710fead1c94ca499c57a7d42" FOREIGN KEY ("issueId") REFERENCES "issue" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_707b033c2d0653f75213614789d" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "issue_comment"("id", "message", "createdAt", "updatedAt", "userId", "issueId") SELECT "id", "message", "createdAt", "updatedAt", "userId", "issueId" FROM "temporary_issue_comment"` ); await queryRunner.query(`DROP TABLE "temporary_issue_comment"`); await queryRunner.query( `DROP INDEX IF EXISTS "IDX_939f205946256cc0d2a1ac51a8"` ); await queryRunner.query( `ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"` ); await queryRunner.query( `CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"` ); await queryRunner.query(`DROP TABLE "temporary_watchlist"`); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") ` ); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query( `CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId") ` ); } } ================================================ FILE: server/migration/sqlite/1771080196816-RenameBlacklistToBlocklist.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class RenameBlacklistToBlocklist1771080196816 implements MigrationInterface { name = 'RenameBlacklistToBlocklist1771080196816'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE "temporary_blocklist" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) `); await queryRunner.query(` INSERT INTO "temporary_blocklist" ("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist" `); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId")` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "blocklist" RENAME TO "blacklist"`); await queryRunner.query(` CREATE TABLE "temporary_blacklist" ( "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION ) `); await queryRunner.query(` INSERT INTO "temporary_blacklist" ("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist" `); await queryRunner.query(`DROP TABLE "blacklist"`); await queryRunner.query( `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId")` ); } } ================================================ FILE: server/migration/sqlite/1771259394105-AddForeignKeyIndexes.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddForeignKeyIndexes1771259394105 implements MigrationInterface { name = 'AddForeignKeyIndexes1771259394105'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` ); await queryRunner.query(`DROP TABLE "blocklist"`); await queryRunner.query( `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` ); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))` ); await queryRunner.query( `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` ); await queryRunner.query(`DROP TABLE "blocklist"`); await queryRunner.query( `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` ); await queryRunner.query( `CREATE INDEX "IDX_ae34e6b153a90672eb9dc4857d" ON "watchlist" ("requestedById") ` ); await queryRunner.query( `CREATE INDEX "IDX_6641da8d831b93dfcb429f8b8b" ON "watchlist" ("mediaId") ` ); await queryRunner.query( `CREATE INDEX "IDX_707b033c2d0653f75213614789" ON "issue_comment" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_180710fead1c94ca499c57a7d4" ON "issue_comment" ("issueId") ` ); await queryRunner.query( `CREATE INDEX "IDX_53d04c07c3f4f54eae372ed665" ON "issue" ("issueType") ` ); await queryRunner.query( `CREATE INDEX "IDX_276e20d053f3cff1645803c95d" ON "issue" ("mediaId") ` ); await queryRunner.query( `CREATE INDEX "IDX_10b17b49d1ee77e7184216001e" ON "issue" ("createdById") ` ); await queryRunner.query( `CREATE INDEX "IDX_da88a1019c850d1a7b143ca02e" ON "issue" ("modifiedById") ` ); await queryRunner.query( `CREATE INDEX "IDX_6f14737e346d6b27d8e50d2157" ON "season_request" ("requestId") ` ); await queryRunner.query( `CREATE INDEX "IDX_a1aa713f41c99e9d10c48da75a" ON "media_request" ("mediaId") ` ); await queryRunner.query( `CREATE INDEX "IDX_6997bee94720f1ecb7f3113709" ON "media_request" ("requestedById") ` ); await queryRunner.query( `CREATE INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1" ON "media_request" ("modifiedById") ` ); await queryRunner.query( `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_087099b39600be695591da9a49" ON "season" ("mediaId") ` ); await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query( `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` ); await queryRunner.query(`DROP TABLE "blocklist"`); await queryRunner.query( `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` ); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query( `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` ); await queryRunner.query( `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))` ); await queryRunner.query( `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` ); await queryRunner.query(`DROP TABLE "temporary_blocklist"`); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_087099b39600be695591da9a49"`); await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); await queryRunner.query(`DROP INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1"`); await queryRunner.query(`DROP INDEX "IDX_6997bee94720f1ecb7f3113709"`); await queryRunner.query(`DROP INDEX "IDX_a1aa713f41c99e9d10c48da75a"`); await queryRunner.query(`DROP INDEX "IDX_6f14737e346d6b27d8e50d2157"`); await queryRunner.query(`DROP INDEX "IDX_da88a1019c850d1a7b143ca02e"`); await queryRunner.query(`DROP INDEX "IDX_10b17b49d1ee77e7184216001e"`); await queryRunner.query(`DROP INDEX "IDX_276e20d053f3cff1645803c95d"`); await queryRunner.query(`DROP INDEX "IDX_53d04c07c3f4f54eae372ed665"`); await queryRunner.query(`DROP INDEX "IDX_180710fead1c94ca499c57a7d4"`); await queryRunner.query(`DROP INDEX "IDX_707b033c2d0653f75213614789"`); await queryRunner.query(`DROP INDEX "IDX_6641da8d831b93dfcb429f8b8b"`); await queryRunner.query(`DROP INDEX "IDX_ae34e6b153a90672eb9dc4857d"`); await queryRunner.query( `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` ); await queryRunner.query( `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))` ); await queryRunner.query( `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` ); await queryRunner.query(`DROP TABLE "temporary_blocklist"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); await queryRunner.query( `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` ); await queryRunner.query( `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` ); await queryRunner.query(`DROP TABLE "temporary_blocklist"`); await queryRunner.query( `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId") ` ); } } ================================================ FILE: server/migration/sqlite/1771337037917-RecoveryLinkExpirationDateTime.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class RecoveryLinkExpirationDateTime1771337037917 implements MigrationInterface { name = 'RecoveryLinkExpirationDateTime1771337037917'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"` ); await queryRunner.query(`DROP TABLE "user"`); await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); } public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); await queryRunner.query( `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` ); await queryRunner.query( `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"` ); await queryRunner.query(`DROP TABLE "temporary_user"`); } } ================================================ FILE: server/migration/sqlite/1772047972752-AddMediaTypeToUniqueConstraints.ts ================================================ import type { MigrationInterface, QueryRunner } from 'typeorm'; export class AddMediaTypeToUniqueConstraints1772047972752 implements MigrationInterface { name = 'AddMediaTypeToUniqueConstraints1772047972752'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` ); await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query( `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` ); await queryRunner.query(`DROP TABLE "blocklist"`); await queryRunner.query( `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); await queryRunner.query( `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` ); await queryRunner.query(`DROP TABLE "user_push_subscription"`); await queryRunner.query( `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` ); await queryRunner.query( `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` ); await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query( `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` ); await queryRunner.query(`DROP TABLE "blocklist"`); await queryRunner.query( `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query( `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_81504e02db89b4c1e3152729fa6" UNIQUE ("tmdbId", "mediaType"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` ); await queryRunner.query(`DROP TABLE "blocklist"`); await queryRunner.query( `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); // Manually added as TypeORM migration:generate does not detect changes to named unique constraints. await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); await queryRunner.query(`DROP INDEX "IDX_ae34e6b153a90672eb9dc4857d"`); await queryRunner.query(`DROP INDEX "IDX_6641da8d831b93dfcb429f8b8b"`); await queryRunner.query( `CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "mediaType", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"` ); await queryRunner.query(`DROP TABLE "watchlist"`); await queryRunner.query( `ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"` ); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId")` ); await queryRunner.query( `CREATE INDEX "IDX_ae34e6b153a90672eb9dc4857d" ON "watchlist" ("requestedById")` ); await queryRunner.query( `CREATE INDEX "IDX_6641da8d831b93dfcb429f8b8b" ON "watchlist" ("mediaId")` ); } public async down(queryRunner: QueryRunner): Promise { // Manually added as TypeORM migration:generate does not detect changes to named unique constraints. await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`); await queryRunner.query(`DROP INDEX "IDX_ae34e6b153a90672eb9dc4857d"`); await queryRunner.query(`DROP INDEX "IDX_6641da8d831b93dfcb429f8b8b"`); await queryRunner.query( `CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"` ); await queryRunner.query(`DROP TABLE "watchlist"`); await queryRunner.query( `ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"` ); await queryRunner.query( `CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId")` ); await queryRunner.query( `CREATE INDEX "IDX_ae34e6b153a90672eb9dc4857d" ON "watchlist" ("requestedById")` ); await queryRunner.query( `CREATE INDEX "IDX_6641da8d831b93dfcb429f8b8b" ON "watchlist" ("mediaId")` ); // Blocklist: revert to original await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query( `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` ); await queryRunner.query( `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` ); await queryRunner.query(`DROP TABLE "temporary_blocklist"`); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query( `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` ); await queryRunner.query( `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` ); await queryRunner.query(`DROP TABLE "temporary_blocklist"`); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query( `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` ); await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); await queryRunner.query( `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` ); await queryRunner.query( `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` ); await queryRunner.query(`DROP TABLE "temporary_blocklist"`); await queryRunner.query( `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` ); await queryRunner.query( `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` ); await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); await queryRunner.query( `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` ); await queryRunner.query( `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` ); await queryRunner.query( `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` ); await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); await queryRunner.query( `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` ); } } ================================================ FILE: server/models/Collection.ts ================================================ import type { TmdbCollection } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import type Media from '@server/entity/Media'; import { sortBy } from 'lodash'; import type { MovieResult } from './Search'; import { mapMovieResult } from './Search'; export interface Collection { id: number; name: string; overview?: string; posterPath?: string; backdropPath?: string; parts: MovieResult[]; } export const mapCollection = ( collection: TmdbCollection, media: Media[] ): Collection => ({ id: collection.id, name: collection.name, overview: collection.overview, posterPath: collection.poster_path, backdropPath: collection.backdrop_path, parts: sortBy(collection.parts, 'release_date').map((part) => mapMovieResult( part, media?.find( (req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE ) ) ), }); ================================================ FILE: server/models/Movie.ts ================================================ import type { TmdbMovieDetails, TmdbMovieReleaseResult, TmdbProductionCompany, } from '@server/api/themoviedb/interfaces'; import type Media from '@server/entity/Media'; import type { Cast, Crew, ExternalIds, Genre, Keyword, ProductionCompany, WatchProviders, } from './common'; import { mapCast, mapCrew, mapExternalIds, mapVideos, mapWatchProviders, } from './common'; export interface Video { url?: string; site: 'YouTube'; key: string; name: string; size: number; type: | 'Clip' | 'Teaser' | 'Trailer' | 'Featurette' | 'Opening Credits' | 'Behind the Scenes' | 'Bloopers'; } export interface MovieDetails { id: number; imdbId?: string; adult: boolean; backdropPath?: string; budget: number; genres: Genre[]; homepage?: string; originalLanguage: string; originalTitle: string; overview?: string; popularity: number; relatedVideos?: Video[]; posterPath?: string; productionCompanies: ProductionCompany[]; productionCountries: { iso_3166_1: string; name: string; }[]; releaseDate: string; releases: TmdbMovieReleaseResult; revenue: number; runtime?: number; spokenLanguages: { iso_639_1: string; name: string; }[]; status: string; tagline?: string; title: string; video: boolean; voteAverage: number; voteCount: number; credits: { cast: Cast[]; crew: Crew[]; }; collection?: { id: number; name: string; posterPath?: string; backdropPath?: string; }; mediaInfo?: Media; externalIds: ExternalIds; mediaUrl?: string; watchProviders?: WatchProviders[]; keywords: Keyword[]; onUserWatchlist?: boolean; } export const mapProductionCompany = ( company: TmdbProductionCompany ): ProductionCompany => ({ id: company.id, name: company.name, originCountry: company.origin_country, description: company.description, headquarters: company.headquarters, homepage: company.homepage, logoPath: company.logo_path, }); export const mapMovieDetails = ( movie: TmdbMovieDetails, media?: Media, userWatchlist?: boolean ): MovieDetails => ({ id: movie.id, adult: movie.adult, budget: movie.budget, genres: movie.genres, relatedVideos: mapVideos(movie.videos), originalLanguage: movie.original_language, originalTitle: movie.original_title, popularity: movie.popularity, productionCompanies: movie.production_companies.map(mapProductionCompany), productionCountries: movie.production_countries, releaseDate: movie.release_date, releases: movie.release_dates, revenue: movie.revenue, spokenLanguages: movie.spoken_languages, status: movie.status, title: movie.title, video: movie.video, voteAverage: movie.vote_average, voteCount: movie.vote_count, backdropPath: movie.backdrop_path, homepage: movie.homepage, imdbId: movie.imdb_id, overview: movie.overview, posterPath: movie.poster_path, runtime: movie.runtime, tagline: movie.tagline, credits: { cast: movie.credits.cast.map(mapCast), crew: movie.credits.crew.map(mapCrew), }, collection: movie.belongs_to_collection ? { id: movie.belongs_to_collection.id, name: movie.belongs_to_collection.name, posterPath: movie.belongs_to_collection.poster_path, backdropPath: movie.belongs_to_collection.backdrop_path, } : undefined, externalIds: mapExternalIds(movie.external_ids), mediaInfo: media, watchProviders: mapWatchProviders(movie['watch/providers']?.results ?? {}), keywords: movie.keywords.keywords.map((keyword) => ({ id: keyword.id, name: keyword.name, })), onUserWatchlist: userWatchlist, }); ================================================ FILE: server/models/Person.ts ================================================ import type { TmdbPersonCreditCast, TmdbPersonCreditCrew, TmdbPersonDetails, } from '@server/api/themoviedb/interfaces'; import type Media from '@server/entity/Media'; export interface PersonDetails { id: number; name: string; birthday: string; deathday: string; knownForDepartment: string; alsoKnownAs?: string[]; gender: number; biography: string; popularity: number; placeOfBirth?: string; profilePath?: string; adult: boolean; imdbId?: string; homepage?: string; } export interface PersonCredit { id: number; originalLanguage: string; episodeCount: number; overview: string; originCountry: string[]; originalName: string; voteCount: number; name: string; mediaType?: string; popularity: number; creditId: string; backdropPath?: string; firstAirDate: string; voteAverage: number; genreIds?: number[]; posterPath?: string; originalTitle: string; video?: boolean; title: string; adult: boolean; releaseDate: string; mediaInfo?: Media; } export interface PersonCreditCast extends PersonCredit { character: string; } export interface PersonCreditCrew extends PersonCredit { department: string; job: string; } export interface CombinedCredit { id: number; cast: PersonCreditCast[]; crew: PersonCreditCrew[]; } export const mapPersonDetails = (person: TmdbPersonDetails): PersonDetails => ({ id: person.id, name: person.name, birthday: person.birthday, deathday: person.deathday, knownForDepartment: person.known_for_department, alsoKnownAs: person.also_known_as, gender: person.gender, biography: person.biography, popularity: person.popularity, placeOfBirth: person.place_of_birth, profilePath: person.profile_path, adult: person.adult, imdbId: person.imdb_id, homepage: person.homepage, }); export const mapCastCredits = ( cast: TmdbPersonCreditCast, media?: Media ): PersonCreditCast => ({ id: cast.id, originalLanguage: cast.original_language, episodeCount: cast.episode_count, overview: cast.overview, originCountry: cast.origin_country, originalName: cast.original_name, voteCount: cast.vote_count, name: cast.name, mediaType: cast.media_type, popularity: cast.popularity, creditId: cast.credit_id, backdropPath: cast.backdrop_path, firstAirDate: cast.first_air_date, voteAverage: cast.vote_average, genreIds: cast.genre_ids, posterPath: cast.poster_path, originalTitle: cast.original_title, video: cast.video, title: cast.title, adult: cast.adult, releaseDate: cast.release_date, character: cast.character, mediaInfo: media, }); export const mapCrewCredits = ( crew: TmdbPersonCreditCrew, media?: Media ): PersonCreditCrew => ({ id: crew.id, originalLanguage: crew.original_language, episodeCount: crew.episode_count, overview: crew.overview, originCountry: crew.origin_country, originalName: crew.original_name, voteCount: crew.vote_count, name: crew.name, mediaType: crew.media_type, popularity: crew.popularity, creditId: crew.credit_id, backdropPath: crew.backdrop_path, firstAirDate: crew.first_air_date, voteAverage: crew.vote_average, genreIds: crew.genre_ids, posterPath: crew.poster_path, originalTitle: crew.original_title, video: crew.video, title: crew.title, adult: crew.adult, releaseDate: crew.release_date, department: crew.department, job: crew.job, mediaInfo: media, }); ================================================ FILE: server/models/Search.ts ================================================ import type { TmdbCollectionResult, TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, TmdbPersonResult, TmdbTvDetails, TmdbTvResult, } from '@server/api/themoviedb/interfaces'; import { MediaType as MainMediaType } from '@server/constants/media'; import type Media from '@server/entity/Media'; export type MediaType = 'tv' | 'movie' | 'person' | 'collection'; interface SearchResult { id: number; mediaType: MediaType; popularity: number; posterPath?: string; backdropPath?: string; voteCount: number; voteAverage: number; genreIds: number[]; overview: string; originalLanguage: string; mediaInfo?: Media; } export interface MovieResult extends SearchResult { mediaType: 'movie'; title: string; originalTitle: string; releaseDate: string; adult: boolean; video: boolean; mediaInfo?: Media; } export interface TvResult extends SearchResult { mediaType: 'tv'; name: string; originalName: string; originCountry: string[]; firstAirDate: string; } export interface CollectionResult { id: number; mediaType: 'collection'; title: string; originalTitle: string; adult: boolean; posterPath?: string; backdropPath?: string; overview: string; originalLanguage: string; } export interface PersonResult { id: number; name: string; popularity: number; profilePath?: string; adult: boolean; mediaType: 'person'; knownFor: (MovieResult | TvResult)[]; } export type Results = MovieResult | TvResult | PersonResult | CollectionResult; export const mapMovieResult = ( movieResult: TmdbMovieResult, media?: Media ): MovieResult => ({ id: movieResult.id, mediaType: 'movie', adult: movieResult.adult, genreIds: movieResult.genre_ids, originalLanguage: movieResult.original_language, originalTitle: movieResult.original_title, overview: movieResult.overview, popularity: movieResult.popularity, releaseDate: movieResult.release_date, title: movieResult.title, video: movieResult.video, voteAverage: movieResult.vote_average, voteCount: movieResult.vote_count, backdropPath: movieResult.backdrop_path, posterPath: movieResult.poster_path, mediaInfo: media, }); export const mapTvResult = ( tvResult: TmdbTvResult, media?: Media ): TvResult => ({ id: tvResult.id, firstAirDate: tvResult.first_air_date, genreIds: tvResult.genre_ids, // Some results from tmdb dont return the mediaType so we force it here! mediaType: tvResult.media_type || 'tv', name: tvResult.name, originCountry: tvResult.origin_country, originalLanguage: tvResult.original_language, originalName: tvResult.original_name, overview: tvResult.overview, popularity: tvResult.popularity, voteAverage: tvResult.vote_average, voteCount: tvResult.vote_count, backdropPath: tvResult.backdrop_path, posterPath: tvResult.poster_path, mediaInfo: media, }); export const mapCollectionResult = ( collectionResult: TmdbCollectionResult ): CollectionResult => ({ id: collectionResult.id, mediaType: collectionResult.media_type || 'collection', adult: collectionResult.adult, originalLanguage: collectionResult.original_language, originalTitle: collectionResult.original_title, title: collectionResult.title, overview: collectionResult.overview, backdropPath: collectionResult.backdrop_path, posterPath: collectionResult.poster_path, }); export const mapPersonResult = ( personResult: TmdbPersonResult ): PersonResult => ({ id: personResult.id, name: personResult.name, popularity: personResult.popularity, adult: personResult.adult, mediaType: personResult.media_type, profilePath: personResult.profile_path, knownFor: personResult.known_for.map((result) => { if (result.media_type === 'movie') { return mapMovieResult(result); } return mapTvResult(result); }), }); export const mapSearchResults = ( results: ( | TmdbMovieResult | TmdbTvResult | TmdbPersonResult | TmdbCollectionResult )[], media?: Media[] ): Results[] => results.map((result) => { switch (result.media_type) { case 'movie': return mapMovieResult( result, media?.find( (req) => req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE ) ); case 'tv': return mapTvResult( result, media?.find( (req) => req.tmdbId === result.id && req.mediaType === MainMediaType.TV ) ); case 'collection': return mapCollectionResult(result); default: return mapPersonResult(result); } }); export const mapMovieDetailsToResult = ( movieDetails: TmdbMovieDetails ): TmdbMovieResult => ({ id: movieDetails.id, media_type: 'movie', adult: movieDetails.adult, genre_ids: movieDetails.genres.map((genre) => genre.id), original_language: movieDetails.original_language, original_title: movieDetails.original_title, overview: movieDetails.overview ?? '', popularity: movieDetails.popularity, release_date: movieDetails.release_date, title: movieDetails.title, video: movieDetails.video, vote_average: movieDetails.vote_average, vote_count: movieDetails.vote_count, backdrop_path: movieDetails.backdrop_path, poster_path: movieDetails.poster_path, }); export const mapTvDetailsToResult = ( tvDetails: TmdbTvDetails ): TmdbTvResult => ({ id: tvDetails.id, media_type: 'tv', first_air_date: tvDetails.first_air_date, genre_ids: tvDetails.genres.map((genre) => genre.id), name: tvDetails.name, origin_country: tvDetails.origin_country, original_language: tvDetails.original_language, original_name: tvDetails.original_name, overview: tvDetails.overview, popularity: tvDetails.popularity, vote_average: tvDetails.vote_average, vote_count: tvDetails.vote_count, backdrop_path: tvDetails.backdrop_path, poster_path: tvDetails.poster_path, }); export const mapPersonDetailsToResult = ( personDetails: TmdbPersonDetails ): TmdbPersonResult => ({ id: personDetails.id, media_type: 'person', name: personDetails.name, popularity: personDetails.popularity, adult: personDetails.adult, profile_path: personDetails.profile_path, known_for: [], }); ================================================ FILE: server/models/Tv.ts ================================================ import type { TmdbNetwork, TmdbSeasonWithEpisodes, TmdbTvDetails, TmdbTvEpisodeResult, TmdbTvRatingResult, TmdbTvSeasonResult, } from '@server/api/themoviedb/interfaces'; import type Media from '@server/entity/Media'; import type { Video } from './Movie'; import type { Cast, Crew, ExternalIds, Genre, Keyword, ProductionCompany, TvNetwork, WatchProviders, } from './common'; import { mapAggregateCast, mapCrew, mapExternalIds, mapVideos, mapWatchProviders, } from './common'; interface Episode { id: number; name: string; airDate: string | null; episodeNumber: number; overview: string; productionCode: string; seasonNumber: number; showId: number; stillPath?: string; voteAverage: number; voteCount: number; } interface Season { airDate: string; id: number; episodeCount: number; name: string; overview: string; posterPath?: string; seasonNumber: number; } export interface SeasonWithEpisodes extends Omit { episodes: Episode[]; externalIds: ExternalIds; } interface SpokenLanguage { englishName: string; iso_639_1: string; name: string; } export interface TvDetails { id: number; backdropPath?: string; posterPath?: string; contentRatings: TmdbTvRatingResult; createdBy: { id: number; name: string; gender: number; profilePath?: string; }[]; episodeRunTime: number[]; firstAirDate?: string; genres: Genre[]; homepage: string; inProduction: boolean; relatedVideos?: Video[]; languages: string[]; lastAirDate: string; lastEpisodeToAir?: Episode; name: string; nextEpisodeToAir?: Episode; networks: TvNetwork[]; numberOfEpisodes: number; numberOfSeasons: number; originCountry: string[]; originalLanguage: string; originalName: string; overview: string; popularity: number; productionCompanies: ProductionCompany[]; productionCountries: { iso_3166_1: string; name: string; }[]; spokenLanguages: SpokenLanguage[]; seasons: Season[]; status: string; tagline?: string; type: string; voteAverage: number; voteCount: number; credits: { cast: Cast[]; crew: Crew[]; }; externalIds: ExternalIds; keywords: Keyword[]; mediaInfo?: Media; watchProviders?: WatchProviders[]; onUserWatchlist?: boolean; } const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ id: episode.id, airDate: episode.air_date, episodeNumber: episode.episode_number, name: episode.name, overview: episode.overview, productionCode: episode.production_code, seasonNumber: episode.season_number, showId: episode.show_id, voteAverage: episode.vote_average, voteCount: episode.vote_count, stillPath: episode.still_path, }); const mapSeasonResult = (season: TmdbTvSeasonResult): Season => ({ airDate: season.air_date, episodeCount: season.episode_count, id: season.id, name: season.name, overview: season.overview, seasonNumber: season.season_number, posterPath: season.poster_path, }); export const mapSeasonWithEpisodes = ( season: TmdbSeasonWithEpisodes ): SeasonWithEpisodes => ({ airDate: season.air_date, episodes: season.episodes.map(mapEpisodeResult), externalIds: mapExternalIds(season.external_ids), id: season.id, name: season.name, overview: season.overview, seasonNumber: season.season_number, posterPath: season.poster_path, }); export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({ id: network.id, name: network.name, originCountry: network.origin_country, headquarters: network.headquarters, homepage: network.homepage, logoPath: network.logo_path, }); export const mapTvDetails = ( show: TmdbTvDetails, media?: Media, userWatchlist?: boolean ): TvDetails => ({ createdBy: show.created_by, episodeRunTime: show.episode_run_time, firstAirDate: show.first_air_date, genres: show.genres.map((genre) => ({ id: genre.id, name: genre.name, })), relatedVideos: mapVideos(show.videos), homepage: show.homepage, id: show.id, inProduction: show.in_production, languages: show.languages, lastAirDate: show.last_air_date, name: show.name, networks: show.networks.map(mapNetwork), numberOfEpisodes: show.number_of_episodes, numberOfSeasons: show.number_of_seasons, originCountry: show.origin_country, originalLanguage: show.original_language, originalName: show.original_name, tagline: show.tagline, overview: show.overview, popularity: show.popularity, productionCompanies: show.production_companies.map((company) => ({ id: company.id, name: company.name, originCountry: company.origin_country, logoPath: company.logo_path, })), productionCountries: show.production_countries, contentRatings: show.content_ratings, spokenLanguages: show.spoken_languages.map((language) => ({ englishName: language.english_name, iso_639_1: language.iso_639_1, name: language.name, })), seasons: show.seasons.map(mapSeasonResult), status: show.status, type: show.type, voteAverage: show.vote_average, voteCount: show.vote_count, backdropPath: show.backdrop_path, lastEpisodeToAir: show.last_episode_to_air ? mapEpisodeResult(show.last_episode_to_air) : undefined, nextEpisodeToAir: show.next_episode_to_air ? mapEpisodeResult(show.next_episode_to_air) : undefined, posterPath: show.poster_path, credits: { cast: show.aggregate_credits.cast.map(mapAggregateCast), crew: show.credits.crew.map(mapCrew), }, externalIds: mapExternalIds(show.external_ids), keywords: show.keywords.results.map((keyword) => ({ id: keyword.id, name: keyword.name, })), mediaInfo: media, watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}), onUserWatchlist: userWatchlist, }); ================================================ FILE: server/models/common.ts ================================================ import type { TmdbAggregateCreditCast, TmdbCreditCast, TmdbCreditCrew, TmdbExternalIds, TmdbVideo, TmdbVideoResult, TmdbWatchProviderDetails, TmdbWatchProviders, } from '@server/api/themoviedb/interfaces'; import type { Video } from '@server/models/Movie'; export interface ProductionCompany { id: number; logoPath?: string; originCountry: string; name: string; description?: string; headquarters?: string; homepage?: string; } export interface TvNetwork { id: number; logoPath?: string; originCountry?: string; name: string; headquarters?: string; homepage?: string; } export interface Keyword { id: number; name: string; } export interface Genre { id: number; name: string; } export interface Cast { id: number; castId: number; character: string; creditId: string; gender?: number; name: string; order: number; profilePath?: string; } export interface Crew { id: number; creditId: string; department: string; gender?: number; job: string; name: string; profilePath?: string; } export interface ExternalIds { imdbId?: string; freebaseMid?: string; freebaseId?: string; tvdbId?: number; tvrageId?: string; facebookId?: string; instagramId?: string; twitterId?: string; } export interface WatchProviders { iso_3166_1: string; link?: string; buy?: WatchProviderDetails[]; flatrate?: WatchProviderDetails[]; } export interface WatchProviderDetails { displayPriority?: number; logoPath?: string; id: number; name: string; } export const mapCast = (person: TmdbCreditCast): Cast => ({ castId: person.cast_id, character: person.character, creditId: person.credit_id, id: person.id, name: person.name, order: person.order, gender: person.gender, profilePath: person.profile_path, }); export const mapAggregateCast = (person: TmdbAggregateCreditCast): Cast => ({ castId: person.cast_id, // the first role is the one for which the actor appears the most as character: person.roles[0].character, creditId: person.roles[0].credit_id, id: person.id, name: person.name, order: person.order, gender: person.gender, profilePath: person.profile_path, }); export const mapCrew = (person: TmdbCreditCrew): Crew => ({ creditId: person.credit_id, department: person.department, id: person.id, job: person.job, name: person.name, gender: person.gender, profilePath: person.profile_path, }); export const mapExternalIds = (eids: TmdbExternalIds): ExternalIds => ({ facebookId: eids.facebook_id, freebaseId: eids.freebase_id, freebaseMid: eids.freebase_mid, imdbId: eids.imdb_id, instagramId: eids.instagram_id, tvdbId: eids.tvdb_id, tvrageId: eids.tvrage_id, twitterId: eids.twitter_id, }); export const mapVideos = (videoResult: TmdbVideoResult): Video[] => videoResult?.results.map(({ key, name, size, type, site }: TmdbVideo) => ({ site, key, name, size, type, url: siteUrlCreator(site, key), })); export const mapWatchProviders = (watchProvidersResult: { [iso_3166_1: string]: TmdbWatchProviders; }): WatchProviders[] => Object.entries(watchProvidersResult).map( ([iso_3166_1, provider]) => ({ iso_3166_1, link: provider.link, buy: mapWatchProviderDetails(provider.buy ?? []), flatrate: mapWatchProviderDetails(provider.flatrate ?? []), }) as WatchProviders ); export const mapWatchProviderDetails = ( watchProviderDetails: TmdbWatchProviderDetails[] ): WatchProviderDetails[] => watchProviderDetails.map( (provider) => ({ displayPriority: provider.display_priority, logoPath: provider.logo_path, id: provider.provider_id, name: provider.provider_name, }) as WatchProviderDetails ); const siteUrlCreator = (site: Video['site'], key: string): string => ({ YouTube: `https://www.youtube.com/watch?v=${key}`, })[site]; ================================================ FILE: server/repositories/watchlist.repository.ts ================================================ import { getRepository } from '@server/datasource'; import { Watchlist } from '@server/entity/Watchlist'; export const UserRepository = getRepository(Watchlist).extend({ // findByName(firstName: string, lastName: string) { // return this.createQueryBuilder("user") // .where("user.firstName = :firstName", { firstName }) // .andWhere("user.lastName = :lastName", { lastName }) // .getMany() // }, }); ================================================ FILE: server/routes/auth.test.ts ================================================ import assert from 'node:assert/strict'; import { before, beforeEach, describe, it, mock } from 'node:test'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import PreparedEmail from '@server/lib/email'; import { getSettings } from '@server/lib/settings'; import { checkUser } from '@server/middleware/auth'; import { setupTestDb } from '@server/test/db'; import type { Express } from 'express'; import express from 'express'; import session from 'express-session'; import request from 'supertest'; import authRoutes from './auth'; const emailMock = mock.method(PreparedEmail.prototype, 'send', async () => { return undefined; }).mock; let app: Express; function createApp() { const app = express(); app.use(express.json()); app.use( session({ secret: 'test-secret', resave: false, saveUninitialized: false, }) ); app.use(checkUser); app.use('/auth', authRoutes); // Error handler matching how next({ status, message }) calls are handled app.use( ( err: { status?: number; message?: string }, _req: express.Request, res: express.Response, // We must provide a next function for the function signature here even though its not used // eslint-disable-next-line @typescript-eslint/no-unused-vars _next: express.NextFunction ) => { res .status(err.status ?? 500) .json({ status: err.status ?? 500, message: err.message }); } ); return app; } before(async () => { app = createApp(); }); setupTestDb(); /** Create a supertest agent that is logged in as the given user. */ async function authenticatedAgent(email: string, password: string) { const agent = request.agent(app); const settings = getSettings(); settings.main.localLogin = true; const res = await agent.post('/auth/local').send({ email, password }); assert.strictEqual(res.status, 200); return agent; } describe('GET /auth/me', () => { it('returns 403 when not authenticated', async () => { const res = await request(app).get('/auth/me'); assert.strictEqual(res.status, 403); }); it('returns the authenticated user', async () => { const agent = await authenticatedAgent('admin@seerr.dev', 'test1234'); const res = await agent.get('/auth/me'); assert.strictEqual(res.status, 200); assert.ok('id' in res.body); assert.strictEqual(res.body.displayName, 'admin'); }); it('includes userEmailRequired warning when email is required but invalid', async () => { const settings = getSettings(); settings.notifications.agents.email.options.userEmailRequired = true; // Change the user's email to something invalid const userRepo = getRepository(User); const user = await userRepo.findOneOrFail({ where: { email: 'admin@seerr.dev' }, }); user.email = 'not-an-email'; await userRepo.save(user); // Log in with the changed email const agent = request.agent(app); settings.main.localLogin = true; const loginRes = await agent .post('/auth/local') .send({ email: 'not-an-email', password: 'test1234' }); assert.strictEqual(loginRes.status, 200); const res = await agent.get('/auth/me'); assert.strictEqual(res.status, 200); assert.ok(res.body.warnings.includes('userEmailRequired')); settings.notifications.agents.email.options.userEmailRequired = false; }); }); describe('POST /auth/local', () => { beforeEach(() => { const settings = getSettings(); settings.main.localLogin = true; }); it('returns 200 and user data on valid credentials', async () => { const res = await request(app) .post('/auth/local') .send({ email: 'admin@seerr.dev', password: 'test1234' }); assert.strictEqual(res.status, 200); assert.ok('id' in res.body); // filter() strips sensitive fields like password assert.ok(!('password' in res.body)); }); it('returns 403 on wrong password', async () => { const res = await request(app) .post('/auth/local') .send({ email: 'admin@seerr.dev', password: 'wrongpassword' }); assert.strictEqual(res.status, 403); assert.strictEqual(res.body.message, 'Access denied.'); }); it('returns 403 for nonexistent user', async () => { const res = await request(app) .post('/auth/local') .send({ email: 'nobody@seerr.dev', password: 'test1234' }); assert.strictEqual(res.status, 403); assert.strictEqual(res.body.message, 'Access denied.'); }); it('returns 500 when local login is disabled', async () => { const settings = getSettings(); settings.main.localLogin = false; const res = await request(app) .post('/auth/local') .send({ email: 'admin@seerr.dev', password: 'test1234' }); assert.strictEqual(res.status, 500); assert.strictEqual(res.body.error, 'Password sign-in is disabled.'); }); it('returns 500 when email is missing', async () => { const res = await request(app) .post('/auth/local') .send({ password: 'test1234' }); assert.strictEqual(res.status, 500); assert.match(res.body.error, /email address and a password/); }); it('returns 500 when password is missing', async () => { const res = await request(app) .post('/auth/local') .send({ email: 'admin@seerr.dev' }); assert.strictEqual(res.status, 500); assert.match(res.body.error, /email address and a password/); }); it('is case-insensitive for email', async () => { const res = await request(app) .post('/auth/local') .send({ email: 'Admin@Seerr.Dev', password: 'test1234' }); assert.strictEqual(res.status, 200); assert.ok('id' in res.body); }); it('allows the non-admin user to log in', async () => { const res = await request(app) .post('/auth/local') .send({ email: 'friend@seerr.dev', password: 'test1234' }); assert.strictEqual(res.status, 200); assert.ok('id' in res.body); }); it('sets a session on successful login', async () => { const agent = request.agent(app); await agent .post('/auth/local') .send({ email: 'admin@seerr.dev', password: 'test1234' }); // Session should persist — /me should succeed const meRes = await agent.get('/auth/me'); assert.strictEqual(meRes.status, 200); }); }); describe('POST /auth/logout', () => { it('returns 200 when not logged in', async () => { const res = await request(app).post('/auth/logout'); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.status, 'ok'); }); it('destroys session and returns 200 when logged in', async () => { const agent = await authenticatedAgent('admin@seerr.dev', 'test1234'); // Verify session is active const meBeforeRes = await agent.get('/auth/me'); assert.strictEqual(meBeforeRes.status, 200); const logoutRes = await agent.post('/auth/logout'); assert.strictEqual(logoutRes.status, 200); assert.strictEqual(logoutRes.body.status, 'ok'); // Session should be invalidated — /me should fail const meAfterRes = await agent.get('/auth/me'); assert.strictEqual(meAfterRes.status, 403); }); }); describe('POST /auth/reset-password', () => { beforeEach(() => { emailMock.resetCalls(); }); it('returns 200 for a valid email', async () => { const res = await request(app) .post('/auth/reset-password') .send({ email: 'admin@seerr.dev' }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.status, 'ok'); assert.strictEqual(emailMock.callCount(), 1); }); it('returns 200 for nonexistent email (does not reveal user existence)', async () => { const res = await request(app) .post('/auth/reset-password') .send({ email: 'nonexistent@seerr.dev' }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.status, 'ok'); assert.strictEqual(emailMock.callCount(), 0); }); it('returns 500 when email is missing', async () => { const res = await request(app).post('/auth/reset-password').send({}); assert.strictEqual(res.status, 500); assert.strictEqual(res.body.message, 'Email address required.'); assert.strictEqual(emailMock.callCount(), 0); }); it('sets a resetPasswordGuid on the user', async () => { await request(app) .post('/auth/reset-password') .send({ email: 'admin@seerr.dev' }); const userRepo = getRepository(User); const user = await userRepo .createQueryBuilder('user') .addSelect(['user.resetPasswordGuid', 'user.recoveryLinkExpirationDate']) .where('user.email = :email', { email: 'admin@seerr.dev' }) .getOneOrFail(); assert.notStrictEqual(user.resetPasswordGuid, undefined); assert.notStrictEqual(user.resetPasswordGuid, null); assert.notStrictEqual(user.recoveryLinkExpirationDate, undefined); assert.strictEqual(emailMock.callCount(), 1); }); }); describe('POST /auth/reset-password/:guid', () => { /** Trigger a password reset and return the guid. */ async function getResetGuid(email: string): Promise { await request(app).post('/auth/reset-password').send({ email }); const userRepo = getRepository(User); const user = await userRepo .createQueryBuilder('user') .addSelect('user.resetPasswordGuid') .where('user.email = :email', { email }) .getOneOrFail(); return user.resetPasswordGuid!; } it('resets password with a valid guid and password', async () => { const guid = await getResetGuid('admin@seerr.dev'); const res = await request(app) .post(`/auth/reset-password/${guid}`) .send({ password: 'newpassword123' }); assert.strictEqual(res.status, 200); assert.strictEqual(res.body.status, 'ok'); // Old password no longer works const oldLogin = await request(app) .post('/auth/local') .send({ email: 'admin@seerr.dev', password: 'test1234' }); assert.strictEqual(oldLogin.status, 403); // New password works const newLogin = await request(app) .post('/auth/local') .send({ email: 'admin@seerr.dev', password: 'newpassword123' }); assert.strictEqual(newLogin.status, 200); }); it('returns 500 for an invalid guid', async () => { const res = await request(app) .post('/auth/reset-password/invalid-guid-here') .send({ password: 'newpassword123' }); assert.strictEqual(res.status, 500); assert.strictEqual(res.body.message, 'Invalid password reset link.'); }); it('returns 500 when password is too short', async () => { const guid = await getResetGuid('admin@seerr.dev'); const res = await request(app) .post(`/auth/reset-password/${guid}`) .send({ password: 'short' }); assert.strictEqual(res.status, 500); assert.strictEqual( res.body.message, 'Password must be at least 8 characters long.' ); }); it('returns 500 when password is missing', async () => { const guid = await getResetGuid('admin@seerr.dev'); const res = await request(app) .post(`/auth/reset-password/${guid}`) .send({}); assert.strictEqual(res.status, 500); assert.strictEqual( res.body.message, 'Password must be at least 8 characters long.' ); }); it('returns 500 for an expired recovery link', async () => { const guid = await getResetGuid('admin@seerr.dev'); // Expire the link const userRepo = getRepository(User); const user = await userRepo.findOneOrFail({ where: { email: 'admin@seerr.dev' }, }); user.recoveryLinkExpirationDate = new Date('2020-01-01'); await userRepo.save(user); const res = await request(app) .post(`/auth/reset-password/${guid}`) .send({ password: 'newpassword123' }); assert.strictEqual(res.status, 500); assert.strictEqual(res.body.message, 'Invalid password reset link.'); }); it('cannot reuse a guid after successful reset', async () => { const guid = await getResetGuid('admin@seerr.dev'); // First reset succeeds const first = await request(app) .post(`/auth/reset-password/${guid}`) .send({ password: 'newpassword123' }); assert.strictEqual(first.status, 200); // Second reset with same guid fails (recoveryLinkExpirationDate was cleared) const second = await request(app) .post(`/auth/reset-password/${guid}`) .send({ password: 'anotherpassword' }); assert.strictEqual(second.status, 500); }); }); ================================================ FILE: server/routes/auth.ts ================================================ import JellyfinAPI from '@server/api/jellyfin'; import PlexTvAPI from '@server/api/plextv'; import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType, ServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { checkAvatarChanged } from '@server/routes/avatarproxy'; import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import axios from 'axios'; import { Router } from 'express'; import net from 'net'; import validator from 'validator'; const authRoutes = Router(); authRoutes.get('/me', isAuthenticated(), async (req, res) => { const userRepository = getRepository(User); if (!req.user) { return res.status(500).json({ status: 500, error: 'Please sign in.', }); } const user = await userRepository.findOneOrFail({ where: { id: req.user.id }, }); // check if email is required in settings and if user has an valid email const settings = await getSettings(); if ( settings.notifications.agents.email.options.userEmailRequired && !validator.isEmail(user.email, { require_tld: false }) ) { user.warnings.push('userEmailRequired'); logger.warn(`User ${user.username} has no valid email address`); } return res.status(200).json(user); }); authRoutes.post('/plex', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { authToken?: string }; if (!body.authToken) { return next({ status: 500, message: 'Authentication token required.', }); } if ( settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && (settings.main.mediaServerLogin === false || settings.main.mediaServerType != MediaServerType.PLEX) ) { return res.status(500).json({ error: 'Plex login is disabled' }); } try { // First we need to use this auth token to get the user's email from plex.tv const plextv = new PlexTvAPI(body.authToken); const account = await plextv.getUser(); // Next let's see if the user already exists let user = await userRepository .createQueryBuilder('user') .where('user.plexId = :id', { id: account.id }) .orWhere('user.email = :email', { email: account.email.toLowerCase(), }) .getOne(); if (!user && !(await userRepository.count())) { user = new User({ email: account.email, plexUsername: account.username, plexId: account.id, plexToken: account.authToken, permissions: Permission.ADMIN, avatar: account.thumb, userType: UserType.PLEX, }); settings.main.mediaServerType = MediaServerType.PLEX; await settings.save(); startJobs(); await userRepository.save(user); } else { const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true, plexId: true, email: true }, where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); if (!account.id) { logger.error('Plex ID was missing from Plex.tv response', { label: 'API', ip: req.ip, email: account.email, plexUsername: account.username, }); return next({ status: 500, message: 'Something went wrong. Try again.', }); } if ( account.id === mainUser.plexId || (account.email === mainUser.email && !mainUser.plexId) || (await mainPlexTv.checkUserAccess(account.id)) ) { if (user) { if (!user.plexId) { logger.info( 'Found matching Plex user; updating user with Plex data', { label: 'API', ip: req.ip, email: user.email, userId: user.id, plexId: account.id, plexUsername: account.username, } ); } user.plexToken = body.authToken; user.plexId = account.id; user.avatar = account.thumb; user.email = account.email; user.plexUsername = account.username; user.userType = UserType.PLEX; await userRepository.save(user); } else if (!settings.main.newPlexLogin) { logger.warn( 'Failed sign-in attempt by unimported Plex user with access to the media server', { label: 'API', ip: req.ip, email: account.email, plexId: account.id, plexUsername: account.username, } ); return next({ status: 403, message: 'Access denied.', }); } else { logger.info( 'Sign-in attempt from Plex user with access to the media server; creating new Seerr user', { label: 'API', ip: req.ip, email: account.email, plexId: account.id, plexUsername: account.username, } ); user = new User({ email: account.email, plexUsername: account.username, plexId: account.id, plexToken: account.authToken, permissions: settings.main.defaultPermissions, avatar: account.thumb, userType: UserType.PLEX, }); await userRepository.save(user); } } else { logger.warn( 'Failed sign-in attempt by Plex user without access to the media server', { label: 'API', ip: req.ip, email: account.email, plexId: account.id, plexUsername: account.username, } ); return next({ status: 403, message: 'Access denied.', }); } } // Set logged in session if (req.session) { req.session.userId = user.id; } return res.status(200).json(user?.filter() ?? {}); } catch (e) { logger.error('Something went wrong authenticating with Plex account', { label: 'API', errorMessage: e.message, ip: req.ip, }); return next({ status: 500, message: 'Unable to authenticate.', }); } }); function getUserAvatarUrl(user: User): string { return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`; } authRoutes.post('/jellyfin', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { username?: string; password?: string; hostname?: string; port?: number; urlBase?: string; useSsl?: boolean; email?: string; serverType?: number; }; //Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured if ( // media server not configured, allow login for setup settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED && (settings.main.mediaServerLogin === false || // media server is neither jellyfin or emby (settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.EMBY)) ) { return res.status(500).json({ error: 'Jellyfin login is disabled' }); } if (!body.username) { return res.status(500).json({ error: 'You must provide an username' }); } else if (settings.jellyfin.ip !== '' && body.hostname) { return res .status(500) .json({ error: 'Jellyfin hostname already configured' }); } else if (settings.jellyfin.ip === '' && !body.hostname) { return res.status(500).json({ error: 'No hostname provided.' }); } try { const hostname = settings.jellyfin.ip !== '' ? getHostname() : getHostname({ useSsl: body.useSsl, ip: body.hostname, port: body.port, urlBase: body.urlBase, }); // Try to find deviceId that corresponds to jellyfin user, else generate a new one let user = await userRepository.findOne({ where: { jellyfinUsername: body.username }, select: { id: true, jellyfinDeviceId: true }, }); let deviceId = 'BOT_seerr'; if (user && user.id === 1) { // Admin is always BOT_seerr deviceId = 'BOT_seerr'; } else if (user && user.jellyfinDeviceId) { deviceId = user.jellyfinDeviceId; } else if (body.username) { deviceId = Buffer.from(`BOT_seerr_${body.username}`).toString('base64'); } // First we need to attempt to log the user in to jellyfin const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); const ip = req.ip; let clientIp; if (ip) { if (net.isIPv4(ip)) { clientIp = ip; } else if (net.isIPv6(ip)) { clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; } } const account = await jellyfinserver.login( body.username, body.password, clientIp ); // Next let's see if the user already exists user = await userRepository.findOne({ where: { jellyfinUserId: account.User.Id }, }); const missingAdminUser = !user && !(await userRepository.count()); if ( missingAdminUser || settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ) { // Check if user is admin on jellyfin if (account.User.Policy.IsAdministrator === false) { throw new ApiError(403, ApiErrorCode.NotAdmin); } if ( body.serverType !== MediaServerType.JELLYFIN && body.serverType !== MediaServerType.EMBY ) { throw new ApiError(500, ApiErrorCode.NoAdminUser); } settings.main.mediaServerType = body.serverType; if (missingAdminUser) { logger.info( 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Seerr', { label: 'API', ip: req.ip, jellyfinUsername: account.User.Name, } ); // User doesn't exist, and there are no users in the database, we'll create the user // with admin permissions user = new User({ id: 1, email: body.email || account.User.Name, jellyfinUsername: account.User.Name, jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, userType: body.serverType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); user.avatar = getUserAvatarUrl(user); await userRepository.save(user); } else { logger.info( 'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Seerr', { label: 'API', ip: req.ip, jellyfinUsername: account.User.Name, } ); // User alread exist but settings.json is not configured, we'll edit the admin user user = await userRepository.findOne({ where: { id: 1 }, }); if (!user) { throw new Error('Unable to find admin user to edit'); } user.email = body.email || account.User.Name; user.jellyfinUsername = account.User.Name; user.jellyfinUserId = account.User.Id; user.jellyfinDeviceId = deviceId; user.jellyfinAuthToken = account.AccessToken; user.permissions = Permission.ADMIN; user.avatar = getUserAvatarUrl(user); user.userType = body.serverType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY; await userRepository.save(user); } // Create an API key on Jellyfin from this admin user const jellyfinClient = new JellyfinAPI( hostname, account.AccessToken, deviceId ); const apiKey = await jellyfinClient.createApiToken('Seerr'); const serverName = await jellyfinserver.getServerName(); settings.jellyfin.name = serverName; settings.jellyfin.serverId = account.User.ServerId; settings.jellyfin.ip = body.hostname ?? ''; settings.jellyfin.port = body.port ?? 8096; settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.apiKey = apiKey; await settings.save(); startJobs(); } // User already exists, let's update their information else if (account.User.Id === user?.jellyfinUserId) { logger.info( `Found matching ${ settings.main.mediaServerType === MediaServerType.JELLYFIN ? ServerType.JELLYFIN : ServerType.EMBY } user; updating user with ${ settings.main.mediaServerType === MediaServerType.JELLYFIN ? ServerType.JELLYFIN : ServerType.EMBY }`, { label: 'API', ip: req.ip, jellyfinUsername: account.User.Name, } ); user.avatar = getUserAvatarUrl(user); user.jellyfinUsername = account.User.Name; if (user.username === account.User.Name) { user.username = ''; } await userRepository.save(user); } else if (!settings.main.newPlexLogin) { logger.warn( 'Failed sign-in attempt by unimported Jellyfin user with access to the media server', { label: 'API', ip: req.ip, jellyfinUserId: account.User.Id, jellyfinUsername: account.User.Name, } ); return next({ status: 403, message: 'Access denied.', }); } else if (!user) { logger.info( 'Sign-in attempt from Jellyfin user with access to the media server; creating new Seerr user', { label: 'API', ip: req.ip, jellyfinUsername: account.User.Name, } ); user = new User({ email: body.email, jellyfinUsername: account.User.Name, jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); user.avatar = getUserAvatarUrl(user); //initialize Jellyfin/Emby users with local login const passedExplicitPassword = body.password && body.password.length > 0; if (passedExplicitPassword) { await user.setPassword(body.password ?? ''); } await userRepository.save(user); } if (user && user.jellyfinUserId) { try { const { changed } = await checkAvatarChanged(user); if (changed) { user.avatar = getUserAvatarUrl(user); await userRepository.save(user); logger.debug('Avatar updated during login', { userId: user.id, jellyfinUserId: user.jellyfinUserId, }); } } catch (error) { logger.error('Error handling avatar during login', { label: 'Auth', errorMessage: error.message, }); } } // Set logged in session if (req.session) { req.session.userId = user?.id; } return res.status(200).json(user?.filter() ?? {}); } catch (e) { switch (e.errorCode) { case ApiErrorCode.InvalidUrl: logger.error( `The provided ${ settings.main.mediaServerType === MediaServerType.JELLYFIN ? ServerType.JELLYFIN : ServerType.EMBY } is invalid or the server is not reachable.`, { label: 'Auth', error: e.errorCode, status: e.statusCode, hostname: getHostname({ useSsl: body.useSsl, ip: body.hostname, port: body.port, urlBase: body.urlBase, }), } ); return next({ status: e.statusCode, message: e.errorCode, }); case ApiErrorCode.InvalidCredentials: logger.warn( 'Failed login attempt from user with incorrect Jellyfin credentials', { label: 'Auth', account: { ip: req.ip, email: body.username, password: '__REDACTED__', }, } ); return next({ status: e.statusCode, message: e.errorCode, }); case ApiErrorCode.NotAdmin: logger.warn( 'Failed login attempt from user without admin permissions', { label: 'Auth', account: { ip: req.ip, email: body.username, }, } ); return next({ status: e.statusCode, message: e.errorCode, }); case ApiErrorCode.NoAdminUser: logger.warn( 'Failed login attempt from user without admin permissions and no admin user exists', { label: 'Auth', account: { ip: req.ip, email: body.username, }, } ); return next({ status: e.statusCode, message: e.errorCode, }); default: logger.error(e.message, { label: 'Auth' }); return next({ status: 500, message: 'Something went wrong.', }); } } }); authRoutes.post('/local', async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { email?: string; password?: string }; if (!settings.main.localLogin) { return res.status(500).json({ error: 'Password sign-in is disabled.' }); } else if (!body.email || !body.password) { return res.status(500).json({ error: 'You must provide both an email address and a password.', }); } try { const user = await userRepository .createQueryBuilder('user') .select(['user.id', 'user.email', 'user.password', 'user.plexId']) .where('user.email = :email', { email: body.email.toLowerCase() }) .getOne(); if (!user || !(await user.passwordMatch(body.password))) { logger.warn('Failed sign-in attempt using invalid Seerr password', { label: 'API', ip: req.ip, email: body.email, userId: user?.id, }); return next({ status: 403, message: 'Access denied.', }); } // Set logged in session if (user && req.session) { req.session.userId = user.id; } return res.status(200).json(user?.filter() ?? {}); } catch (e) { logger.error('Something went wrong authenticating with Seerr password', { label: 'API', errorMessage: e.message, ip: req.ip, email: body.email, }); return next({ status: 500, message: 'Unable to authenticate.', }); } }); authRoutes.post('/logout', async (req, res, next) => { try { const userId = req.session?.userId; if (!userId) { return res.status(200).json({ status: 'ok' }); } const settings = getSettings(); const isJellyfinOrEmby = settings.main.mediaServerType === MediaServerType.JELLYFIN || settings.main.mediaServerType === MediaServerType.EMBY; if (isJellyfinOrEmby) { const user = await getRepository(User) .createQueryBuilder('user') .addSelect(['user.jellyfinUserId', 'user.jellyfinDeviceId']) .where('user.id = :id', { id: userId }) .getOne(); if (user?.jellyfinUserId && user.jellyfinDeviceId) { try { const baseUrl = getHostname(); try { await axios.delete(`${baseUrl}/Devices`, { params: { Id: user.jellyfinDeviceId }, headers: { 'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="seerr", Version="${getAppVersion()}", Token="${ settings.jellyfin.apiKey }"`, }, }); } catch (error) { logger.error('Failed to delete Jellyfin device', { label: 'Auth', error: error instanceof Error ? error.message : 'Unknown error', userId: user.id, jellyfinUserId: user.jellyfinUserId, }); } } catch (error) { logger.error('Failed to delete Jellyfin device', { label: 'Auth', error: error instanceof Error ? error.message : 'Unknown error', userId: user.id, jellyfinUserId: user.jellyfinUserId, }); } } } req.session?.destroy((err: Error | null) => { if (err) { logger.error('Failed to destroy session', { label: 'Auth', error: err.message, userId, }); return next({ status: 500, message: 'Failed to destroy session.' }); } logger.debug('Successfully logged out user', { label: 'Auth', userId, }); res.status(200).json({ status: 'ok' }); }); } catch (error) { logger.error('Error during logout process', { label: 'Auth', error: error instanceof Error ? error.message : 'Unknown error', userId: req.session?.userId, }); next({ status: 500, message: 'Error during logout process.' }); } }); authRoutes.post('/reset-password', async (req, res, next) => { const userRepository = getRepository(User); const body = req.body as { email?: string }; if (!body.email) { return next({ status: 500, message: 'Email address required.', }); } const user = await userRepository .createQueryBuilder('user') .where('user.email = :email', { email: body.email.toLowerCase() }) .getOne(); if (user) { await user.resetPassword(); await userRepository.save(user); logger.info('Successfully sent password reset link', { label: 'API', ip: req.ip, email: body.email, }); } else { logger.error('Something went wrong sending password reset link', { label: 'API', ip: req.ip, email: body.email, }); } return res.status(200).json({ status: 'ok' }); }); authRoutes.post('/reset-password/:guid', async (req, res, next) => { const userRepository = getRepository(User); if (!req.body.password || req.body.password?.length < 8) { logger.warn('Failed password reset attempt using invalid new password', { label: 'API', ip: req.ip, guid: req.params.guid, }); return next({ status: 500, message: 'Password must be at least 8 characters long.', }); } const user = await userRepository.findOne({ where: { resetPasswordGuid: req.params.guid }, }); if (!user) { logger.warn('Failed password reset attempt using invalid recovery link', { label: 'API', ip: req.ip, guid: req.params.guid, }); return next({ status: 500, message: 'Invalid password reset link.', }); } if ( !user.recoveryLinkExpirationDate || user.recoveryLinkExpirationDate <= new Date() ) { logger.warn('Failed password reset attempt using expired recovery link', { label: 'API', ip: req.ip, guid: req.params.guid, email: user.email, }); return next({ status: 500, message: 'Invalid password reset link.', }); } user.recoveryLinkExpirationDate = null; await user.setPassword(req.body.password); await userRepository.save(user); logger.info('Successfully reset password', { label: 'API', ip: req.ip, guid: req.params.guid, email: user.email, }); return res.status(200).json({ status: 'ok' }); }); export default authRoutes; ================================================ FILE: server/routes/avatarproxy.ts ================================================ import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import ImageProxy from '@server/lib/imageproxy'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import axios from 'axios'; import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; import { createHash } from 'node:crypto'; const router = Router(); let _avatarImageProxy: ImageProxy | null = null; async function initAvatarImageProxy() { if (!_avatarImageProxy) { const userRepository = getRepository(User); const admin = await userRepository.findOne({ where: { id: 1 }, select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], order: { id: 'ASC' }, }); const deviceId = admin?.jellyfinDeviceId || 'BOT_seerr'; const authToken = getSettings().jellyfin.apiKey; _avatarImageProxy = new ImageProxy('avatar', '', { headers: { 'X-Emby-Authorization': `MediaBrowser Client="Seerr", Device="Seerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`, }, }); } return _avatarImageProxy; } function getJellyfinAvatarUrl(userId: string) { const settings = getSettings(); return settings.main.mediaServerType === MediaServerType.JELLYFIN ? `${getHostname()}/UserImage?UserId=${userId}` : `${getHostname()}/Users/${userId}/Images/Primary?quality=90`; } function computeImageHash(buffer: Buffer): string { return createHash('sha256').update(buffer).digest('hex'); } export async function checkAvatarChanged( user: User ): Promise<{ changed: boolean; etag?: string }> { try { if (!user || !user.jellyfinUserId) { return { changed: false }; } const jellyfinAvatarUrl = getJellyfinAvatarUrl(user.jellyfinUserId); let headResponse; try { headResponse = await axios.head(jellyfinAvatarUrl); if (headResponse.status !== 200) { return { changed: false }; } } catch { return { changed: false }; } const settings = getSettings(); let remoteVersion: string; if (settings.main.mediaServerType === MediaServerType.JELLYFIN) { const remoteLastModifiedStr = headResponse.headers['last-modified'] || ''; remoteVersion = ( Date.parse(remoteLastModifiedStr) || Date.now() ).toString(); } else if (settings.main.mediaServerType === MediaServerType.EMBY) { remoteVersion = headResponse.headers['etag']?.replace(/"/g, '') || Date.now().toString(); } else { remoteVersion = Date.now().toString(); } if (user.avatarVersion && user.avatarVersion === remoteVersion) { return { changed: false, etag: user.avatarETag ?? undefined }; } const avatarImageCache = await initAvatarImageProxy(); await avatarImageCache.clearCachedImage(jellyfinAvatarUrl); const imageData = await avatarImageCache.getImage( jellyfinAvatarUrl, gravatarUrl(user.email || 'none', { default: 'mm', size: 200 }) ); const newHash = computeImageHash(imageData.imageBuffer); const hasChanged = user.avatarETag !== newHash; user.avatarVersion = remoteVersion; if (hasChanged) { user.avatarETag = newHash; } await getRepository(User).save(user); return { changed: hasChanged, etag: newHash }; } catch (error) { logger.error('Error checking avatar changes', { errorMessage: error.message, }); return { changed: false }; } } router.get('/:jellyfinUserId', async (req, res) => { try { if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) { const mediaServerType = getSettings().main.mediaServerType; throw new Error( `Provided URL is not ${ mediaServerType === MediaServerType.JELLYFIN ? 'a Jellyfin' : 'an Emby' } avatar.` ); } const avatarImageCache = await initAvatarImageProxy(); const userEtag = req.headers['if-none-match']; const versionParam = req.query.v; const user = await getRepository(User).findOne({ where: { jellyfinUserId: req.params.jellyfinUserId }, }); const fallbackUrl = gravatarUrl(user?.email || 'none', { default: 'mm', size: 200, }); const jellyfinAvatarUrl = getJellyfinAvatarUrl(req.params.jellyfinUserId); let imageData = await avatarImageCache.getImage( jellyfinAvatarUrl, fallbackUrl ); if (imageData.meta.extension === 'json') { // this is a 404 imageData = await avatarImageCache.getImage(fallbackUrl); } if (userEtag && userEtag === `"${imageData.meta.etag}"` && !versionParam) { return res.status(304).end(); } res.writeHead(200, { 'Content-Type': `image/${imageData.meta.extension}`, 'Content-Length': imageData.imageBuffer.length, 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, ETag: `"${imageData.meta.etag}"`, 'OS-Cache-Key': imageData.meta.cacheKey, 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', }); res.end(imageData.imageBuffer); } catch (e) { logger.error('Failed to proxy avatar image', { errorMessage: e.message, }); } }); export default router; ================================================ FILE: server/routes/blocklist.ts ================================================ import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { Blocklist } from '@server/entity/Blocklist'; import Media from '@server/entity/Media'; import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; import { EntityNotFoundError, QueryFailedError } from 'typeorm'; import { z } from 'zod'; const blocklistRoutes = Router(); export const blocklistAdd = z.object({ tmdbId: z.coerce.number(), mediaType: z.nativeEnum(MediaType), title: z.coerce.string().optional(), user: z.coerce.number(), }); const blocklistGet = z.object({ take: z.coerce.number().int().positive().default(25), skip: z.coerce.number().int().nonnegative().default(0), search: z.string().optional(), filter: z.enum(['all', 'manual', 'blocklistedTags']).optional(), }); blocklistRoutes.get( '/', isAuthenticated([Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], { type: 'or', }), async (req, res, next) => { const { take, skip, search, filter } = blocklistGet.parse(req.query); try { let query = getRepository(Blocklist) .createQueryBuilder('blocklist') .leftJoinAndSelect('blocklist.user', 'user') .where('1 = 1'); // Allow use of andWhere later switch (filter) { case 'manual': query = query.andWhere('blocklist.blocklistedTags IS NULL'); break; case 'blocklistedTags': query = query.andWhere('blocklist.blocklistedTags IS NOT NULL'); break; } if (search) { query = query.andWhere('blocklist.title like :title', { title: `%${search}%`, }); } const [blocklistedItems, itemsCount] = await query .orderBy('blocklist.createdAt', 'DESC') .take(take) .skip(skip) .getManyAndCount(); return res.status(200).json({ pageInfo: { pages: Math.ceil(itemsCount / take), pageSize: take, results: itemsCount, page: Math.ceil(skip / take) + 1, }, results: blocklistedItems, } as BlocklistResultsResponse); } catch (error) { logger.error('Something went wrong while retrieving blocklisted items', { label: 'Blocklist', errorMessage: error.message, }); return next({ status: 500, message: 'Unable to retrieve blocklisted items.', }); } } ); blocklistRoutes.get( '/:id', isAuthenticated([Permission.MANAGE_BLOCKLIST], { type: 'or', }), async (req, res, next) => { const mediaType = req.query.mediaType; if (mediaType !== MediaType.MOVIE && mediaType !== MediaType.TV) { return next({ status: 400, message: 'Invalid or missing mediaType query parameter.', }); } try { const blocklisteRepository = getRepository(Blocklist); const blocklistItem = await blocklisteRepository.findOneOrFail({ where: { tmdbId: Number(req.params.id), mediaType, }, }); return res.status(200).send(blocklistItem); } catch (e) { if (e instanceof EntityNotFoundError) { return next({ status: 404, message: e.message, }); } return next({ status: 500, message: e.message }); } } ); blocklistRoutes.post( '/', isAuthenticated([Permission.MANAGE_BLOCKLIST], { type: 'or', }), async (req, res, next) => { try { const values = blocklistAdd.parse(req.body); await Blocklist.addToBlocklist({ blocklistRequest: values, }); return res.status(201).send(); } catch (error) { if (!(error instanceof Error)) { return; } if (error instanceof QueryFailedError) { switch (error.driverError.errno) { case 19: return next({ status: 412, message: 'Item already blocklisted' }); default: logger.warn('Something wrong with data blocklist', { tmdbId: req.body.tmdbId, mediaType: req.body.mediaType, label: 'Blocklist', }); return next({ status: 409, message: 'Something wrong' }); } } return next({ status: 500, message: error.message }); } } ); blocklistRoutes.delete( '/:id', isAuthenticated([Permission.MANAGE_BLOCKLIST], { type: 'or', }), async (req, res, next) => { const mediaType = req.query.mediaType; if (mediaType !== MediaType.MOVIE && mediaType !== MediaType.TV) { return next({ status: 400, message: 'Invalid or missing mediaType query parameter.', }); } try { const blocklisteRepository = getRepository(Blocklist); const blocklistItem = await blocklisteRepository.findOneOrFail({ where: { tmdbId: Number(req.params.id), mediaType, }, }); await blocklisteRepository.remove(blocklistItem); const mediaRepository = getRepository(Media); const mediaItem = await mediaRepository.findOneOrFail({ where: { tmdbId: Number(req.params.id), mediaType: req.query.mediaType as MediaType, }, }); await mediaRepository.remove(mediaItem); return res.status(204).send(); } catch (e) { if (e instanceof EntityNotFoundError) { return next({ status: 404, message: e.message, }); } return next({ status: 500, message: e.message }); } } ); export default blocklistRoutes; ================================================ FILE: server/routes/collection.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; import logger from '@server/logger'; import { mapCollection } from '@server/models/Collection'; import { Router } from 'express'; const collectionRoutes = Router(); collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const collection = await tmdb.getCollection({ collectionId: Number(req.params.id), language: (req.query.language as string) ?? req.locale, }); const media = await Media.getRelatedMedia( req.user, collection.parts.map((part) => ({ tmdbId: part.id, mediaType: MediaType.MOVIE, })) ); return res.status(200).json(mapCollection(collection, media)); } catch (e) { logger.debug('Something went wrong retrieving collection', { label: 'API', errorMessage: e.message, collectionId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve collection.', }); } }); export default collectionRoutes; ================================================ FILE: server/routes/discover.ts ================================================ import PlexTvAPI from '@server/api/plextv'; import type { SortOptions } from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { GenreSliderItem, WatchlistResponse, } from '@server/interfaces/api/discoverInterfaces'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { mapProductionCompany } from '@server/models/Movie'; import { mapCollectionResult, mapMovieResult, mapPersonResult, mapTvResult, } from '@server/models/Search'; import { mapNetwork } from '@server/models/Tv'; import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; import { z } from 'zod'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); const discoverRegion = user?.settings?.streamingRegion === 'all' ? '' : user?.settings?.streamingRegion ? user?.settings?.streamingRegion : settings.main.discoverRegion; const originalLanguage = user?.settings?.originalLanguage === 'all' ? '' : user?.settings?.originalLanguage ? user?.settings?.originalLanguage : settings.main.originalLanguage; return new TheMovieDb({ discoverRegion, originalLanguage, }); }; const discoverRoutes = Router(); const QueryFilterOptions = z.object({ page: z.coerce.string().optional(), sortBy: z.coerce.string().optional(), primaryReleaseDateGte: z.coerce.string().optional(), primaryReleaseDateLte: z.coerce.string().optional(), firstAirDateGte: z.coerce.string().optional(), firstAirDateLte: z.coerce.string().optional(), studio: z.coerce.string().optional(), genre: z.coerce.string().optional(), keywords: z.coerce.string().optional(), excludeKeywords: z.coerce.string().optional(), language: z.coerce.string().optional(), withRuntimeGte: z.coerce.string().optional(), withRuntimeLte: z.coerce.string().optional(), voteAverageGte: z.coerce.string().optional(), voteAverageLte: z.coerce.string().optional(), voteCountGte: z.coerce.string().optional(), voteCountLte: z.coerce.string().optional(), network: z.coerce.string().optional(), watchProviders: z.coerce.string().optional(), watchRegion: z.coerce.string().optional(), status: z.coerce.string().optional(), certification: z.coerce.string().optional(), certificationGte: z.coerce.string().optional(), certificationLte: z.coerce.string().optional(), certificationCountry: z.coerce.string().optional(), certificationMode: z.enum(['exact', 'range']).optional(), }); export type FilterOptions = z.infer; const ApiQuerySchema = QueryFilterOptions.omit({ certificationMode: true, }); discoverRoutes.get('/movies', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { const query = ApiQuerySchema.parse(req.query); const keywords = query.keywords; const excludeKeywords = query.excludeKeywords; const data = await tmdb.getDiscoverMovies({ page: Number(query.page), sortBy: query.sortBy as SortOptions, language: req.locale ?? query.language, originalLanguage: query.language, genre: query.genre, studio: query.studio, primaryReleaseDateLte: query.primaryReleaseDateLte ? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0] : undefined, primaryReleaseDateGte: query.primaryReleaseDateGte ? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0] : undefined, keywords, excludeKeywords, withRuntimeGte: query.withRuntimeGte, withRuntimeLte: query.withRuntimeLte, voteAverageGte: query.voteAverageGte, voteAverageLte: query.voteAverageLte, voteCountGte: query.voteCountGte, voteCountLte: query.voteCountLte, watchProviders: query.watchProviders, watchRegion: query.watchRegion, certification: query.certification, certificationGte: query.certificationGte, certificationLte: query.certificationLte, certificationCountry: query.certificationCountry, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); let keywordData: TmdbKeyword[] = []; if (keywords) { const splitKeywords = keywords.split(','); const keywordResults = await Promise.all( splitKeywords.map(async (keywordId) => { return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); }) ); keywordData = keywordResults.filter( (keyword): keyword is TmdbKeyword => keyword !== null ); } return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, keywords: keywordData, results: data.results.map((result) => mapMovieResult( result, media.find( (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving popular movies', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve popular movies.', }); } }); discoverRoutes.get<{ language: string }>( '/movies/language/:language', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { const languages = await tmdb.getLanguages(); const language = languages.find( (lang) => lang.iso_639_1 === req.params.language ); if (!language) { return next({ status: 404, message: 'Language not found.' }); } const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, originalLanguage: req.params.language, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, language, results: data.results.map((result) => mapMovieResult( result, media.find( (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving movies by language', { label: 'API', errorMessage: e.message, language: req.params.language, }); return next({ status: 500, message: 'Unable to retrieve movies by language.', }); } } ); discoverRoutes.get<{ genreId: string }>( '/movies/genre/:genreId', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { const genres = await tmdb.getMovieGenres({ language: (req.query.language as string) ?? req.locale, }); const genre = genres.find( (genre) => genre.id === Number(req.params.genreId) ); if (!genre) { return next({ status: 404, message: 'Genre not found.' }); } const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, genre: req.params.genreId as string, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, genre, results: data.results.map((result) => mapMovieResult( result, media.find( (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving movies by genre', { label: 'API', errorMessage: e.message, genreId: req.params.genreId, }); return next({ status: 500, message: 'Unable to retrieve movies by genre.', }); } } ); discoverRoutes.get<{ studioId: string }>( '/movies/studio/:studioId', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const studio = await tmdb.getStudio(Number(req.params.studioId)); const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, studio: req.params.studioId as string, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, studio: mapProductionCompany(studio), results: data.results.map((result) => mapMovieResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving movies by studio', { label: 'API', errorMessage: e.message, studioId: req.params.studioId, }); return next({ status: 500, message: 'Unable to retrieve movies by studio.', }); } } ); discoverRoutes.get('/movies/upcoming', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); const now = new Date(); const offset = now.getTimezoneOffset(); const date = new Date(now.getTime() - offset * 60 * 1000) .toISOString() .split('T')[0]; try { const data = await tmdb.getDiscoverMovies({ page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, primaryReleaseDateGte: date, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, results: data.results.map((result) => mapMovieResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving upcoming movies', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve upcoming movies.', }); } }); discoverRoutes.get('/tv', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { const query = ApiQuerySchema.parse(req.query); const keywords = query.keywords; const excludeKeywords = query.excludeKeywords; const data = await tmdb.getDiscoverTv({ page: Number(query.page), sortBy: query.sortBy as SortOptions, language: req.locale ?? query.language, genre: query.genre, network: query.network ? Number(query.network) : undefined, firstAirDateLte: query.firstAirDateLte ? new Date(query.firstAirDateLte).toISOString().split('T')[0] : undefined, firstAirDateGte: query.firstAirDateGte ? new Date(query.firstAirDateGte).toISOString().split('T')[0] : undefined, originalLanguage: query.language, keywords, excludeKeywords, withRuntimeGte: query.withRuntimeGte, withRuntimeLte: query.withRuntimeLte, voteAverageGte: query.voteAverageGte, voteAverageLte: query.voteAverageLte, voteCountGte: query.voteCountGte, voteCountLte: query.voteCountLte, watchProviders: query.watchProviders, watchRegion: query.watchRegion, withStatus: query.status, certification: query.certification, certificationGte: query.certificationGte, certificationLte: query.certificationLte, certificationCountry: query.certificationCountry, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })) ); let keywordData: TmdbKeyword[] = []; if (keywords) { const splitKeywords = keywords.split(','); const keywordResults = await Promise.all( splitKeywords.map(async (keywordId) => { return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); }) ); keywordData = keywordResults.filter( (keyword): keyword is TmdbKeyword => keyword !== null ); } return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, keywords: keywordData, results: data.results.map((result) => mapTvResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving popular series', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve popular series.', }); } }); discoverRoutes.get<{ language: string }>( '/tv/language/:language', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { const languages = await tmdb.getLanguages(); const language = languages.find( (lang) => lang.iso_639_1 === req.params.language ); if (!language) { return next({ status: 404, message: 'Language not found.' }); } const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, originalLanguage: req.params.language, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, language, results: data.results.map((result) => mapTvResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving series by language', { label: 'API', errorMessage: e.message, language: req.params.language, }); return next({ status: 500, message: 'Unable to retrieve series by language.', }); } } ); discoverRoutes.get<{ genreId: string }>( '/tv/genre/:genreId', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { const genres = await tmdb.getTvGenres({ language: (req.query.language as string) ?? req.locale, }); const genre = genres.find( (genre) => genre.id === Number(req.params.genreId) ); if (!genre) { return next({ status: 404, message: 'Genre not found.' }); } const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, genre: req.params.genreId, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, genre, results: data.results.map((result) => mapTvResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving series by genre', { label: 'API', errorMessage: e.message, genreId: req.params.genreId, }); return next({ status: 500, message: 'Unable to retrieve series by genre.', }); } } ); discoverRoutes.get<{ networkId: string }>( '/tv/network/:networkId', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const network = await tmdb.getNetwork(Number(req.params.networkId)); const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, network: Number(req.params.networkId), }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, network: mapNetwork(network), results: data.results.map((result) => mapTvResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving series by network', { label: 'API', errorMessage: e.message, networkId: req.params.networkId, }); return next({ status: 500, message: 'Unable to retrieve series by network.', }); } } ); discoverRoutes.get('/tv/upcoming', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); const now = new Date(); const offset = now.getTimezoneOffset(); const date = new Date(now.getTime() - offset * 60 * 1000) .toISOString() .split('T')[0]; try { const data = await tmdb.getDiscoverTv({ page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, firstAirDateGte: date, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, results: data.results.map((result) => mapTvResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving upcoming series', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve upcoming series.', }); } }); discoverRoutes.get('/trending', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { const mediaType = (req.query.mediaType as 'all' | 'movie' | 'tv') ?? 'all'; const timeWindow = (req.query.timeWindow as 'day' | 'week') === 'week' ? 'week' : 'day'; const language = (req.query.language as string) ?? req.locale; const page = Number(req.query.page); const trendingFetchers = { movie: async () => ({ data: await tmdb.getMovieTrending({ page, language, timeWindow }), mapper: mapMovieResult, type: MediaType.MOVIE, }), tv: async () => ({ data: await tmdb.getTvTrending({ page, language, timeWindow }), mapper: mapTvResult, type: MediaType.TV, }), all: async () => ({ data: await tmdb.getAllTrending({ page, language, timeWindow }), mapper: (result: any, media?: Media) => { if (isMovie(result)) { return mapMovieResult(result, media); } else if (isPerson(result)) { return mapPersonResult(result); } else if (isCollection(result)) { return mapCollectionResult(result); } else { return mapTvResult(result, media); } }, type: null, }), } as const; const { data, mapper, type } = await trendingFetchers[mediaType](); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: isMovie(result) ? MediaType.MOVIE : MediaType.TV, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, results: data.results.map((result) => { // - If "type" is set (case: "movie" or "tv"), the mediaType must also match. // - If "type" is not set (case: "all"), only filter by tmdbId. const selectedMedia = media.find( (med) => med.tmdbId === result.id && (type ? med.mediaType === type : true) ); return mapper(result, selectedMedia); }), }); } catch (e) { logger.debug('Something went wrong retrieving trending items', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve trending items.', }); } }); discoverRoutes.get<{ keywordId: string }>( '/keyword/:keywordId/movies', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const data = await tmdb.getMoviesByKeyword({ keywordId: Number(req.params.keywordId), page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); const media = await Media.getRelatedMedia( req.user, data.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); return res.status(200).json({ page: data.page, totalPages: data.total_pages, totalResults: data.total_results, results: data.results.map((result) => mapMovieResult( result, media.find( (med) => med.tmdbId === result.id && med.mediaType === MediaType.MOVIE ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving movies by keyword', { label: 'API', errorMessage: e.message, keywordId: req.params.keywordId, }); return next({ status: 500, message: 'Unable to retrieve movies by keyword.', }); } } ); discoverRoutes.get<{ language: string }, GenreSliderItem[]>( '/genreslider/movie', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const mappedGenres: GenreSliderItem[] = []; const genres = await tmdb.getMovieGenres({ language: (req.query.language as string) ?? req.locale, }); await Promise.all( genres.map(async (genre) => { const genreData = await tmdb.getDiscoverMovies({ genre: genre.id.toString(), }); mappedGenres.push({ id: genre.id, name: genre.name, backdrops: genreData.results .filter((title) => !!title.backdrop_path) .map((title) => title.backdrop_path) as string[], }); }) ); const sortedData = sortBy(mappedGenres, 'name'); return res.status(200).json(sortedData); } catch (e) { logger.debug('Something went wrong retrieving the movie genre slider', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve movie genre slider.', }); } } ); discoverRoutes.get<{ language: string }, GenreSliderItem[]>( '/genreslider/tv', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const mappedGenres: GenreSliderItem[] = []; const genres = await tmdb.getTvGenres({ language: (req.query.language as string) ?? req.locale, }); await Promise.all( genres.map(async (genre) => { const genreData = await tmdb.getDiscoverTv({ genre: genre.id.toString(), }); mappedGenres.push({ id: genre.id, name: genre.name, backdrops: genreData.results .filter((title) => !!title.backdrop_path) .map((title) => title.backdrop_path) as string[], }); }) ); const sortedData = sortBy(mappedGenres, 'name'); return res.status(200).json(sortedData); } catch (e) { logger.debug('Something went wrong retrieving the series genre slider', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve series genre slider.', }); } } ); discoverRoutes.get, WatchlistResponse>( '/watchlist', async (req, res) => { const userRepository = getRepository(User); const itemsPerPage = 20; const page = req.query.page ? Number(req.query.page) : 1; const offset = (page - 1) * itemsPerPage; const activeUser = await userRepository.findOne({ where: { id: req.user?.id }, select: ['id', 'plexToken'], }); if (activeUser && !activeUser?.plexToken) { // Non-Plex users can only see their own watchlist const [result, total] = await getRepository(Watchlist).findAndCount({ where: { requestedBy: { id: activeUser?.id } }, relations: { /*requestedBy: true,media:true*/ }, // loadRelationIds: true, take: itemsPerPage, skip: offset, }); if (total) { return res.json({ page: page, totalPages: Math.ceil(total / itemsPerPage), totalResults: total, results: result, }); } } if (!activeUser?.plexToken) { // We will just return an empty array if the user has no Plex token return res.json({ page: 1, totalPages: 1, totalResults: 0, results: [], }); } // List watchlist from Plex const plexTV = new PlexTvAPI(activeUser.plexToken); const watchlist = await plexTV.getWatchlist({ offset }); return res.json({ page, totalPages: Math.ceil(watchlist.totalSize / itemsPerPage), totalResults: watchlist.totalSize, results: watchlist.items.map((item) => ({ id: item.tmdbId, ratingKey: item.ratingKey, title: item.title, mediaType: item.type === 'show' ? 'tv' : 'movie', tmdbId: item.tmdbId, })), }); } ); export default discoverRoutes; ================================================ FILE: server/routes/imageproxy.ts ================================================ import ImageProxy from '@server/lib/imageproxy'; import logger from '@server/logger'; import { Router } from 'express'; const router = Router(); // Delay the initialization of ImageProxy instances until the proxy (if any) is properly configured let _tmdbImageProxy: ImageProxy; function initTmdbImageProxy() { if (!_tmdbImageProxy) { _tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { rateLimitOptions: { maxRequests: 20, maxRPS: 50, }, }); } return _tmdbImageProxy; } let _tvdbImageProxy: ImageProxy; function initTvdbImageProxy() { if (!_tvdbImageProxy) { _tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', { rateLimitOptions: { maxRequests: 20, maxRPS: 50, }, }); } return _tvdbImageProxy; } router.get('/:type/*', async (req, res) => { const imagePath = req.path.replace(/^\/\w+/, ''); if (imagePath.startsWith('//') || imagePath.includes('://')) { logger.error('Invalid URL for image proxy', { imagePath }); return res.status(403).send('Invalid URL for image proxy'); } try { let imageData; if (req.params.type === 'tmdb') { imageData = await initTmdbImageProxy().getImage(imagePath); } else if (req.params.type === 'tvdb') { imageData = await initTvdbImageProxy().getImage(imagePath); } else { logger.error('Unsupported image type', { imagePath, type: req.params.type, }); res.status(400).send('Unsupported image type'); return; } res.writeHead(200, { 'Content-Type': `image/${imageData.meta.extension}`, 'Content-Length': imageData.imageBuffer.length, 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, 'OS-Cache-Key': imageData.meta.cacheKey, 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', }); res.end(imageData.imageBuffer); } catch (e) { logger.error('Failed to proxy image', { imagePath, errorMessage: e.message, }); res.status(500).send(); } }); export default router; ================================================ FILE: server/routes/index.ts ================================================ import GithubAPI from '@server/api/github'; import PushoverAPI from '@server/api/pushover'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbMovieResult, TmdbTvResult, } from '@server/api/themoviedb/interfaces'; import { getRepository } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { checkUser, isAuthenticated } from '@server/middleware/auth'; import deprecatedRoute from '@server/middleware/deprecation'; import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; import { mapWatchProviderDetails } from '@server/models/common'; import overrideRuleRoutes from '@server/routes/overrideRule'; import settingsRoutes from '@server/routes/settings'; import watchlistRoutes from '@server/routes/watchlist'; import { appDataPath, appDataPermissions, appDataStatus, } from '@server/utils/appDataVolume'; import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import authRoutes from './auth'; import blocklistRoutes from './blocklist'; import collectionRoutes from './collection'; import discoverRoutes, { createTmdbWithRegionLanguage } from './discover'; import issueRoutes from './issue'; import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; import movieRoutes from './movie'; import personRoutes from './person'; import requestRoutes from './request'; import searchRoutes from './search'; import serviceRoutes from './service'; import tvRoutes from './tv'; import user from './user'; const router = Router(); router.use(checkUser); router.get('/status', async (req, res) => { const githubApi = new GithubAPI(); const currentVersion = getAppVersion(); const commitTag = getCommitTag(); let updateAvailable = false; let commitsBehind = 0; if (currentVersion.startsWith('develop-') && commitTag !== 'local') { const commits = await githubApi.getSeerrCommits(); if (commits.length) { const filteredCommits = commits.filter( (commit) => !commit.commit.message.includes('[skip ci]') ); if (filteredCommits[0].sha !== commitTag) { updateAvailable = true; } const commitIndex = filteredCommits.findIndex( (commit) => commit.sha === commitTag ); if (updateAvailable) { commitsBehind = commitIndex; } } } else if (commitTag !== 'local') { const releases = await githubApi.getSeerrReleases(); if (releases.length) { const latestVersion = releases[0]; if (!latestVersion.name.includes(currentVersion)) { updateAvailable = true; } } } return res.status(200).json({ version: getAppVersion(), commitTag: getCommitTag(), updateAvailable, commitsBehind, restartRequired: restartFlag.isSet(), }); }); router.get('/status/appdata', (_req, res) => { return res.status(200).json({ appData: appDataStatus(), appDataPath: appDataPath(), appDataPermissions: appDataPermissions(), }); }); router.use('/user', isAuthenticated(), user); router.get('/settings/public', async (req, res) => { const settings = getSettings(); if (!(req.user?.settings?.notificationTypes.webpush ?? true)) { return res .status(200) .json({ ...settings.fullPublicSettings, enablePushRegistration: false }); } else { return res.status(200).json(settings.fullPublicSettings); } }); router.get('/settings/discover', isAuthenticated(), async (_req, res) => { const sliderRepository = getRepository(DiscoverSlider); const sliders = await sliderRepository.find({ order: { order: 'ASC' } }); return res.json(sliders); }); router.get( '/settings/notifications/pushover/sounds', isAuthenticated(), async (req, res, next) => { const pushoverApi = new PushoverAPI(); try { if (!req.query.token) { throw new Error('Pushover application token missing from request'); } const sounds = await pushoverApi.getSounds(req.query.token as string); res.status(200).json(sounds); } catch (e) { logger.debug('Something went wrong retrieving Pushover sounds', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve Pushover sounds.', }); } } ); router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); router.use('/watchlist', isAuthenticated(), watchlistRoutes); router.use('/blocklist', isAuthenticated(), blocklistRoutes); router.use( '/blacklist', isAuthenticated(), deprecatedRoute({ oldPath: '/api/v1/blacklist', newPath: '/api/v1/blocklist', sunsetDate: '2026-06-01', }), blocklistRoutes ); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); router.use('/collection', isAuthenticated(), collectionRoutes); router.use('/service', isAuthenticated(), serviceRoutes); router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/auth', authRoutes); router.use( '/overrideRule', isAuthenticated(Permission.ADMIN), overrideRuleRoutes ); router.get('/regions', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); try { const regions = await tmdb.getRegions(); return res.status(200).json(regions); } catch (e) { logger.debug('Something went wrong retrieving regions', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve regions.', }); } }); router.get('/languages', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); try { const languages = await tmdb.getLanguages(); return res.status(200).json(languages); } catch (e) { logger.debug('Something went wrong retrieving languages', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve languages.', }); } }); router.get<{ id: string }>('/studio/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const studio = await tmdb.getStudio(Number(req.params.id)); return res.status(200).json(mapProductionCompany(studio)); } catch (e) { logger.debug('Something went wrong retrieving studio', { label: 'API', errorMessage: e.message, studioId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve studio.', }); } }); router.get<{ id: string }>('/network/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const network = await tmdb.getNetwork(Number(req.params.id)); return res.status(200).json(mapNetwork(network)); } catch (e) { logger.debug('Something went wrong retrieving network', { label: 'API', errorMessage: e.message, networkId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve network.', }); } }); router.get('/genres/movie', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); try { const genres = await tmdb.getMovieGenres({ language: (req.query.language as string) ?? req.locale, }); return res.status(200).json(genres); } catch (e) { logger.debug('Something went wrong retrieving movie genres', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve movie genres.', }); } }); router.get('/genres/tv', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); try { const genres = await tmdb.getTvGenres({ language: (req.query.language as string) ?? req.locale, }); return res.status(200).json(genres); } catch (e) { logger.debug('Something went wrong retrieving series genres', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve series genres.', }); } }); router.get('/backdrops', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(); try { const data = ( await tmdb.getAllTrending({ page: 1, timeWindow: 'week', }) ).results.filter((result) => !isPerson(result)) as ( | TmdbMovieResult | TmdbTvResult )[]; return res .status(200) .json( data .map((result) => result.backdrop_path) .filter((backdropPath) => !!backdropPath) ); } catch (e) { logger.debug('Something went wrong retrieving backdrops', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve backdrops.', }); } }); router.get('/keyword/:keywordId', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(); try { const result = await tmdb.getKeywordDetails({ keywordId: Number(req.params.keywordId), }); return res.status(200).json(result); } catch (e) { logger.debug('Something went wrong retrieving keyword data', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve keyword data.', }); } }); router.get('/watchproviders/regions', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(); try { const result = await tmdb.getAvailableWatchProviderRegions({}); return res.status(200).json(result); } catch (e) { logger.debug('Something went wrong retrieving watch provider regions', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve watch provider regions.', }); } }); router.get('/watchproviders/movies', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(); try { const result = await tmdb.getMovieWatchProviders({ watchRegion: req.query.watchRegion as string, }); return res.status(200).json(mapWatchProviderDetails(result)); } catch (e) { logger.debug('Something went wrong retrieving movie watch providers', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve movie watch providers.', }); } }); router.get('/watchproviders/tv', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(); try { const result = await tmdb.getTvWatchProviders({ watchRegion: req.query.watchRegion as string, }); return res.status(200).json(mapWatchProviderDetails(result)); } catch (e) { logger.debug('Something went wrong retrieving tv watch providers', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve tv watch providers.', }); } }); router.get( '/certifications/movie', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); try { const certifications = await tmdb.getMovieCertifications(); return res.status(200).json(certifications); } catch (e) { logger.error('Something went wrong retrieving movie certifications', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve movie certifications.', }); } } ); router.get('/certifications/tv', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); try { const certifications = await tmdb.getTvCertifications(); return res.status(200).json(certifications); } catch (e) { logger.debug('Something went wrong retrieving TV certifications', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve TV certifications.', }); } }); router.get('/', (_req, res) => { return res.status(200).json({ api: 'Seerr API', version: '1.0', }); }); export default router; ================================================ FILE: server/routes/issue.ts ================================================ import { IssueStatus, IssueType } from '@server/constants/issue'; import { getRepository } from '@server/datasource'; import Issue from '@server/entity/Issue'; import IssueComment from '@server/entity/IssueComment'; import Media from '@server/entity/Media'; import type { IssueResultsResponse } from '@server/interfaces/api/issueInterfaces'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; const issueRoutes = Router(); issueRoutes.get, IssueResultsResponse>( '/', isAuthenticated( [ Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES, Permission.CREATE_ISSUES, ], { type: 'or' } ), async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 10; const skip = req.query.skip ? Number(req.query.skip) : 0; const createdBy = req.query.createdBy ? Number(req.query.createdBy) : null; let sortFilter: string; switch (req.query.sort) { case 'modified': sortFilter = 'issue.updatedAt'; break; default: sortFilter = 'issue.createdAt'; } let statusFilter: IssueStatus[]; switch (req.query.filter) { case 'open': statusFilter = [IssueStatus.OPEN]; break; case 'resolved': statusFilter = [IssueStatus.RESOLVED]; break; default: statusFilter = [IssueStatus.OPEN, IssueStatus.RESOLVED]; } let query = getRepository(Issue) .createQueryBuilder('issue') .leftJoinAndSelect('issue.createdBy', 'createdBy') .leftJoinAndSelect('issue.media', 'media') .leftJoinAndSelect('issue.modifiedBy', 'modifiedBy') .leftJoinAndSelect('issue.comments', 'comments') .where('issue.status IN (:...issueStatus)', { issueStatus: statusFilter, }); if ( !req.user?.hasPermission( [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { type: 'or' } ) ) { if (createdBy && createdBy !== req.user?.id) { return next({ status: 403, message: 'You do not have permission to view issues reported by other users', }); } query = query.andWhere('createdBy.id = :id', { id: req.user?.id }); } else if (createdBy) { query = query.andWhere('createdBy.id = :id', { id: createdBy }); } const [issues, issueCount] = await query .orderBy(sortFilter, 'DESC') .take(pageSize) .skip(skip) .getManyAndCount(); return res.status(200).json({ pageInfo: { pages: Math.ceil(issueCount / pageSize), pageSize, results: issueCount, page: Math.ceil(skip / pageSize) + 1, }, results: issues, }); } ); issueRoutes.post< Record, Issue, { message: string; mediaId: number; issueType: number; problemSeason: number; problemEpisode: number; } >( '/', isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { type: 'or', }), async (req, res, next) => { // Satisfy typescript here. User is set, we assure you! if (!req.user) { return next({ status: 500, message: 'User missing from request.' }); } const issueRepository = getRepository(Issue); const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: req.body.mediaId }, }); if (!media) { return next({ status: 404, message: 'Media does not exist.' }); } const issue = new Issue({ createdBy: req.user, issueType: req.body.issueType, problemSeason: req.body.problemSeason, problemEpisode: req.body.problemEpisode, media, comments: [ new IssueComment({ user: req.user, message: req.body.message, }), ], }); const newIssue = await issueRepository.save(issue); return res.status(200).json(newIssue); } ); issueRoutes.get('/count', async (req, res, next) => { const issueRepository = getRepository(Issue); try { const query = issueRepository.createQueryBuilder('issue'); const totalCount = await query.getCount(); const videoCount = await query .where('issue.issueType = :issueType', { issueType: IssueType.VIDEO, }) .getCount(); const audioCount = await query .where('issue.issueType = :issueType', { issueType: IssueType.AUDIO, }) .getCount(); const subtitlesCount = await query .where('issue.issueType = :issueType', { issueType: IssueType.SUBTITLES, }) .getCount(); const othersCount = await query .where('issue.issueType = :issueType', { issueType: IssueType.OTHER, }) .getCount(); const openCount = await query .where('issue.status = :issueStatus', { issueStatus: IssueStatus.OPEN, }) .getCount(); const closedCount = await query .where('issue.status = :issueStatus', { issueStatus: IssueStatus.RESOLVED, }) .getCount(); return res.status(200).json({ total: totalCount, video: videoCount, audio: audioCount, subtitles: subtitlesCount, others: othersCount, open: openCount, closed: closedCount, }); } catch (e) { logger.debug('Something went wrong retrieving issue counts.', { label: 'API', errorMessage: e.message, }); next({ status: 500, message: 'Unable to retrieve issue counts.' }); } }); issueRoutes.get<{ issueId: string }>( '/:issueId', isAuthenticated( [ Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES, Permission.CREATE_ISSUES, ], { type: 'or' } ), async (req, res, next) => { const issueRepository = getRepository(Issue); // Satisfy typescript here. User is set, we assure you! if (!req.user) { return next({ status: 500, message: 'User missing from request.' }); } try { const issue = await issueRepository .createQueryBuilder('issue') .leftJoinAndSelect('issue.comments', 'comments') .leftJoinAndSelect('issue.createdBy', 'createdBy') .leftJoinAndSelect('comments.user', 'user') .leftJoinAndSelect('issue.media', 'media') .where('issue.id = :issueId', { issueId: Number(req.params.issueId) }) .getOneOrFail(); if ( issue.createdBy.id !== req.user.id && !req.user.hasPermission( [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { type: 'or' } ) ) { return next({ status: 403, message: 'You do not have permission to view this issue.', }); } return res.status(200).json(issue); } catch (e) { logger.debug('Failed to retrieve issue.', { label: 'API', errorMessage: e.message, }); next({ status: 500, message: 'Issue not found.' }); } } ); issueRoutes.post<{ issueId: string }, Issue, { message: string }>( '/:issueId/comment', isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { type: 'or', }), async (req, res, next) => { const issueRepository = getRepository(Issue); // Satisfy typescript here. User is set, we assure you! if (!req.user) { return next({ status: 500, message: 'User missing from request.' }); } try { const issue = await issueRepository.findOneOrFail({ where: { id: Number(req.params.issueId) }, }); if ( issue.createdBy.id !== req.user.id && !req.user.hasPermission(Permission.MANAGE_ISSUES) ) { return next({ status: 403, message: 'You do not have permission to comment on this issue.', }); } const comment = new IssueComment({ message: req.body.message, user: req.user, }); issue.comments = [...issue.comments, comment]; issue.updatedAt = new Date(); await issueRepository.save(issue); return res.status(200).json(issue); } catch (e) { logger.debug('Something went wrong creating an issue comment.', { label: 'API', errorMessage: e.message, }); next({ status: 500, message: 'Issue not found.' }); } } ); issueRoutes.post<{ issueId: string; status: string }, Issue>( '/:issueId/:status', isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { type: 'or', }), async (req, res, next) => { const issueRepository = getRepository(Issue); // Satisfy typescript here. User is set, we assure you! if (!req.user) { return next({ status: 500, message: 'User missing from request.' }); } try { const issue = await issueRepository.findOneOrFail({ where: { id: Number(req.params.issueId) }, }); if ( !req.user?.hasPermission(Permission.MANAGE_ISSUES) && issue.createdBy.id !== req.user?.id ) { return next({ status: 401, message: 'You do not have permission to modify this issue.', }); } let newStatus: IssueStatus | undefined; switch (req.params.status) { case 'resolved': newStatus = IssueStatus.RESOLVED; break; case 'open': newStatus = IssueStatus.OPEN; } if (!newStatus) { return next({ status: 400, message: 'You must provide a valid status', }); } issue.status = newStatus; issue.modifiedBy = req.user; await issueRepository.save(issue); return res.status(200).json(issue); } catch (e) { logger.debug('Something went wrong creating an issue comment.', { label: 'API', errorMessage: e.message, }); next({ status: 500, message: 'Issue not found.' }); } } ); issueRoutes.delete( '/:issueId', isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { type: 'or', }), async (req, res, next) => { const issueRepository = getRepository(Issue); try { const issue = await issueRepository.findOneOrFail({ where: { id: Number(req.params.issueId) }, relations: { createdBy: true }, }); if ( !req.user?.hasPermission(Permission.MANAGE_ISSUES) && (issue.createdBy.id !== req.user?.id || issue.comments.length > 1) ) { return next({ status: 401, message: 'You do not have permission to delete this issue.', }); } await issueRepository.remove(issue); return res.status(204).send(); } catch (e) { logger.error('Something went wrong deleting an issue.', { label: 'API', errorMessage: e.message, }); next({ status: 404, message: 'Issue not found.' }); } } ); export default issueRoutes; ================================================ FILE: server/routes/issueComment.ts ================================================ import { getRepository } from '@server/datasource'; import IssueComment from '@server/entity/IssueComment'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; const issueCommentRoutes = Router(); issueCommentRoutes.get<{ commentId: string }, IssueComment>( '/:commentId', isAuthenticated( [ Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES, Permission.CREATE_ISSUES, ], { type: 'or', } ), async (req, res, next) => { const issueCommentRepository = getRepository(IssueComment); try { const comment = await issueCommentRepository.findOneOrFail({ where: { id: Number(req.params.commentId) }, }); if ( !req.user?.hasPermission( [Permission.MANAGE_ISSUES, Permission.VIEW_ISSUES], { type: 'or' } ) && comment.user.id !== req.user?.id ) { return next({ status: 403, message: 'You do not have permission to view this comment.', }); } return res.status(200).json(comment); } catch (e) { logger.debug('Request for unknown issue comment failed', { label: 'API', errorMessage: e.message, }); next({ status: 404, message: 'Issue comment not found.' }); } } ); issueCommentRoutes.put< { commentId: string }, IssueComment, { message: string } >( '/:commentId', isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { type: 'or', }), async (req, res, next) => { const issueCommentRepository = getRepository(IssueComment); try { const comment = await issueCommentRepository.findOneOrFail({ where: { id: Number(req.params.commentId) }, }); if (comment.user.id !== req.user?.id) { return next({ status: 403, message: 'You can only edit your own comments.', }); } comment.message = req.body.message; await issueCommentRepository.save(comment); return res.status(200).json(comment); } catch (e) { logger.debug('Put request for issue comment failed', { label: 'API', errorMessage: e.message, }); next({ status: 404, message: 'Issue comment not found.' }); } } ); issueCommentRoutes.delete<{ commentId: string }, IssueComment>( '/:commentId', isAuthenticated([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { type: 'or', }), async (req, res, next) => { const issueCommentRepository = getRepository(IssueComment); try { const comment = await issueCommentRepository.findOneOrFail({ where: { id: Number(req.params.commentId) }, }); if ( !req.user?.hasPermission([Permission.MANAGE_ISSUES], { type: 'or' }) && comment.user.id !== req.user?.id ) { return next({ status: 403, message: 'You do not have permission to delete this comment.', }); } await issueCommentRepository.remove(comment); return res.status(204).send(); } catch (e) { logger.debug('Delete request for issue comment failed', { label: 'API', errorMessage: e.message, }); next({ status: 404, message: 'Issue comment not found.' }); } } ); export default issueCommentRoutes; ================================================ FILE: server/routes/media.ts ================================================ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TautulliAPI from '@server/api/tautulli'; import TheMovieDb from '@server/api/themoviedb'; import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import type { MediaResultsResponse, MediaWatchDataResponse, } from '@server/interfaces/api/mediaInterfaces'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; import type { FindOneOptions } from 'typeorm'; import { In, IsNull, Not } from 'typeorm'; const mediaRoutes = Router(); mediaRoutes.get('/', async (req, res, next) => { const mediaRepository = getRepository(Media); const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; let statusFilter = undefined; switch (req.query.filter) { case 'available': statusFilter = MediaStatus.AVAILABLE; break; case 'partial': statusFilter = MediaStatus.PARTIALLY_AVAILABLE; break; case 'allavailable': statusFilter = In([ MediaStatus.AVAILABLE, MediaStatus.PARTIALLY_AVAILABLE, ]); break; case 'processing': statusFilter = MediaStatus.PROCESSING; break; case 'pending': statusFilter = MediaStatus.PENDING; break; } let sortFilter: FindOneOptions['order'] = { id: 'DESC', }; switch (req.query.sort) { case 'modified': sortFilter = { updatedAt: 'DESC', }; break; case 'mediaAdded': sortFilter = { mediaAddedAt: 'DESC', }; } let whereClause: FindOneOptions['where']; if (statusFilter || req.query.sort === 'mediaAdded') { whereClause = {}; if (statusFilter) whereClause.status = statusFilter; if (req.query.sort === 'mediaAdded') whereClause.mediaAddedAt = Not(IsNull()); } try { const [media, mediaCount] = await mediaRepository.findAndCount({ order: sortFilter, where: whereClause, take: pageSize, skip, }); return res.status(200).json({ pageInfo: { pages: Math.ceil(mediaCount / pageSize), pageSize, results: mediaCount, page: Math.ceil(skip / pageSize) + 1, }, results: media, } as MediaResultsResponse); } catch (e) { next({ status: 500, message: e.message }); } }); mediaRoutes.post< { id: string; status: 'available' | 'partial' | 'processing' | 'pending' | 'unknown'; }, Media >( '/:id/:status', isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const mediaRepository = getRepository(Media); const seasonRepository = getRepository(Season); const media = await mediaRepository.findOne({ where: { id: Number(req.params.id) }, }); if (!media) { return next({ status: 404, message: 'Media does not exist.' }); } const is4k = String(req.body.is4k) === 'true'; switch (req.params.status) { case 'available': media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE; if (media.mediaType === MediaType.TV) { const expectedSeasons = req.body.seasons ?? []; for (const expectedSeason of expectedSeasons) { let season = media.seasons.find( (s) => s.seasonNumber === expectedSeason?.seasonNumber ); if (!season) { // Create the season if it doesn't exist season = seasonRepository.create({ seasonNumber: expectedSeason?.seasonNumber, }); media.seasons.push(season); } season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE; } } break; case 'partial': if (media.mediaType === MediaType.MOVIE) { return next({ status: 400, message: 'Only series can be set to be partially available', }); } media[is4k ? 'status4k' : 'status'] = MediaStatus.PARTIALLY_AVAILABLE; break; case 'processing': media[is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING; break; case 'pending': media[is4k ? 'status4k' : 'status'] = MediaStatus.PENDING; break; case 'unknown': media[is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN; } await mediaRepository.save(media); return res.status(200).json(media); } ); mediaRoutes.delete( '/:id', isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { try { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); if (media.status === MediaStatus.BLOCKLISTED) { media.resetServiceData(); await mediaRepository.save(media); } else { await mediaRepository.remove(media); } return res.status(204).send(); } catch (e) { logger.error('Something went wrong fetching media in delete request', { label: 'Media', message: e.message, }); next({ status: 404, message: 'Media not found' }); } } ); mediaRoutes.delete( '/:id/file', isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { try { const settings = getSettings(); const mediaRepository = getRepository(Media); const media = await mediaRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); const is4k = String(req.query.is4k) === 'true'; const isMovie = media.mediaType === MediaType.MOVIE; let serviceSettings; if (isMovie) { serviceSettings = settings.radarr.find( (radarr) => radarr.isDefault && radarr.is4k === is4k ); } else { serviceSettings = settings.sonarr.find( (sonarr) => sonarr.isDefault && sonarr.is4k === is4k ); } const specificServiceId = is4k ? media.serviceId4k : media.serviceId; if ( specificServiceId && specificServiceId >= 0 && serviceSettings?.id !== specificServiceId ) { if (isMovie) { serviceSettings = settings.radarr.find( (radarr) => radarr.id === specificServiceId ); } else { serviceSettings = settings.sonarr.find( (sonarr) => sonarr.id === specificServiceId ); } } if (!serviceSettings) { logger.warn( `There is no default ${ is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' }/ server configured. Did you set any of your ${ is4k ? '4K ' : '' + isMovie ? 'Radarr' : 'Sonarr' } servers as default?`, { label: 'Media Request', mediaId: media.id, } ); return; } let service; if (isMovie) { service = new RadarrAPI({ apiKey: serviceSettings?.apiKey, url: RadarrAPI.buildUrl(serviceSettings, '/api/v3'), }); } else { service = new SonarrAPI({ apiKey: serviceSettings?.apiKey, url: SonarrAPI.buildUrl(serviceSettings, '/api/v3'), }); } if (isMovie) { await (service as RadarrAPI).removeMovie(media.tmdbId); } else { const tmdb = new TheMovieDb(); const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; if (!tvdbId) { throw new Error('TVDB ID not found'); } await (service as SonarrAPI).removeSeries(tvdbId); } return res.status(204).send(); } catch (e) { logger.error('Something went wrong fetching media in delete request', { label: 'Media', message: e.message, }); next({ status: 404, message: 'Media not found' }); } } ); mediaRoutes.get<{ id: string }, MediaWatchDataResponse>( '/:id/watch_data', isAuthenticated(Permission.ADMIN), async (req, res, next) => { const settings = getSettings().tautulli; if (!settings.hostname || !settings.port || !settings.apiKey) { return next({ status: 404, message: 'Tautulli API not configured.', }); } const media = await getRepository(Media).findOne({ where: { id: Number(req.params.id) }, }); if (!media) { return next({ status: 404, message: 'Media does not exist.' }); } try { const tautulli = new TautulliAPI(settings); const userRepository = getRepository(User); const response: MediaWatchDataResponse = {}; if (media.ratingKey) { const watchStats = await tautulli.getMediaWatchStats(media.ratingKey); const watchUsers = await tautulli.getMediaWatchUsers(media.ratingKey); const plexIds = watchUsers.map((u) => u.user_id); if (!plexIds.length) plexIds.push(-1); const users = await userRepository .createQueryBuilder('user') .where('user.plexId IN (:...plexIds)', { plexIds }) .getMany(); const playCount = watchStats.find((i) => i.query_days == 0)?.total_plays ?? 0; const playCount7Days = watchStats.find((i) => i.query_days == 7)?.total_plays ?? 0; const playCount30Days = watchStats.find((i) => i.query_days == 30)?.total_plays ?? 0; response.data = { users: users, playCount, playCount7Days, playCount30Days, }; } if (media.ratingKey4k) { const watchStats4k = await tautulli.getMediaWatchStats( media.ratingKey4k ); const watchUsers4k = await tautulli.getMediaWatchUsers( media.ratingKey4k ); const plexIds4k = watchUsers4k.map((u) => u.user_id); if (!plexIds4k.length) plexIds4k.push(-1); const users = await userRepository .createQueryBuilder('user') .where('user.plexId IN (:...plexIds)', { plexIds: plexIds4k }) .getMany(); const playCount = watchStats4k.find((i) => i.query_days == 0)?.total_plays ?? 0; const playCount7Days = watchStats4k.find((i) => i.query_days == 7)?.total_plays ?? 0; const playCount30Days = watchStats4k.find((i) => i.query_days == 30)?.total_plays ?? 0; response.data4k = { users, playCount, playCount7Days, playCount30Days, }; } return res.status(200).json(response); } catch (e) { logger.error('Something went wrong fetching media watch data', { label: 'API', errorMessage: e.message, mediaId: req.params.id, }); next({ status: 500, message: 'Failed to fetch watch data.' }); } } ); export default mediaRoutes; ================================================ FILE: server/routes/movie.ts ================================================ import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy'; import RottenTomatoes from '@server/api/rating/rottentomatoes'; import { type RatingResponse } from '@server/api/ratings'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapMovieDetails } from '@server/models/Movie'; import { mapMovieResult } from '@server/models/Search'; import { Router } from 'express'; const movieRoutes = Router(); movieRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const tmdbMovie = await tmdb.getMovie({ movieId: Number(req.params.id), language: (req.query.language as string) ?? req.locale, }); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); const onUserWatchlist = await getRepository(Watchlist).exist({ where: { tmdbId: Number(req.params.id), mediaType: MediaType.MOVIE, requestedBy: { id: req.user?.id, }, }, }); const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist); // TMDB issue where it doesnt fallback to English when no overview is available in requested locale. if (!data.overview) { const tvEnglish = await tmdb.getMovie({ movieId: Number(req.params.id) }); data.overview = tvEnglish.overview; } return res.status(200).json(data); } catch (e) { logger.debug('Something went wrong retrieving movie', { label: 'API', errorMessage: e.message, movieId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve movie.', }); } }); movieRoutes.get('/:id/recommendations', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const results = await tmdb.getMovieRecommendations({ movieId: Number(req.params.id), page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); const media = await Media.getRelatedMedia( req.user, results.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); return res.status(200).json({ page: results.page, totalPages: results.total_pages, totalResults: results.total_results, results: results.results.map((result) => mapMovieResult( result, media.find( (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving movie recommendations', { label: 'API', errorMessage: e.message, movieId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve movie recommendations.', }); } }); movieRoutes.get('/:id/similar', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const results = await tmdb.getMovieSimilar({ movieId: Number(req.params.id), page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); const media = await Media.getRelatedMedia( req.user, results.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); return res.status(200).json({ page: results.page, totalPages: results.total_pages, totalResults: results.total_results, results: results.results.map((result) => mapMovieResult( result, media.find( (req) => req.tmdbId === result.id && req.mediaType === MediaType.MOVIE ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving similar movies', { label: 'API', errorMessage: e.message, movieId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve similar movies.', }); } }); /** * Endpoint backed by RottenTomatoes */ movieRoutes.get('/:id/ratings', async (req, res, next) => { const tmdb = new TheMovieDb(); const rtapi = new RottenTomatoes(); try { const movie = await tmdb.getMovie({ movieId: Number(req.params.id), }); const rtratings = await rtapi.getMovieRatings( movie.title, Number(movie.release_date.slice(0, 4)) ); if (!rtratings) { return next({ status: 404, message: 'Rotten Tomatoes ratings not found.', }); } return res.status(200).json(rtratings); } catch (e) { logger.debug('Something went wrong retrieving movie ratings', { label: 'API', errorMessage: e.message, movieId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve movie ratings.', }); } }); /** * Endpoint combining RottenTomatoes and IMDB */ movieRoutes.get('/:id/ratingscombined', async (req, res, next) => { const tmdb = new TheMovieDb(); const rtapi = new RottenTomatoes(); const imdbApi = new IMDBRadarrProxy(); try { const movie = await tmdb.getMovie({ movieId: Number(req.params.id), }); const rtratings = await rtapi.getMovieRatings( movie.title, Number(movie.release_date.slice(0, 4)) ); let imdbRatings; if (movie.imdb_id) { imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id); } if (!rtratings && !imdbRatings) { return next({ status: 404, message: 'No ratings found.', }); } const ratings: RatingResponse = { ...(rtratings ? { rt: rtratings } : {}), ...(imdbRatings ? { imdb: imdbRatings } : {}), }; return res.status(200).json(ratings); } catch (e) { logger.debug('Something went wrong retrieving movie ratings', { label: 'API', errorMessage: e.message, movieId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve movie ratings.', }); } }); export default movieRoutes; ================================================ FILE: server/routes/overrideRule.ts ================================================ import { getRepository } from '@server/datasource'; import OverrideRule from '@server/entity/OverrideRule'; import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; import { Permission } from '@server/lib/permissions'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; const overrideRuleRoutes = Router(); overrideRuleRoutes.get( '/', isAuthenticated(Permission.ADMIN), async (req, res, next) => { const overrideRuleRepository = getRepository(OverrideRule); try { const rules = await overrideRuleRepository.find({}); return res.status(200).json(rules as OverrideRuleResultsResponse); } catch (e) { next({ status: 404, message: e.message }); } } ); overrideRuleRoutes.post< Record, OverrideRule, { users?: string; genre?: string; language?: string; keywords?: string; profileId?: number; rootFolder?: string; tags?: string; radarrServiceId?: number; sonarrServiceId?: number; } >('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => { const overrideRuleRepository = getRepository(OverrideRule); try { const rule = new OverrideRule({ users: req.body.users, genre: req.body.genre, language: req.body.language, keywords: req.body.keywords, profileId: req.body.profileId, rootFolder: req.body.rootFolder, tags: req.body.tags, radarrServiceId: req.body.radarrServiceId, sonarrServiceId: req.body.sonarrServiceId, }); const newRule = await overrideRuleRepository.save(rule); return res.status(200).json(newRule); } catch (e) { next({ status: 404, message: e.message }); } }); overrideRuleRoutes.put< { ruleId: string }, OverrideRule, { users?: string; genre?: string; language?: string; keywords?: string; profileId?: number; rootFolder?: string; tags?: string; radarrServiceId?: number; sonarrServiceId?: number; } >('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => { const overrideRuleRepository = getRepository(OverrideRule); try { const rule = await overrideRuleRepository.findOne({ where: { id: Number(req.params.ruleId), }, }); if (!rule) { return next({ status: 404, message: 'Override Rule not found.' }); } rule.users = req.body.users; rule.genre = req.body.genre; rule.language = req.body.language; rule.keywords = req.body.keywords; rule.profileId = req.body.profileId; rule.rootFolder = req.body.rootFolder; rule.tags = req.body.tags; rule.radarrServiceId = req.body.radarrServiceId; rule.sonarrServiceId = req.body.sonarrServiceId; const newRule = await overrideRuleRepository.save(rule); return res.status(200).json(newRule); } catch (e) { next({ status: 404, message: e.message }); } }); overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>( '/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => { const overrideRuleRepository = getRepository(OverrideRule); try { const rule = await overrideRuleRepository.findOne({ where: { id: Number(req.params.ruleId), }, }); if (!rule) { return next({ status: 404, message: 'Override Rule not found.' }); } await overrideRuleRepository.remove(rule); return res.status(200).json(rule); } catch (e) { next({ status: 404, message: e.message }); } } ); export default overrideRuleRoutes; ================================================ FILE: server/routes/person.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import Media from '@server/entity/Media'; import logger from '@server/logger'; import { mapCastCredits, mapCrewCredits, mapPersonDetails, } from '@server/models/Person'; import { Router } from 'express'; const personRoutes = Router(); personRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const person = await tmdb.getPerson({ personId: Number(req.params.id), language: (req.query.language as string) ?? req.locale, }); return res.status(200).json(mapPersonDetails(person)); } catch (e) { logger.debug('Something went wrong retrieving person', { label: 'API', errorMessage: e.message, personId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve person.', }); } }); personRoutes.get('/:id/combined_credits', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const combinedCredits = await tmdb.getPersonCombinedCredits({ personId: Number(req.params.id), language: (req.query.language as string) ?? req.locale, }); const castMedia = await Media.getRelatedMedia( req.user, combinedCredits.cast .filter((result) => result.media_type) .map((result) => ({ tmdbId: result.id, mediaType: result.media_type!, })) ); const crewMedia = await Media.getRelatedMedia( req.user, combinedCredits.crew .filter((result) => result.media_type) .map((result) => ({ tmdbId: result.id, mediaType: result.media_type!, })) ); return res.status(200).json({ cast: combinedCredits.cast .map((result) => mapCastCredits( result, castMedia.find( (med) => med.tmdbId === result.id && med.mediaType === result.media_type ) ) ) .filter((item) => !item.adult), crew: combinedCredits.crew .map((result) => mapCrewCredits( result, crewMedia.find( (med) => med.tmdbId === result.id && med.mediaType === result.media_type ) ) ) .filter((item) => !item.adult), id: combinedCredits.id, }); } catch (e) { logger.debug('Something went wrong retrieving combined credits', { label: 'API', errorMessage: e.message, personId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve combined credits.', }); } }); export default personRoutes; ================================================ FILE: server/routes/request.ts ================================================ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaRequestStatus, MediaStatus, MediaType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { BlocklistedMediaError, DuplicateMediaRequestError, MediaRequest, NoSeasonsAvailableError, QuotaRestrictedError, RequestPermissionError, } from '@server/entity/MediaRequest'; import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; import type { MediaRequestBody, RequestResultsResponse, } from '@server/interfaces/api/requestInterfaces'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; const requestRoutes = Router(); requestRoutes.get, RequestResultsResponse>( '/', async (req, res, next) => { try { const pageSize = req.query.take ? Number(req.query.take) : 10; const skip = req.query.skip ? Number(req.query.skip) : 0; const requestedBy = req.query.requestedBy ? Number(req.query.requestedBy) : null; const mediaType = (req.query.mediaType as MediaType | 'all') || 'all'; let statusFilter: MediaRequestStatus[]; switch (req.query.filter) { case 'approved': case 'processing': statusFilter = [MediaRequestStatus.APPROVED]; break; case 'pending': statusFilter = [MediaRequestStatus.PENDING]; break; case 'unavailable': statusFilter = [ MediaRequestStatus.PENDING, MediaRequestStatus.APPROVED, ]; break; case 'failed': statusFilter = [MediaRequestStatus.FAILED]; break; case 'completed': case 'available': case 'deleted': statusFilter = [MediaRequestStatus.COMPLETED]; break; default: statusFilter = [ MediaRequestStatus.PENDING, MediaRequestStatus.APPROVED, MediaRequestStatus.DECLINED, MediaRequestStatus.FAILED, MediaRequestStatus.COMPLETED, ]; } let mediaStatusFilter: MediaStatus[]; switch (req.query.filter) { case 'available': mediaStatusFilter = [MediaStatus.AVAILABLE]; break; case 'processing': case 'unavailable': mediaStatusFilter = [ MediaStatus.UNKNOWN, MediaStatus.PENDING, MediaStatus.PROCESSING, MediaStatus.PARTIALLY_AVAILABLE, ]; break; case 'deleted': mediaStatusFilter = [MediaStatus.DELETED]; break; default: mediaStatusFilter = [ MediaStatus.UNKNOWN, MediaStatus.PENDING, MediaStatus.PROCESSING, MediaStatus.PARTIALLY_AVAILABLE, MediaStatus.AVAILABLE, MediaStatus.DELETED, ]; } let sortFilter: string; let sortDirection: 'ASC' | 'DESC'; switch (req.query.sort) { case 'modified': sortFilter = 'request.updatedAt'; break; default: sortFilter = 'request.id'; } switch (req.query.sortDirection) { case 'asc': sortDirection = 'ASC'; break; default: sortDirection = 'DESC'; } let query = getRepository(MediaRequest) .createQueryBuilder('request') .leftJoinAndSelect('request.media', 'media') .leftJoinAndSelect('request.seasons', 'seasons') .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') .leftJoinAndSelect('request.requestedBy', 'requestedBy') .where('request.status IN (:...requestStatus)', { requestStatus: statusFilter, }) .andWhere( '((request.is4k = false AND media.status IN (:...mediaStatus)) OR (request.is4k = true AND media.status4k IN (:...mediaStatus)))', { mediaStatus: mediaStatusFilter, } ); if ( !req.user?.hasPermission( [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], { type: 'or' } ) ) { if (requestedBy && requestedBy !== req.user?.id) { return next({ status: 403, message: "You do not have permission to view this user's requests.", }); } query = query.andWhere('requestedBy.id = :id', { id: req.user?.id, }); } else if (requestedBy) { query = query.andWhere('requestedBy.id = :id', { id: requestedBy, }); } switch (mediaType) { case 'all': break; case 'movie': query = query.andWhere('request.type = :type', { type: MediaType.MOVIE, }); break; case 'tv': query = query.andWhere('request.type = :type', { type: MediaType.TV, }); break; } const [requests, requestCount] = await query .orderBy(sortFilter, sortDirection) .take(pageSize) .skip(skip) .getManyAndCount(); const settings = getSettings(); // get all quality profiles for every configured sonarr server const sonarrServers = await Promise.all( settings.sonarr.map(async (sonarrSetting) => { const sonarr = new SonarrAPI({ apiKey: sonarrSetting.apiKey, url: SonarrAPI.buildUrl(sonarrSetting, '/api/v3'), }); return { id: sonarrSetting.id, profiles: await sonarr.getProfiles().catch(() => undefined), }; }) ); // get all quality profiles for every configured radarr server const radarrServers = await Promise.all( settings.radarr.map(async (radarrSetting) => { const radarr = new RadarrAPI({ apiKey: radarrSetting.apiKey, url: RadarrAPI.buildUrl(radarrSetting, '/api/v3'), }); return { id: radarrSetting.id, profiles: await radarr.getProfiles().catch(() => undefined), }; }) ); // add profile names to the media requests, with undefined if not found let mappedRequests = requests.map((r) => { switch (r.type) { case MediaType.MOVIE: { const profileName = radarrServers .find((serverr) => serverr.id === r.serverId) ?.profiles?.find((profile) => profile.id === r.profileId)?.name; return { ...r, profileName, }; } case MediaType.TV: { return { ...r, profileName: sonarrServers .find((serverr) => serverr.id === r.serverId) ?.profiles?.find((profile) => profile.id === r.profileId)?.name, }; } } }); // add canRemove prop if user has permission if (req.user?.hasPermission(Permission.MANAGE_REQUESTS)) { mappedRequests = mappedRequests.map((r) => { switch (r.type) { case MediaType.MOVIE: { return { ...r, // check if the radarr server for this request is configured canRemove: radarrServers.some( (server) => server.id === (r.is4k ? r.media.serviceId4k : r.media.serviceId) ), }; } case MediaType.TV: { return { ...r, // check if the sonarr server for this request is configured canRemove: sonarrServers.some( (server) => server.id === (r.is4k ? r.media.serviceId4k : r.media.serviceId) ), }; } } }); } return res.status(200).json({ pageInfo: { pages: Math.ceil(requestCount / pageSize), pageSize, results: requestCount, page: Math.ceil(skip / pageSize) + 1, }, results: mappedRequests, serviceErrors: { radarr: radarrServers .filter((s) => !s.profiles) .map((s) => ({ id: s.id, name: settings.radarr.find((r) => r.id === s.id)?.name || `Radarr ${s.id}`, })), sonarr: sonarrServers .filter((s) => !s.profiles) .map((s) => ({ id: s.id, name: settings.sonarr.find((r) => r.id === s.id)?.name || `Sonarr ${s.id}`, })), }, }); } catch (e) { next({ status: 500, message: e.message }); } } ); requestRoutes.post( '/', async (req, res, next) => { try { if (!req.user) { return next({ status: 401, message: 'You must be logged in to request media.', }); } const request = await MediaRequest.request(req.body, req.user); return res.status(201).json(request); } catch (error) { if (!(error instanceof Error)) { return; } switch (error.constructor) { case RequestPermissionError: case QuotaRestrictedError: return next({ status: 403, message: error.message }); case DuplicateMediaRequestError: return next({ status: 409, message: error.message }); case NoSeasonsAvailableError: return next({ status: 202, message: error.message }); case BlocklistedMediaError: return next({ status: 403, message: error.message }); default: return next({ status: 500, message: error.message }); } } } ); requestRoutes.get('/count', async (_req, res, next) => { const requestRepository = getRepository(MediaRequest); try { const query = requestRepository .createQueryBuilder('request') .innerJoinAndSelect('request.media', 'media'); const totalCount = await query.getCount(); const movieCount = await query .where('request.type = :requestType', { requestType: MediaType.MOVIE, }) .getCount(); const tvCount = await query .where('request.type = :requestType', { requestType: MediaType.TV, }) .getCount(); const pendingCount = await query .where('request.status = :requestStatus', { requestStatus: MediaRequestStatus.PENDING, }) .getCount(); const approvedCount = await query .where('request.status = :requestStatus', { requestStatus: MediaRequestStatus.APPROVED, }) .getCount(); const declinedCount = await query .where('request.status = :requestStatus', { requestStatus: MediaRequestStatus.DECLINED, }) .getCount(); const processingCount = await query .where('request.status = :requestStatus', { requestStatus: MediaRequestStatus.APPROVED, }) .andWhere( '((request.is4k = false AND media.status != :availableStatus) OR (request.is4k = true AND media.status4k != :availableStatus))', { availableStatus: MediaStatus.AVAILABLE, } ) .getCount(); const availableCount = await query .where('request.status = :requestStatus', { requestStatus: MediaRequestStatus.APPROVED, }) .andWhere( '((request.is4k = false AND media.status = :availableStatus) OR (request.is4k = true AND media.status4k = :availableStatus))', { availableStatus: MediaStatus.AVAILABLE, } ) .getCount(); const completedCount = await query .where('request.status = :requestStatus', { requestStatus: MediaRequestStatus.COMPLETED, }) .getCount(); return res.status(200).json({ total: totalCount, movie: movieCount, tv: tvCount, pending: pendingCount, approved: approvedCount, declined: declinedCount, processing: processingCount, available: availableCount, completed: completedCount, }); } catch (e) { logger.error('Something went wrong retrieving request counts', { label: 'API', errorMessage: e.message, }); next({ status: 500, message: 'Unable to retrieve request counts.' }); } }); requestRoutes.get('/:requestId', async (req, res, next) => { const requestRepository = getRepository(MediaRequest); try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, relations: { requestedBy: true, modifiedBy: true }, }); if ( request.requestedBy.id !== req.user?.id && !req.user?.hasPermission( [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], { type: 'or' } ) ) { return next({ status: 403, message: 'You do not have permission to view this request.', }); } return res.status(200).json(request); } catch (e) { logger.debug('Failed to retrieve request.', { label: 'API', errorMessage: e.message, }); next({ status: 404, message: 'Request not found.' }); } }); requestRoutes.put<{ requestId: string }>( '/:requestId', async (req, res, next) => { const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); try { const request = await requestRepository.findOne({ where: { id: Number(req.params.requestId), }, }); if (!request) { return next({ status: 404, message: 'Request not found.' }); } if ( (request.requestedBy.id !== req.user?.id || (req.body.mediaType !== 'tv' && !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) && !req.user?.hasPermission(Permission.MANAGE_REQUESTS) ) { return next({ status: 403, message: 'You do not have permission to modify this request.', }); } let requestUser = request.requestedBy; if ( req.body.userId && req.body.userId !== request.requestedBy.id && !req.user?.hasPermission([ Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS, ]) ) { return next({ status: 403, message: 'You do not have permission to modify the request user.', }); } else if (req.body.userId) { requestUser = await userRepository.findOneOrFail({ where: { id: req.body.userId }, }); } if (req.body.mediaType === MediaType.MOVIE) { request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; request.tags = req.body.tags; request.requestedBy = requestUser as User; requestRepository.save(request); } else if (req.body.mediaType === MediaType.TV) { const mediaRepository = getRepository(Media); request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; request.languageProfileId = req.body.languageProfileId; request.tags = req.body.tags; request.requestedBy = requestUser as User; const requestedSeasons = req.body.seasons as number[] | undefined; if (!requestedSeasons || requestedSeasons.length === 0) { throw new Error( 'Missing seasons. If you want to cancel a series request, use the DELETE method.' ); } // Get existing media so we can work with all the requests const media = await mediaRepository.findOneOrFail({ where: { tmdbId: request.media.tmdbId, mediaType: MediaType.TV }, relations: { requests: true }, }); // Get all requested seasons that are not part of this request we are editing const existingSeasons = media.requests .filter( (r) => r.is4k === request.is4k && r.id !== request.id && r.status !== MediaRequestStatus.DECLINED && r.status !== MediaRequestStatus.COMPLETED ) .reduce((seasons, r) => { const combinedSeasons = r.seasons.map( (season) => season.seasonNumber ); return [...seasons, ...combinedSeasons]; }, [] as number[]); const filteredSeasons = requestedSeasons.filter( (rs) => !existingSeasons.includes(rs) ); if (filteredSeasons.length === 0) { return next({ status: 202, message: 'No seasons available to request', }); } const newSeasons = requestedSeasons.filter( (sn) => !request.seasons.map((s) => s.seasonNumber).includes(sn) ); request.seasons = request.seasons.filter((rs) => filteredSeasons.includes(rs.seasonNumber) ); if (newSeasons.length > 0) { logger.debug('Adding new seasons to request', { label: 'Media Request', newSeasons, }); request.seasons.push( ...newSeasons.map( (ns) => new SeasonRequest({ seasonNumber: ns, status: MediaRequestStatus.PENDING, }) ) ); } await requestRepository.save(request); } return res.status(200).json(request); } catch (e) { next({ status: 500, message: e.message }); } } ); requestRoutes.delete('/:requestId', async (req, res, next) => { const requestRepository = getRepository(MediaRequest); try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, relations: { requestedBy: true, modifiedBy: true }, }); if ( !req.user?.hasPermission(Permission.MANAGE_REQUESTS) && request.requestedBy.id !== req.user?.id && request.status !== 1 ) { return next({ status: 401, message: 'You do not have permission to delete this request.', }); } await requestRepository.remove(request); return res.status(204).send(); } catch (e) { logger.error('Something went wrong deleting a request.', { label: 'API', errorMessage: e.message, }); next({ status: 404, message: 'Request not found.' }); } }); requestRoutes.post<{ requestId: string; }>( '/:requestId/retry', isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const requestRepository = getRepository(MediaRequest); try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, relations: { requestedBy: true, modifiedBy: true }, }); // this also triggers updating the parent media's status & sending to *arr request.status = MediaRequestStatus.APPROVED; await requestRepository.save(request); return res.status(200).json(request); } catch (e) { logger.error('Error processing request retry', { label: 'Media Request', message: e.message, }); next({ status: 404, message: 'Request not found.' }); } } ); requestRoutes.post<{ requestId: string; status: 'pending' | 'approve' | 'decline'; }>( '/:requestId/:status', isAuthenticated(Permission.MANAGE_REQUESTS), async (req, res, next) => { const requestRepository = getRepository(MediaRequest); try { const request = await requestRepository.findOneOrFail({ where: { id: Number(req.params.requestId) }, relations: { requestedBy: true, modifiedBy: true }, }); let newStatus: MediaRequestStatus; switch (req.params.status) { case 'pending': newStatus = MediaRequestStatus.PENDING; break; case 'approve': newStatus = MediaRequestStatus.APPROVED; break; case 'decline': newStatus = MediaRequestStatus.DECLINED; break; } request.status = newStatus; request.modifiedBy = req.user; await requestRepository.save(request); return res.status(200).json(request); } catch (e) { logger.error('Error processing request update', { label: 'Media Request', message: e.message, }); next({ status: 404, message: 'Request not found.' }); } } ); export default requestRoutes; ================================================ FILE: server/routes/search.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; import Media from '@server/entity/Media'; import { findSearchProvider } from '@server/lib/search'; import logger from '@server/logger'; import { mapSearchResults } from '@server/models/Search'; import { Router } from 'express'; const searchRoutes = Router(); searchRoutes.get('/', async (req, res, next) => { const queryString = req.query.query as string; const searchProvider = findSearchProvider(queryString.toLowerCase()); let results: TmdbSearchMultiResponse; try { if (searchProvider) { const [id] = queryString .toLowerCase() .match(searchProvider.pattern) as RegExpMatchArray; results = await searchProvider.search({ id, language: (req.query.language as string) ?? req.locale, query: queryString, }); } else { const tmdb = new TheMovieDb(); results = await tmdb.searchMulti({ query: queryString, page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); } const media = await Media.getRelatedMedia( req.user, results.results.map((result) => ({ tmdbId: result.id, mediaType: result.media_type, })) ); return res.status(200).json({ page: results.page, totalPages: results.total_pages, totalResults: results.total_results, results: mapSearchResults(results.results, media), }); } catch (e) { logger.debug('Something went wrong retrieving search results', { label: 'API', errorMessage: e.message, query: req.query.query, }); return next({ status: 500, message: 'Unable to retrieve search results.', }); } }); searchRoutes.get('/keyword', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const results = await tmdb.searchKeyword({ query: req.query.query as string, page: Number(req.query.page), }); return res.status(200).json(results); } catch (e) { logger.debug('Something went wrong retrieving keyword search results', { label: 'API', errorMessage: e.message, query: req.query.query, }); return next({ status: 500, message: 'Unable to retrieve keyword search results.', }); } }); searchRoutes.get('/company', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const results = await tmdb.searchCompany({ query: req.query.query as string, page: Number(req.query.page), }); return res.status(200).json(results); } catch (e) { logger.debug('Something went wrong retrieving company search results', { label: 'API', errorMessage: e.message, query: req.query.query, }); return next({ status: 500, message: 'Unable to retrieve company search results.', }); } }); export default searchRoutes; ================================================ FILE: server/routes/service.ts ================================================ import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import type { ServiceCommonServer, ServiceCommonServerWithDetails, } from '@server/interfaces/api/serviceInterfaces'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { Router } from 'express'; const serviceRoutes = Router(); serviceRoutes.get('/radarr', async (req, res) => { const settings = getSettings(); const filteredRadarrServers: ServiceCommonServer[] = settings.radarr.map( (radarr) => ({ id: radarr.id, name: radarr.name, is4k: radarr.is4k, isDefault: radarr.isDefault, activeDirectory: radarr.activeDirectory, activeProfileId: radarr.activeProfileId, activeTags: radarr.tags ?? [], }) ); return res.status(200).json(filteredRadarrServers); }); serviceRoutes.get<{ radarrId: string }>( '/radarr/:radarrId', async (req, res, next) => { const settings = getSettings(); const radarrSettings = settings.radarr.find( (radarr) => radarr.id === Number(req.params.radarrId) ); if (!radarrSettings) { return next({ status: 404, message: 'Radarr server with provided ID does not exist.', }); } const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); const profiles = await radarr.getProfiles(); const rootFolders = await radarr.getRootFolders(); const tags = await radarr.getTags(); return res.status(200).json({ server: { id: radarrSettings.id, name: radarrSettings.name, is4k: radarrSettings.is4k, isDefault: radarrSettings.isDefault, activeDirectory: radarrSettings.activeDirectory, activeProfileId: radarrSettings.activeProfileId, activeTags: radarrSettings.tags, }, profiles: profiles.map((profile) => ({ id: profile.id, name: profile.name, })), rootFolders: rootFolders.map((folder) => ({ id: folder.id, freeSpace: folder.freeSpace, path: folder.path, totalSpace: folder.totalSpace, })), tags, } as ServiceCommonServerWithDetails); } ); serviceRoutes.get('/sonarr', async (req, res) => { const settings = getSettings(); const filteredSonarrServers: ServiceCommonServer[] = settings.sonarr.map( (sonarr) => ({ id: sonarr.id, name: sonarr.name, is4k: sonarr.is4k, isDefault: sonarr.isDefault, activeDirectory: sonarr.activeDirectory, activeProfileId: sonarr.activeProfileId, activeAnimeProfileId: sonarr.activeAnimeProfileId, activeAnimeDirectory: sonarr.activeAnimeDirectory, activeLanguageProfileId: sonarr.activeLanguageProfileId, activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId, activeTags: [], }) ); return res.status(200).json(filteredSonarrServers); }); serviceRoutes.get<{ sonarrId: string }>( '/sonarr/:sonarrId', async (req, res, next) => { const settings = getSettings(); const sonarrSettings = settings.sonarr.find( (sonarr) => sonarr.id === Number(req.params.sonarrId) ); if (!sonarrSettings) { return next({ status: 404, message: 'Sonarr server with provided ID does not exist.', }); } const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); try { const systemStatus = await sonarr.getSystemStatus(); const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]); const profiles = await sonarr.getProfiles(); const rootFolders = await sonarr.getRootFolders(); const languageProfiles = sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null; const tags = await sonarr.getTags(); return res.status(200).json({ server: { id: sonarrSettings.id, name: sonarrSettings.name, is4k: sonarrSettings.is4k, isDefault: sonarrSettings.isDefault, activeDirectory: sonarrSettings.activeDirectory, activeProfileId: sonarrSettings.activeProfileId, activeAnimeProfileId: sonarrSettings.activeAnimeProfileId, activeAnimeDirectory: sonarrSettings.activeAnimeDirectory, activeLanguageProfileId: sonarrSettings.activeLanguageProfileId, activeAnimeLanguageProfileId: sonarrSettings.activeAnimeLanguageProfileId, activeTags: sonarrSettings.tags, activeAnimeTags: sonarrSettings.animeTags, }, profiles: profiles.map((profile) => ({ id: profile.id, name: profile.name, })), rootFolders: rootFolders.map((folder) => ({ id: folder.id, freeSpace: folder.freeSpace, path: folder.path, totalSpace: folder.totalSpace, })), languageProfiles: languageProfiles, tags, } as ServiceCommonServerWithDetails); } catch (e) { next({ status: 500, message: e.message }); } } ); serviceRoutes.get<{ tmdbId: string }>( '/sonarr/lookup/:tmdbId', async (req, res, next) => { const settings = getSettings(); const tmdb = new TheMovieDb(); const sonarrSettings = settings.sonarr[0]; if (!sonarrSettings) { logger.error('No sonarr server has been setup', { label: 'Media Request', }); return next({ status: 404, message: 'No sonarr server has been setup', }); } const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); try { const tv = await tmdb.getTvShow({ tvId: Number(req.params.tmdbId), language: 'en', }); const response = await sonarr.getSeriesByTitle(tv.name); return res.status(200).json(response); } catch (e) { logger.error('Failed to fetch tvdb search results', { label: 'Media Request', message: e.message, }); return next({ status: 500, message: 'Something went wrong trying to fetch series information', }); } } ); export default serviceRoutes; ================================================ FILE: server/routes/settings/discover.ts ================================================ import { getRepository } from '@server/datasource'; import DiscoverSlider from '@server/entity/DiscoverSlider'; import logger from '@server/logger'; import { Router } from 'express'; const discoverSettingRoutes = Router(); discoverSettingRoutes.post('/', async (req, res) => { const sliderRepository = getRepository(DiscoverSlider); const sliders = req.body as DiscoverSlider[]; if (!Array.isArray(sliders)) { return res.status(400).json({ message: 'Invalid request body.' }); } for (let x = 0; x < sliders.length; x++) { const slider = sliders[x]; const existingSlider = await sliderRepository.findOne({ where: { id: slider.id, }, }); if (existingSlider && slider.id) { existingSlider.enabled = slider.enabled; existingSlider.order = x; // Only allow changes to the following when the slider is not built in if (!existingSlider.isBuiltIn) { existingSlider.title = slider.title; existingSlider.data = slider.data; existingSlider.type = slider.type; } await sliderRepository.save(existingSlider); } else { const newSlider = new DiscoverSlider({ isBuiltIn: false, data: slider.data, title: slider.title, enabled: slider.enabled, order: x, type: slider.type, }); await sliderRepository.save(newSlider); } } return res.json(sliders); }); discoverSettingRoutes.post('/add', async (req, res) => { const sliderRepository = getRepository(DiscoverSlider); const slider = req.body as DiscoverSlider; const newSlider = new DiscoverSlider({ isBuiltIn: false, data: slider.data, title: slider.title, enabled: false, order: -1, type: slider.type, }); await sliderRepository.save(newSlider); return res.json(newSlider); }); discoverSettingRoutes.get('/reset', async (_req, res) => { const sliderRepository = getRepository(DiscoverSlider); await sliderRepository.clear(); await DiscoverSlider.bootstrapSliders(); return res.status(204).send(); }); discoverSettingRoutes.put('/:sliderId', async (req, res, next) => { const sliderRepository = getRepository(DiscoverSlider); const slider = req.body as DiscoverSlider; try { const existingSlider = await sliderRepository.findOneOrFail({ where: { id: Number(req.params.sliderId), }, }); // Only allow changes to the following when the slider is not built in if (!existingSlider.isBuiltIn) { existingSlider.title = slider.title; existingSlider.data = slider.data; existingSlider.type = slider.type; } await sliderRepository.save(existingSlider); return res.status(200).json(existingSlider); } catch (e) { logger.error('Something went wrong updating a slider.', { label: 'API', errorMessage: e.message, }); next({ status: 404, message: 'Slider not found or cannot be updated.' }); } }); discoverSettingRoutes.delete('/:sliderId', async (req, res, next) => { const sliderRepository = getRepository(DiscoverSlider); try { const slider = await sliderRepository.findOneOrFail({ where: { id: Number(req.params.sliderId), isBuiltIn: false }, }); await sliderRepository.remove(slider); return res.status(204).send(); } catch (e) { logger.error('Something went wrong deleting a slider.', { label: 'API', errorMessage: e.message, }); next({ status: 404, message: 'Slider not found or cannot be deleted.' }); } }); export default discoverSettingRoutes; ================================================ FILE: server/routes/settings/index.ts ================================================ import JellyfinAPI from '@server/api/jellyfin'; import PlexAPI from '@server/api/plexapi'; import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { ApiErrorCode } from '@server/constants/error'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import type { PlexConnection } from '@server/interfaces/api/plexInterfaces'; import type { LogMessage, LogsResultsResponse, SettingsAboutResponse, } from '@server/interfaces/api/settingsInterfaces'; import { scheduledJobs } from '@server/job/schedule'; import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; import ImageProxy from '@server/lib/imageproxy'; import { Permission } from '@server/lib/permissions'; import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin'; import { plexFullScanner } from '@server/lib/scanners/plex'; import type { JobId, Library, MainSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import discoverSettingRoutes from '@server/routes/settings/discover'; import { ApiError } from '@server/types/error'; import { appDataPath } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; import { dnsCache } from '@server/utils/dnsCache'; import { getHostname } from '@server/utils/getHostname'; import type { DnsEntries, DnsStats } from 'dns-caching'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; import semver from 'semver'; import { URL } from 'url'; import metadataRoutes from './metadata'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; import sonarrRoutes from './sonarr'; const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/metadatas', metadataRoutes); const filteredMainSettings = ( user: User, main: MainSettings ): Partial => { if (!user?.hasPermission(Permission.ADMIN)) { return omit(main, 'apiKey'); } return main; }; settingsRoutes.get('/main', (req, res, next) => { const settings = getSettings(); if (!req.user) { return next({ status: 400, message: 'User missing from request.' }); } res.status(200).json(filteredMainSettings(req.user, settings.main)); }); settingsRoutes.post('/main', async (req, res) => { const settings = getSettings(); settings.main = merge(settings.main, req.body); await settings.save(); return res.status(200).json(settings.main); }); settingsRoutes.get('/network', (req, res) => { const settings = getSettings(); res.status(200).json(settings.network); }); settingsRoutes.post('/network', async (req, res) => { const settings = getSettings(); settings.network = merge(settings.network, req.body); await settings.save(); return res.status(200).json(settings.network); }); settingsRoutes.post('/main/regenerate', async (req, res, next) => { const settings = getSettings(); const main = await settings.regenerateApiKey(); if (!req.user) { return next({ status: 500, message: 'User missing from request.' }); } return res.status(200).json(filteredMainSettings(req.user, main)); }); settingsRoutes.get('/plex', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.plex); }); settingsRoutes.post('/plex', async (req, res, next) => { const userRepository = getRepository(User); const settings = getSettings(); try { const admin = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); Object.assign(settings.plex, req.body); const plexClient = new PlexAPI({ plexToken: admin.plexToken }); const result = await plexClient.getStatus(); if (!result?.MediaContainer?.machineIdentifier) { throw new Error('Server not found'); } settings.plex.machineId = result.MediaContainer.machineIdentifier; settings.plex.name = result.MediaContainer.friendlyName; await settings.save(); } catch (e) { logger.error('Something went wrong testing Plex connection', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to connect to Plex.', }); } return res.status(200).json(settings.plex); }); settingsRoutes.get('/plex/devices/servers', async (req, res, next) => { const userRepository = getRepository(User); try { const admin = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); const plexTvClient = admin.plexToken ? new PlexTvAPI(admin.plexToken) : null; const devices = (await plexTvClient?.getDevices())?.filter((device) => { return device.provides.includes('server') && device.owned; }); const settings = getSettings(); if (devices) { await Promise.all( devices.map(async (device) => { const plexDirectConnections: PlexConnection[] = []; device.connection.forEach((connection) => { const url = new URL(connection.uri); if (url.hostname !== connection.address) { const plexDirectConnection = { ...connection }; plexDirectConnection.address = url.hostname; plexDirectConnections.push(plexDirectConnection); // Connect to IP addresses over HTTP connection.protocol = 'http'; } }); plexDirectConnections.forEach((plexDirectConnection) => { device.connection.push(plexDirectConnection); }); await Promise.all( device.connection.map(async (connection) => { const plexDeviceSettings = { ...settings.plex, ip: connection.address, port: connection.port, useSsl: connection.protocol === 'https', }; const plexClient = new PlexAPI({ plexToken: admin.plexToken, plexSettings: plexDeviceSettings, timeout: 5000, }); try { await plexClient.getStatus(); connection.status = 200; connection.message = 'OK'; } catch (e) { connection.status = 500; connection.message = e.message.split(':')[0]; } }) ); }) ); } return res.status(200).json(devices); } catch (e) { logger.error('Something went wrong retrieving Plex server list', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to retrieve Plex server list.', }); } }); settingsRoutes.get('/plex/library', async (req, res) => { const settings = getSettings(); if (req.query.sync) { const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); const plexapi = new PlexAPI({ plexToken: admin.plexToken }); await plexapi.syncLibraries(); } const enabledLibraries = req.query.enable ? (req.query.enable as string).split(',') : []; settings.plex.libraries = settings.plex.libraries.map((library) => ({ ...library, enabled: enabledLibraries.includes(library.id), })); await settings.save(); return res.status(200).json(settings.plex.libraries); }); settingsRoutes.get('/plex/sync', (_req, res) => { return res.status(200).json(plexFullScanner.status()); }); settingsRoutes.post('/plex/sync', (req, res) => { if (req.body.cancel) { plexFullScanner.cancel(); } else if (req.body.start) { plexFullScanner.run(); } return res.status(200).json(plexFullScanner.status()); }); settingsRoutes.get('/jellyfin', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.jellyfin); }); settingsRoutes.post('/jellyfin', async (req, res, next) => { const userRepository = getRepository(User); const settings = getSettings(); try { const admin = await userRepository.findOneOrFail({ where: { id: 1 }, select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], order: { id: 'ASC' }, }); const tempJellyfinSettings = { ...settings.jellyfin, ...req.body }; const jellyfinClient = new JellyfinAPI( getHostname(tempJellyfinSettings), tempJellyfinSettings.apiKey, admin.jellyfinDeviceId ?? '' ); const result = await jellyfinClient.getSystemInfo(); if (!result?.Id) { throw new ApiError(result?.status, ApiErrorCode.InvalidUrl); } Object.assign(settings.jellyfin, req.body); settings.jellyfin.serverId = result.Id; settings.jellyfin.name = result.ServerName; await settings.save(); } catch (e) { if (e instanceof ApiError) { logger.error('Something went wrong testing Jellyfin connection', { label: 'API', status: e.statusCode, errorMessage: ApiErrorCode.InvalidUrl, }); return next({ status: e.statusCode, message: ApiErrorCode.InvalidUrl, }); } else { logger.error('Something went wrong', { label: 'API', errorMessage: e.message, }); return next({ status: e.statusCode ?? 500, message: ApiErrorCode.Unknown, }); } } return res.status(200).json(settings.jellyfin); }); settingsRoutes.get('/jellyfin/library', async (req, res, next) => { const settings = getSettings(); if (req.query.sync) { const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], where: { id: 1 }, order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( getHostname(), settings.jellyfin.apiKey, admin.jellyfinDeviceId ?? '' ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const libraries = await jellyfinClient.getLibraries(); if (libraries.length === 0) { // Check if no libraries are found due to the fallback to user views // This only affects LDAP users const account = await jellyfinClient.getUser(); // Automatic Library grouping is not supported when user views are used to get library if (account.Configuration.GroupedFolders?.length > 0) { return next({ status: 501, message: ApiErrorCode.SyncErrorGroupedFolders, }); } return next({ status: 404, message: ApiErrorCode.SyncErrorNoLibraries }); } const newLibraries: Library[] = libraries.map((library) => { const existing = settings.jellyfin.libraries.find( (l) => l.id === library.key && l.name === library.title ); return { id: library.key, name: library.title, enabled: existing?.enabled ?? false, type: library.type, }; }); settings.jellyfin.libraries = newLibraries; } const enabledLibraries = req.query.enable ? (req.query.enable as string).split(',') : []; settings.jellyfin.libraries = settings.jellyfin.libraries.map((library) => ({ ...library, enabled: enabledLibraries.includes(library.id), })); await settings.save(); return res.status(200).json(settings.jellyfin.libraries); }); settingsRoutes.get('/jellyfin/users', async (req, res) => { const settings = getSettings(); const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], where: { id: 1 }, order: { id: 'ASC' }, }); const jellyfinClient = new JellyfinAPI( getHostname(), settings.jellyfin.apiKey, admin.jellyfinDeviceId ?? '' ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const resp = await jellyfinClient.getUsers(); const users = resp.users.map((user) => ({ username: user.Name, id: user.Id, thumb: `/avatarproxy/${user.Id}`, email: user.Name, })); return res.status(200).json(users); }); settingsRoutes.get('/jellyfin/sync', (_req, res) => { return res.status(200).json(jellyfinFullScanner.status()); }); settingsRoutes.post('/jellyfin/sync', (req, res) => { if (req.body.cancel) { jellyfinFullScanner.cancel(); } else if (req.body.start) { jellyfinFullScanner.run(); } return res.status(200).json(jellyfinFullScanner.status()); }); settingsRoutes.get('/tautulli', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.tautulli); }); settingsRoutes.post('/tautulli', async (req, res, next) => { const settings = getSettings(); Object.assign(settings.tautulli, req.body); if (settings.tautulli.hostname) { try { const tautulliClient = new TautulliAPI(settings.tautulli); const result = await tautulliClient.getInfo(); if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) { throw new Error('Tautulli version not supported'); } await settings.save(); } catch (e) { logger.error('Something went wrong testing Tautulli connection', { label: 'API', errorMessage: e.message, }); return next({ status: 500, message: 'Unable to connect to Tautulli.', }); } } return res.status(200).json(settings.tautulli); }); settingsRoutes.get( '/plex/users', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { const userRepository = getRepository(User); const qb = userRepository.createQueryBuilder('user'); try { const admin = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); const plexApi = new PlexTvAPI(admin.plexToken ?? ''); const plexUsers = (await plexApi.getUsers()).MediaContainer.User.map( (user) => user.$ ).filter((user) => user.email); const unimportedPlexUsers: { id: string; title: string; username: string; email: string; thumb: string; }[] = []; const plexIds = plexUsers.map((plexUser) => plexUser.id); const plexEmails = plexUsers.map((plexUser) => plexUser.email.toLowerCase() ); if (!plexIds.length) plexIds.push('-1'); if (!plexEmails.length) plexEmails.push('@'); const existingUsers = await qb .where('user.plexId IN (:...plexIds)', { plexIds }) .orWhere('user.email IN (:...plexEmails)', { plexEmails }) .getMany(); await Promise.all( plexUsers.map(async (plexUser) => { if ( !existingUsers.find( (user) => user.plexId === parseInt(plexUser.id) || user.email === plexUser.email.toLowerCase() ) && (await plexApi.checkUserAccess(parseInt(plexUser.id))) ) { unimportedPlexUsers.push(plexUser); } }) ); return res.status(200).json(sortBy(unimportedPlexUsers, 'username')); } catch (e) { logger.error('Something went wrong getting unimported Plex users', { label: 'API', errorMessage: e.message, }); next({ status: 500, message: 'Unable to retrieve unimported Plex users.', }); } } ); settingsRoutes.get( '/logs', rateLimit({ windowMs: 60 * 1000, max: 50 }), (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 25; const skip = req.query.skip ? Number(req.query.skip) : 0; const search = (req.query.search as string) ?? ''; const searchRegexp = new RegExp(escapeRegExp(search), 'i'); let filter: string[] = []; switch (req.query.filter) { case 'debug': filter.push('debug'); // falls through case 'info': filter.push('info'); // falls through case 'warn': filter.push('warn'); // falls through case 'error': filter.push('error'); break; default: filter = ['debug', 'info', 'warn', 'error']; } const logFile = process.env.CONFIG_DIRECTORY ? `${process.env.CONFIG_DIRECTORY}/logs/.machinelogs.json` : path.join(__dirname, '../../../config/logs/.machinelogs.json'); const logs: LogMessage[] = []; const logMessageProperties = [ 'timestamp', 'level', 'label', 'message', 'data', ]; const deepValueStrings = (obj: Record): string[] => { const values = []; for (const val of Object.values(obj)) { if (typeof val === 'string') { values.push(val); } else if (typeof val === 'number') { values.push(val.toString()); } else if (val !== null && typeof val === 'object') { values.push(...deepValueStrings(val as Record)); } } return values; }; try { fs.readFileSync(logFile, 'utf-8') .split('\n') .forEach((line) => { if (!line.length) return; const logMessage = JSON.parse(line); if (!filter.includes(logMessage.level)) { return; } if ( !Object.keys(logMessage).every((key) => logMessageProperties.includes(key) ) ) { Object.keys(logMessage) .filter((prop) => !logMessageProperties.includes(prop)) .forEach((prop) => { set(logMessage, `data.${prop}`, logMessage[prop]); }); } if (req.query.search) { if ( // label and data are sometimes undefined !searchRegexp.test(logMessage.label ?? '') && !searchRegexp.test(logMessage.message) && !deepValueStrings(logMessage.data ?? {}).some((val) => searchRegexp.test(val) ) ) { return; } } logs.push(logMessage); }); const displayedLogs = logs.reverse().slice(skip, skip + pageSize); return res.status(200).json({ pageInfo: { pages: Math.ceil(logs.length / pageSize), pageSize, results: logs.length, page: Math.ceil(skip / pageSize) + 1, }, results: displayedLogs, } as LogsResultsResponse); } catch (error) { logger.error('Something went wrong while retrieving logs', { label: 'Logs', errorMessage: error.message, }); return next({ status: 500, message: 'Unable to retrieve logs.', }); } } ); settingsRoutes.get('/jobs', (_req, res) => { return res.status(200).json( scheduledJobs.map((job) => ({ id: job.id, name: job.name, type: job.type, interval: job.interval, cronSchedule: job.cronSchedule, nextExecutionTime: job.job.nextInvocation(), running: job.running ? job.running() : false, })) ); }); settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId); if (!scheduledJob) { return next({ status: 404, message: 'Job not found.' }); } scheduledJob.job.invoke(); return res.status(200).json({ id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); }); settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/cancel', (req, res, next) => { const scheduledJob = scheduledJobs.find( (job) => job.id === req.params.jobId ); if (!scheduledJob) { return next({ status: 404, message: 'Job not found.' }); } if (scheduledJob.cancelFn) { scheduledJob.cancelFn(); } return res.status(200).json({ id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); } ); settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/schedule', async (req, res, next) => { const scheduledJob = scheduledJobs.find( (job) => job.id === req.params.jobId ); if (!scheduledJob) { return next({ status: 404, message: 'Job not found.' }); } const result = rescheduleJob(scheduledJob.job, req.body.schedule); const settings = getSettings(); if (result) { settings.jobs[scheduledJob.id].schedule = req.body.schedule; await settings.save(); scheduledJob.cronSchedule = req.body.schedule; return res.status(200).json({ id: scheduledJob.id, name: scheduledJob.name, type: scheduledJob.type, interval: scheduledJob.interval, cronSchedule: scheduledJob.cronSchedule, nextExecutionTime: scheduledJob.job.nextInvocation(), running: scheduledJob.running ? scheduledJob.running() : false, }); } else { return next({ status: 400, message: 'Invalid job schedule.' }); } } ); settingsRoutes.get('/cache', async (_req, res) => { const cacheManagerCaches = cacheManager.getAllCaches(); const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({ id: cache.id, name: cache.name, stats: cache.getStats(), })); const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const avatarImageCache = await ImageProxy.getImageStats('avatar'); const stats: DnsStats | undefined = dnsCache?.getStats(); const entries: DnsEntries | undefined = dnsCache?.getCacheEntries(); return res.status(200).json({ apiCaches, imageCache: { tmdb: tmdbImageCache, avatar: avatarImageCache, }, dnsCache: { stats, entries, }, }); }); settingsRoutes.post<{ cacheId: AvailableCacheIds }>( '/cache/:cacheId/flush', (req, res, next) => { const cache = cacheManager.getCache(req.params.cacheId); if (cache) { cache.flush(); return res.status(204).send(); } next({ status: 404, message: 'Cache not found.' }); } ); settingsRoutes.post<{ dnsEntry: string }>( '/cache/dns/:dnsEntry/flush', (req, res, next) => { const dnsEntry = req.params.dnsEntry; if (dnsCache) { dnsCache.clear(dnsEntry); return res.status(204).send(); } next({ status: 404, message: 'Cache not found.' }); } ); settingsRoutes.post( '/initialize', isAuthenticated(Permission.ADMIN), async (_req, res) => { const settings = getSettings(); settings.public.initialized = true; await settings.save(); return res.status(200).json(settings.public); } ); settingsRoutes.get('/about', async (req, res) => { const mediaRepository = getRepository(Media); const mediaRequestRepository = getRepository(MediaRequest); const totalMediaItems = await mediaRepository.count(); const totalRequests = await mediaRequestRepository.count(); return res.status(200).json({ version: getAppVersion(), totalMediaItems, totalRequests, tz: process.env.TZ, appDataPath: appDataPath(), } as SettingsAboutResponse); }); export default settingsRoutes; ================================================ FILE: server/routes/settings/metadata.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import Tvdb from '@server/api/tvdb'; import { getSettings, MetadataProviderType, type MetadataSettings, } from '@server/lib/settings'; import logger from '@server/logger'; import { Router } from 'express'; function getTestResultString(testValue: number): string { if (testValue === -1) return 'not tested'; if (testValue === 0) return 'failed'; return 'ok'; } const metadataRoutes = Router(); metadataRoutes.get('/', (_req, res) => { const settings = getSettings(); res.status(200).json({ tv: settings.metadataSettings.tv, anime: settings.metadataSettings.anime, }); }); metadataRoutes.put('/', async (req, res) => { const settings = getSettings(); const body = req.body as MetadataSettings; let tvdbTest = -1; let tmdbTest = -1; try { if ( body.tv === MetadataProviderType.TVDB || body.anime === MetadataProviderType.TVDB ) { tvdbTest = 0; const tvdb = await Tvdb.getInstance(); await tvdb.test(); tvdbTest = 1; } } catch (e) { logger.error('Failed to test metadata provider', { label: 'Metadata', message: e.message, }); } try { if ( body.tv === MetadataProviderType.TMDB || body.anime === MetadataProviderType.TMDB ) { tmdbTest = 0; const tmdb = new TheMovieDb(); await tmdb.getTvShow({ tvId: 1054 }); tmdbTest = 1; } } catch (e) { logger.error('Failed to test metadata provider', { label: 'MetadataProvider', message: e.message, }); } // If a test failed, return the test results if (tvdbTest === 0 || tmdbTest === 0) { return res.status(500).json({ success: false, tests: { tvdb: getTestResultString(tvdbTest), tmdb: getTestResultString(tmdbTest), }, }); } settings.metadataSettings = { tv: body.tv, anime: body.anime, }; await settings.save(); res.status(200).json({ success: true, tv: body.tv, anime: body.anime, tests: { tvdb: getTestResultString(tvdbTest), tmdb: getTestResultString(tmdbTest), }, }); }); metadataRoutes.post('/test', async (req, res) => { let tvdbTest = -1; let tmdbTest = -1; try { const body = req.body as { tmdb: boolean; tvdb: boolean }; try { if (body.tmdb) { tmdbTest = 0; const tmdb = new TheMovieDb(); await tmdb.getTvShow({ tvId: 1054 }); tmdbTest = 1; } } catch (e) { logger.error('Failed to test metadata provider', { label: 'MetadataProvider', message: e.message, }); } try { if (body.tvdb) { tvdbTest = 0; const tvdb = await Tvdb.getInstance(); await tvdb.test(); tvdbTest = 1; } } catch (e) { logger.error('Failed to test metadata provider', { label: 'MetadataProvider', message: e.message, }); } const success = !(tvdbTest === 0 || tmdbTest === 0); const statusCode = success ? 200 : 500; return res.status(statusCode).json({ success: success, tests: { tmdb: getTestResultString(tmdbTest), tvdb: getTestResultString(tvdbTest), }, }); } catch (e) { return res.status(500).json({ success: false, tests: { tmdb: getTestResultString(tmdbTest), tvdb: getTestResultString(tvdbTest), }, error: e.message, }); } }); export default metadataRoutes; ================================================ FILE: server/routes/settings/notifications.ts ================================================ import type { User } from '@server/entity/User'; import { Notification } from '@server/lib/notifications'; import type { NotificationAgent } from '@server/lib/notifications/agents/agent'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; import GotifyAgent from '@server/lib/notifications/agents/gotify'; import NtfyAgent from '@server/lib/notifications/agents/ntfy'; import PushbulletAgent from '@server/lib/notifications/agents/pushbullet'; import PushoverAgent from '@server/lib/notifications/agents/pushover'; import SlackAgent from '@server/lib/notifications/agents/slack'; import TelegramAgent from '@server/lib/notifications/agents/telegram'; import WebhookAgent from '@server/lib/notifications/agents/webhook'; import WebPushAgent from '@server/lib/notifications/agents/webpush'; import { getSettings } from '@server/lib/settings'; import { Router } from 'express'; const notificationRoutes = Router(); const sendTestNotification = async (agent: NotificationAgent, user: User) => await agent.send(Notification.TEST_NOTIFICATION, { notifySystem: true, notifyAdmin: false, notifyUser: user, subject: 'Test Notification', message: 'Check check, 1, 2, 3. Are we coming in clear?', }); notificationRoutes.get('/discord', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.discord); }); notificationRoutes.post('/discord', async (req, res) => { const settings = getSettings(); settings.notifications.agents.discord = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.discord); }); notificationRoutes.post('/discord/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const discordAgent = new DiscordAgent(req.body); if (await sendTestNotification(discordAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send Discord notification.', }); } }); notificationRoutes.get('/slack', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.slack); }); notificationRoutes.post('/slack', async (req, res) => { const settings = getSettings(); settings.notifications.agents.slack = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.slack); }); notificationRoutes.post('/slack/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const slackAgent = new SlackAgent(req.body); if (await sendTestNotification(slackAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send Slack notification.', }); } }); notificationRoutes.get('/telegram', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.telegram); }); notificationRoutes.post('/telegram', async (req, res) => { const settings = getSettings(); settings.notifications.agents.telegram = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.telegram); }); notificationRoutes.post('/telegram/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const telegramAgent = new TelegramAgent(req.body); if (await sendTestNotification(telegramAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send Telegram notification.', }); } }); notificationRoutes.get('/pushbullet', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.pushbullet); }); notificationRoutes.post('/pushbullet', async (req, res) => { const settings = getSettings(); settings.notifications.agents.pushbullet = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.pushbullet); }); notificationRoutes.post('/pushbullet/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const pushbulletAgent = new PushbulletAgent(req.body); if (await sendTestNotification(pushbulletAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send Pushbullet notification.', }); } }); notificationRoutes.get('/pushover', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.pushover); }); notificationRoutes.post('/pushover', async (req, res) => { const settings = getSettings(); settings.notifications.agents.pushover = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.pushover); }); notificationRoutes.post('/pushover/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const pushoverAgent = new PushoverAgent(req.body); if (await sendTestNotification(pushoverAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send Pushover notification.', }); } }); notificationRoutes.get('/email', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.email); }); notificationRoutes.post('/email', async (req, res) => { const settings = getSettings(); settings.notifications.agents.email = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.email); }); notificationRoutes.post('/email/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const emailAgent = new EmailAgent(req.body); if (await sendTestNotification(emailAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send email notification.', }); } }); notificationRoutes.get('/webpush', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.webpush); }); notificationRoutes.post('/webpush', async (req, res) => { const settings = getSettings(); settings.notifications.agents.webpush = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.webpush); }); notificationRoutes.post('/webpush/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const webpushAgent = new WebPushAgent(req.body); if (await sendTestNotification(webpushAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send web push notification.', }); } }); notificationRoutes.get('/webhook', (_req, res) => { const settings = getSettings(); const webhookSettings = settings.notifications.agents.webhook; const response: typeof webhookSettings = { enabled: webhookSettings.enabled, embedPoster: webhookSettings.embedPoster, types: webhookSettings.types, options: { ...webhookSettings.options, jsonPayload: JSON.parse( Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString( 'utf8' ) ), customHeaders: webhookSettings.options.customHeaders ?? [], supportVariables: webhookSettings.options.supportVariables ?? false, }, }; res.status(200).json(response); }); notificationRoutes.post('/webhook', async (req, res, next) => { const settings = getSettings(); try { JSON.parse(req.body.options.jsonPayload); settings.notifications.agents.webhook = { enabled: req.body.enabled, embedPoster: req.body.embedPoster, types: req.body.types, options: { jsonPayload: Buffer.from(req.body.options.jsonPayload).toString( 'base64' ), webhookUrl: req.body.options.webhookUrl, authHeader: req.body.options.authHeader, customHeaders: req.body.options.customHeaders ?? [], supportVariables: req.body.options.supportVariables ?? false, }, }; await settings.save(); res.status(200).json(settings.notifications.agents.webhook); } catch (e) { next({ status: 500, message: e.message }); } }); notificationRoutes.post('/webhook/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } try { JSON.parse(req.body.options.jsonPayload); const testBody = { enabled: req.body.enabled, embedPoster: req.body.embedPoster, types: req.body.types, options: { jsonPayload: Buffer.from(req.body.options.jsonPayload).toString( 'base64' ), webhookUrl: req.body.options.webhookUrl, authHeader: req.body.options.authHeader, customHeaders: req.body.options.customHeaders ?? [], supportVariables: req.body.options.supportVariables ?? false, }, }; const webhookAgent = new WebhookAgent(testBody); if (await sendTestNotification(webhookAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send webhook notification.', }); } } catch (e) { next({ status: 500, message: e.message }); } }); notificationRoutes.get('/gotify', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.gotify); }); notificationRoutes.post('/gotify', async (req, res) => { const settings = getSettings(); settings.notifications.agents.gotify = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.gotify); }); notificationRoutes.post('/gotify/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const gotifyAgent = new GotifyAgent(req.body); if (await sendTestNotification(gotifyAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send Gotify notification.', }); } }); notificationRoutes.get('/ntfy', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.notifications.agents.ntfy); }); notificationRoutes.post('/ntfy', async (req, res) => { const settings = getSettings(); settings.notifications.agents.ntfy = req.body; await settings.save(); res.status(200).json(settings.notifications.agents.ntfy); }); notificationRoutes.post('/ntfy/test', async (req, res, next) => { if (!req.user) { return next({ status: 500, message: 'User information is missing from the request.', }); } const ntfyAgent = new NtfyAgent(req.body); if (await sendTestNotification(ntfyAgent, req.user)) { return res.status(204).send(); } else { return next({ status: 500, message: 'Failed to send ntfy notification.', }); } }); export default notificationRoutes; ================================================ FILE: server/routes/settings/radarr.ts ================================================ import RadarrAPI from '@server/api/servarr/radarr'; import type { RadarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { Router } from 'express'; const radarrRoutes = Router(); radarrRoutes.get('/', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.radarr); }); radarrRoutes.post('/', async (req, res) => { const settings = getSettings(); const newRadarr = req.body as RadarrSettings; const lastItem = settings.radarr[settings.radarr.length - 1]; newRadarr.id = lastItem ? lastItem.id + 1 : 0; // If we are setting this as the default, clear any previous defaults for the same type first // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true // and are the default if (req.body.isDefault) { settings.radarr .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) .forEach((radarrInstance) => { radarrInstance.isDefault = false; }); } settings.radarr = [...settings.radarr, newRadarr]; await settings.save(); return res.status(201).json(newRadarr); }); radarrRoutes.post< undefined, Record, RadarrSettings & { tagLabel?: string } >('/test', async (req, res, next) => { try { const radarr = new RadarrAPI({ apiKey: req.body.apiKey, url: RadarrAPI.buildUrl(req.body, '/api/v3'), }); const urlBase = await radarr .getSystemStatus() .then((value) => value.urlBase) .catch(() => req.body.baseUrl); const profiles = await radarr.getProfiles(); const folders = await radarr.getRootFolders(); const tags = await radarr.getTags(); return res.status(200).json({ profiles, rootFolders: folders.map((folder) => ({ id: folder.id, path: folder.path, })), tags, urlBase, }); } catch (e) { logger.error('Failed to test Radarr', { label: 'Radarr', message: e.message, }); next({ status: 500, message: 'Failed to connect to Radarr' }); } }); radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( '/:id', async (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( (r) => r.id === Number(req.params.id) ); if (radarrIndex === -1) { return next({ status: '404', message: 'Settings instance not found' }); } // If we are setting this as the default, clear any previous defaults for the same type first // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true // and are the default if (req.body.isDefault) { settings.radarr .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) .forEach((radarrInstance) => { radarrInstance.isDefault = false; }); } settings.radarr[radarrIndex] = { ...req.body, id: Number(req.params.id), } as RadarrSettings; await settings.save(); return res.status(200).json(settings.radarr[radarrIndex]); } ); radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => { const settings = getSettings(); const radarrSettings = settings.radarr.find( (r) => r.id === Number(req.params.id) ); if (!radarrSettings) { return next({ status: '404', message: 'Settings instance not found' }); } const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); const profiles = await radarr.getProfiles(); return res.status(200).json( profiles.map((profile) => ({ id: profile.id, name: profile.name, })) ); }); radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( (r) => r.id === Number(req.params.id) ); if (radarrIndex === -1) { return next({ status: '404', message: 'Settings instance not found' }); } const removed = settings.radarr.splice(radarrIndex, 1); await settings.save(); return res.status(200).json(removed[0]); }); export default radarrRoutes; ================================================ FILE: server/routes/settings/sonarr.ts ================================================ import SonarrAPI from '@server/api/servarr/sonarr'; import type { SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { Router } from 'express'; const sonarrRoutes = Router(); sonarrRoutes.get('/', (_req, res) => { const settings = getSettings(); res.status(200).json(settings.sonarr); }); sonarrRoutes.post('/', async (req, res) => { const settings = getSettings(); const newSonarr = req.body as SonarrSettings; const lastItem = settings.sonarr[settings.sonarr.length - 1]; newSonarr.id = lastItem ? lastItem.id + 1 : 0; // If we are setting this as the default, clear any previous defaults for the same type first // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true // and are the default if (req.body.isDefault) { settings.sonarr .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) .forEach((sonarrInstance) => { sonarrInstance.isDefault = false; }); } settings.sonarr = [...settings.sonarr, newSonarr]; await settings.save(); return res.status(201).json(newSonarr); }); sonarrRoutes.post('/test', async (req, res, next) => { try { const sonarr = new SonarrAPI({ apiKey: req.body.apiKey, url: SonarrAPI.buildUrl(req.body, '/api/v3'), }); const systemStatus = await sonarr.getSystemStatus(); const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]); const urlBase = systemStatus.urlBase; const profiles = await sonarr.getProfiles(); const folders = await sonarr.getRootFolders(); const languageProfiles = sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null; const tags = await sonarr.getTags(); return res.status(200).json({ profiles, rootFolders: folders.map((folder) => ({ id: folder.id, path: folder.path, })), languageProfiles, tags, urlBase, }); } catch (e) { logger.error('Failed to test Sonarr', { label: 'Sonarr', message: e.message, }); next({ status: 500, message: 'Failed to connect to Sonarr' }); } }); sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => { const settings = getSettings(); const sonarrIndex = settings.sonarr.findIndex( (r) => r.id === Number(req.params.id) ); if (sonarrIndex === -1) { return res .status(404) .json({ status: '404', message: 'Settings instance not found' }); } // If we are setting this as the default, clear any previous defaults for the same type first // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true // and are the default if (req.body.isDefault) { settings.sonarr .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) .forEach((sonarrInstance) => { sonarrInstance.isDefault = false; }); } settings.sonarr[sonarrIndex] = { ...req.body, id: Number(req.params.id), } as SonarrSettings; await settings.save(); return res.status(200).json(settings.sonarr[sonarrIndex]); }); sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => { const settings = getSettings(); const sonarrIndex = settings.sonarr.findIndex( (r) => r.id === Number(req.params.id) ); if (sonarrIndex === -1) { return res .status(404) .json({ status: '404', message: 'Settings instance not found' }); } const removed = settings.sonarr.splice(sonarrIndex, 1); await settings.save(); return res.status(200).json(removed[0]); }); export default sonarrRoutes; ================================================ FILE: server/routes/tv.ts ================================================ import { getMetadataProvider } from '@server/api/metadata'; import RottenTomatoes from '@server/api/rating/rottentomatoes'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; import { Router } from 'express'; const tvRoutes = Router(); tvRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const tmdbTv = await tmdb.getTvShow({ tvId: Number(req.params.id), }); const metadataProvider = tmdbTv.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID ) ? await getMetadataProvider('anime') : await getMetadataProvider('tv'); const tv = await metadataProvider.getTvShow({ tvId: Number(req.params.id), language: (req.query.language as string) ?? req.locale, }); const media = await Media.getMedia(tv.id, MediaType.TV); const onUserWatchlist = await getRepository(Watchlist).exist({ where: { tmdbId: Number(req.params.id), mediaType: MediaType.TV, requestedBy: { id: req.user?.id, }, }, }); const data = mapTvDetails(tv, media, onUserWatchlist); // TMDB issue where it doesnt fallback to English when no overview is available in requested locale. if (!data.overview) { const tvEnglish = await metadataProvider.getTvShow({ tvId: Number(req.params.id), }); data.overview = tvEnglish.overview; } return res.status(200).json(data); } catch (e) { logger.debug('Something went wrong retrieving series', { label: 'API', errorMessage: e.message, tvId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve series.', }); } }); tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { try { const tmdb = new TheMovieDb(); const tmdbTv = await tmdb.getTvShow({ tvId: Number(req.params.id), }); const metadataProvider = tmdbTv.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID ) ? await getMetadataProvider('anime') : await getMetadataProvider('tv'); const season = await metadataProvider.getTvSeason({ tvId: Number(req.params.id), seasonNumber: Number(req.params.seasonNumber), language: (req.query.language as string) ?? req.locale, }); return res.status(200).json(mapSeasonWithEpisodes(season)); } catch (e) { logger.debug('Something went wrong retrieving season', { label: 'API', errorMessage: e.message, tvId: req.params.id, seasonNumber: req.params.seasonNumber, }); return next({ status: 500, message: 'Unable to retrieve season.', }); } }); tvRoutes.get('/:id/recommendations', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const results = await tmdb.getTvRecommendations({ tvId: Number(req.params.id), page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); const media = await Media.getRelatedMedia( req.user, results.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })) ); return res.status(200).json({ page: results.page, totalPages: results.total_pages, totalResults: results.total_results, results: results.results.map((result) => mapTvResult( result, media.find( (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving series recommendations', { label: 'API', errorMessage: e.message, tvId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve series recommendations.', }); } }); tvRoutes.get('/:id/similar', async (req, res, next) => { const tmdb = new TheMovieDb(); try { const results = await tmdb.getTvSimilar({ tvId: Number(req.params.id), page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); const media = await Media.getRelatedMedia( req.user, results.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })) ); return res.status(200).json({ page: results.page, totalPages: results.total_pages, totalResults: results.total_results, results: results.results.map((result) => mapTvResult( result, media.find( (req) => req.tmdbId === result.id && req.mediaType === MediaType.TV ) ) ), }); } catch (e) { logger.debug('Something went wrong retrieving similar series', { label: 'API', errorMessage: e.message, tvId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve similar series.', }); } }); tvRoutes.get('/:id/ratings', async (req, res, next) => { const tmdb = new TheMovieDb(); const rtapi = new RottenTomatoes(); try { const tv = await tmdb.getTvShow({ tvId: Number(req.params.id), }); const rtratings = await rtapi.getTVRatings( tv.name, tv.first_air_date ? Number(tv.first_air_date.slice(0, 4)) : undefined ); if (!rtratings) { return next({ status: 404, message: 'Rotten Tomatoes ratings not found.', }); } return res.status(200).json(rtratings); } catch (e) { logger.debug('Something went wrong retrieving series ratings', { label: 'API', errorMessage: e.message, tvId: req.params.id, }); return next({ status: 500, message: 'Unable to retrieve series ratings.', }); } }); export default tvRoutes; ================================================ FILE: server/routes/user/index.ts ================================================ import JellyfinAPI from '@server/api/jellyfin'; import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import dataSource, { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; import { Watchlist } from '@server/entity/Watchlist'; import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces'; import type { QuotaResponse, UserRequestsResponse, UserResultsResponse, UserWatchDataResponse, } from '@server/interfaces/api/userInterfaces'; import { Permission, hasPermission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { getHostname } from '@server/utils/getHostname'; import { normalizeJellyfinGuid } from '@server/utils/jellyfin'; import { isOwnProfileOrAdmin } from '@server/utils/profileMiddleware'; import { Router } from 'express'; import gravatarUrl from 'gravatar-url'; import { findIndex, sortBy } from 'lodash'; import type { EntityManager } from 'typeorm'; import { In, Not } from 'typeorm'; import userSettingsRoutes from './usersettings'; const router = Router(); router.get('/', async (req, res, next) => { try { const includeIds = [ ...new Set( req.query.includeIds ? req.query.includeIds.toString().split(',') : [] ), ]; const pageSize = req.query.take ? Number(req.query.take) : Math.max(10, includeIds.length); const skip = req.query.skip ? Number(req.query.skip) : 0; const q = req.query.q ? req.query.q.toString().toLowerCase() : ''; let query = getRepository(User).createQueryBuilder('user'); if (q) { query = query.where( 'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q', { q: `%${q}%` } ); } if (includeIds.length > 0) { query.andWhereInIds(includeIds); } switch (req.query.sort) { case 'updated': query = query.orderBy('user.updatedAt', 'DESC'); break; case 'displayname': query = query .addSelect( `CASE WHEN (user.username IS NULL OR user.username = '') THEN ( CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN "user"."email" ELSE LOWER(user.jellyfinUsername) END) ELSE LOWER(user.jellyfinUsername) END) ELSE LOWER(user.username) END`, 'displayname_sort_key' ) .orderBy('displayname_sort_key', 'ASC'); break; case 'requests': query = query .addSelect((subQuery) => { return subQuery .select('COUNT(request.id)', 'request_count') .from(MediaRequest, 'request') .where('request.requestedBy.id = user.id'); }, 'request_count') .orderBy('request_count', 'DESC'); break; default: query = query.orderBy('user.id', 'ASC'); break; } const [users, userCount] = await query .take(pageSize) .skip(skip) .distinct(true) .getManyAndCount(); return res.status(200).json({ pageInfo: { pages: Math.ceil(userCount / pageSize), pageSize, results: userCount, page: Math.ceil(skip / pageSize) + 1, }, results: User.filterMany( users, req.user?.hasPermission(Permission.MANAGE_USERS) ), } as UserResultsResponse); } catch (e) { next({ status: 500, message: e.message }); } }); router.post( '/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const settings = getSettings(); const body = req.body; const email = body.email || body.username; const userRepository = getRepository(User); const existingUser = await userRepository .createQueryBuilder('user') .where('user.email = :email', { email: email.toLowerCase(), }) .getOne(); if (existingUser) { return next({ status: 409, message: 'User already exists with submitted email.', errors: ['USER_EXISTS'], }); } const passedExplicitPassword = body.password && body.password.length > 0; const avatar = gravatarUrl(email, { default: 'mm', size: 200 }); if ( !passedExplicitPassword && !settings.notifications.agents.email.enabled ) { throw new Error('Email notifications must be enabled'); } const user = new User({ email, avatar: body.avatar ?? avatar, username: body.username, password: body.password, permissions: settings.main.defaultPermissions, plexToken: '', userType: UserType.LOCAL, }); if (passedExplicitPassword) { await user?.setPassword(body.password); } else { await user?.generatePassword(); } await userRepository.save(user); return res.status(201).json(user.filter()); } catch (e) { next({ status: 500, message: e.message }); } } ); router.post< never, unknown, { endpoint: string; p256dh: string; auth: string; userAgent: string; } >('/registerPushSubscription', async (req, res, next) => { try { // This prevents race conditions where two requests both pass the checks await dataSource.transaction( async (transactionalEntityManager: EntityManager) => { const transactionalRepo = transactionalEntityManager.getRepository(UserPushSubscription); // Check for existing subscription by auth or endpoint within transaction const existingSubscription = await transactionalRepo.findOne({ relations: { user: true }, where: [ { auth: req.body.auth, user: { id: req.user?.id } }, { endpoint: req.body.endpoint, user: { id: req.user?.id } }, ], }); if (existingSubscription) { // If endpoint matches but auth is different, update with new keys (iOS refresh case) if ( existingSubscription.endpoint === req.body.endpoint && existingSubscription.auth !== req.body.auth ) { existingSubscription.auth = req.body.auth; existingSubscription.p256dh = req.body.p256dh; existingSubscription.userAgent = req.body.userAgent; await transactionalRepo.save(existingSubscription); logger.debug( 'Updated existing push subscription with new keys for same endpoint.', { label: 'API' } ); return; } logger.debug( 'Duplicate subscription detected. Skipping registration.', { label: 'API' } ); return; } // Clean up old subscriptions from the same device (userAgent) for this user // iOS can silently refresh endpoints, leaving stale subscriptions in the database // Only clean up if we're creating a new subscription (not updating an existing one) if (req.body.userAgent) { const staleSubscriptions = await transactionalRepo.find({ relations: { user: true }, where: { userAgent: req.body.userAgent, user: { id: req.user?.id }, // Only remove subscriptions with different endpoints (stale ones) // Keep subscriptions that might be from different browsers/tabs endpoint: Not(req.body.endpoint), }, }); if (staleSubscriptions.length > 0) { await transactionalRepo.remove(staleSubscriptions); logger.debug( `Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`, { label: 'API' } ); } } const userPushSubscription = new UserPushSubscription({ auth: req.body.auth, endpoint: req.body.endpoint, p256dh: req.body.p256dh, userAgent: req.body.userAgent, user: req.user, }); await transactionalRepo.save(userPushSubscription); } ); return res.status(204).send(); } catch { logger.error('Failed to register user push subscription', { label: 'API', }); next({ status: 500, message: 'Failed to register subscription.' }); } }); router.get<{ userId: string }>( '/:userId/pushSubscriptions', isOwnProfileOrAdmin(), async (req, res, next) => { try { const userPushSubRepository = getRepository(UserPushSubscription); const userPushSubs = await userPushSubRepository.find({ relations: { user: true }, where: { user: { id: Number(req.params.userId) } }, }); return res.status(200).json(userPushSubs); } catch { next({ status: 404, message: 'User subscriptions not found.' }); } } ); router.get<{ userId: string; endpoint: string }>( '/:userId/pushSubscription/:endpoint', isOwnProfileOrAdmin(), async (req, res, next) => { try { const userPushSubRepository = getRepository(UserPushSubscription); const userPushSub = await userPushSubRepository.findOneOrFail({ relations: { user: true, }, where: { user: { id: Number(req.params.userId) }, endpoint: req.params.endpoint, }, }); return res.status(200).json(userPushSub); } catch { next({ status: 404, message: 'User subscription not found.' }); } } ); router.delete<{ userId: string; endpoint: string }>( '/:userId/pushSubscription/:endpoint', isOwnProfileOrAdmin(), async (req, res, next) => { try { const userPushSubRepository = getRepository(UserPushSubscription); const userPushSub = await userPushSubRepository.findOne({ relations: { user: true }, where: { user: { id: Number(req.params.userId) }, endpoint: req.params.endpoint, }, }); // If not found, just return 204 to prevent push disable failure // (rare scenario where user push sub does not exist) if (!userPushSub) { return res.status(204).send(); } await userPushSubRepository.remove(userPushSub); return res.status(204).send(); } catch (e) { logger.error('Something went wrong deleting the user push subcription', { label: 'API', endpoint: req.params.endpoint, errorMessage: e.message, }); return next({ status: 500, message: 'User push subcription not found', }); } } ); router.get<{ id: string }>('/:id', async (req, res, next) => { try { const userRepository = getRepository(User); const user = await userRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); const isOwnProfile = req.user?.id === user.id; const isAdmin = req.user?.hasPermission(Permission.MANAGE_USERS); return res.status(200).json(user.filter(isOwnProfile || isAdmin)); } catch { next({ status: 404, message: 'User not found.' }); } }); router.use('/:id/settings', userSettingsRoutes); router.get<{ id: string }, UserRequestsResponse>( '/:id/requests', async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; try { const user = await getRepository(User).findOne({ where: { id: Number(req.params.id) }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } if ( user.id !== req.user?.id && !req.user?.hasPermission( [Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW], { type: 'or' } ) ) { return next({ status: 403, message: "You do not have permission to view this user's requests.", }); } const [requests, requestCount] = await getRepository(MediaRequest) .createQueryBuilder('request') .leftJoinAndSelect('request.media', 'media') .leftJoinAndSelect('request.seasons', 'seasons') .leftJoinAndSelect('request.modifiedBy', 'modifiedBy') .leftJoinAndSelect('request.requestedBy', 'requestedBy') .andWhere('requestedBy.id = :id', { id: user.id, }) .orderBy('request.id', 'DESC') .take(pageSize) .skip(skip) .getManyAndCount(); return res.status(200).json({ pageInfo: { pages: Math.ceil(requestCount / pageSize), pageSize, results: requestCount, page: Math.ceil(skip / pageSize) + 1, }, results: requests, }); } catch (e) { next({ status: 500, message: e.message }); } } ); export const canMakePermissionsChange = ( permissions: number, user?: User ): boolean => // Only let the owner grant admin privileges !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1); router.put< Record, Partial[], { ids: string[]; permissions: number } >('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const isOwner = req.user?.id === 1; if (!canMakePermissionsChange(req.body.permissions, req.user)) { return next({ status: 403, message: 'You do not have permission to grant this level of access', }); } const userRepository = getRepository(User); const users: User[] = await userRepository.find({ where: { id: In( isOwner ? req.body.ids : req.body.ids.filter((id) => Number(id) !== 1) ), }, }); const updatedUsers = await Promise.all( users.map(async (user) => { return userRepository.save({ ...user, ...{ permissions: req.body.permissions }, }); }) ); return res.status(200).json(updatedUsers); } catch (e) { next({ status: 500, message: e.message }); } }); router.put<{ id: string }>( '/:id', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const userRepository = getRepository(User); const user = await userRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); // Only let the owner user modify themselves if (user.id === 1 && req.user?.id !== 1) { return next({ status: 403, message: 'You do not have permission to modify this user', }); } if (!canMakePermissionsChange(req.body.permissions, req.user)) { return next({ status: 403, message: 'You do not have permission to grant this level of access', }); } Object.assign(user, { username: req.body.username, permissions: req.body.permissions, }); await userRepository.save(user); return res.status(200).json(user.filter()); } catch { next({ status: 404, message: 'User not found.' }); } } ); router.delete<{ id: string }>( '/:id', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const userRepository = getRepository(User); const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, relations: { requests: true }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } if (user.id === 1) { return next({ status: 405, message: 'This account cannot be deleted.', }); } if (user.hasPermission(Permission.ADMIN) && req.user?.id !== 1) { return next({ status: 405, message: 'You cannot delete users with administrative privileges.', }); } const requestRepository = getRepository(MediaRequest); /** * Requests are usually deleted through a cascade constraint. Those however, do * not trigger the removal event so listeners to not run and the parent Media * will not be updated back to unknown for titles that were still pending. So * we manually remove all requests from the user here so the parent media's * properly reflect the change. */ await requestRepository.remove(user.requests, { /** * Break-up into groups of 1000 requests to be removed at a time. * Necessary for users with >1000 requests, else an SQLite 'Expression tree is too large' error occurs. * https://typeorm.io/repository-api#additional-options */ chunk: user.requests.length / 1000, }); await userRepository.delete(user.id); return res.status(200).json(user.filter()); } catch (e) { logger.error('Something went wrong deleting a user', { label: 'API', userId: req.params.id, errorMessage: e.message, }); return next({ status: 500, message: 'Something went wrong deleting the user', }); } } ); router.post( '/import-from-plex', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { plexIds: string[] } | undefined; // taken from auth.ts const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); const plexUsersResponse = await mainPlexTv.getUsers(); const createdUsers: User[] = []; for (const rawUser of plexUsersResponse.MediaContainer.User) { const account = rawUser.$; if (account.email) { const user = await userRepository .createQueryBuilder('user') .where('user.plexId = :id', { id: account.id }) .orWhere('user.email = :email', { email: account.email.toLowerCase(), }) .getOne(); if (user) { // Update the user's avatar with their Plex thumbnail, in case it changed user.avatar = account.thumb; user.email = account.email; user.plexUsername = account.username; // In case the user was previously a local account if (user.userType === UserType.LOCAL) { user.userType = UserType.PLEX; user.plexId = parseInt(account.id); } await userRepository.save(user); } else if (!body || body.plexIds.includes(account.id)) { if (await mainPlexTv.checkUserAccess(parseInt(account.id))) { const newUser = new User({ plexUsername: account.username, email: account.email, permissions: settings.main.defaultPermissions, plexId: parseInt(account.id), plexToken: '', avatar: account.thumb, userType: UserType.PLEX, }); await userRepository.save(newUser); createdUsers.push(newUser); } } } } return res.status(201).json(User.filterMany(createdUsers)); } catch (e) { next({ status: 500, message: e.message }); } } ); router.post( '/import-from-jellyfin', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { try { const settings = getSettings(); const userRepository = getRepository(User); const body = req.body as { jellyfinUserIds: string[] }; // taken from auth.ts const admin = await userRepository.findOneOrFail({ where: { id: 1 }, select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'], order: { id: 'ASC' }, }); const hostname = getHostname(); const jellyfinClient = new JellyfinAPI( hostname, settings.jellyfin.apiKey, admin.jellyfinDeviceId ?? '' ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); //const jellyfinUsersResponse = await jellyfinClient.getUsers(); const createdUsers: User[] = []; jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const jellyfinUsers = await jellyfinClient.getUsers(); const jellyfinUsersById = new Map( jellyfinUsers.users.map((user) => [ normalizeJellyfinGuid(user.Id), user, ]) ); for (const rawJellyfinUserId of body.jellyfinUserIds) { const jellyfinUserId = normalizeJellyfinGuid(rawJellyfinUserId); if (!jellyfinUserId) { continue; } const jellyfinUser = jellyfinUsersById.get(jellyfinUserId); const user = await userRepository.findOne({ select: ['id', 'jellyfinUserId'], where: { jellyfinUserId: jellyfinUserId }, }); if (!user) { const newUser = new User({ jellyfinUsername: jellyfinUser?.Name, jellyfinUserId: jellyfinUser?.Id, jellyfinDeviceId: Buffer.from( `BOT_seerr_${jellyfinUser?.Name ?? ''}` ).toString('base64'), email: jellyfinUser?.Name, permissions: settings.main.defaultPermissions, avatar: `/avatarproxy/${jellyfinUser?.Id}`, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY, }); await userRepository.save(newUser); createdUsers.push(newUser); } } return res.status(201).json(User.filterMany(createdUsers)); } catch (e) { next({ status: 500, message: e.message }); } } ); router.get<{ id: string }, QuotaResponse>( '/:id/quota', async (req, res, next) => { try { const userRepository = getRepository(User); if ( Number(req.params.id) !== req.user?.id && !req.user?.hasPermission( [Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS], { type: 'and' } ) ) { return next({ status: 403, message: "You do not have permission to view this user's request limits.", }); } const user = await userRepository.findOneOrFail({ where: { id: Number(req.params.id) }, }); const quotas = await user.getQuota(); return res.status(200).json(quotas); } catch (e) { next({ status: 404, message: e.message }); } } ); router.get<{ id: string }, UserWatchDataResponse>( '/:id/watch_data', isOwnProfileOrAdmin(), async (req, res, next) => { const settings = getSettings().tautulli; if (!settings.hostname || !settings.port || !settings.apiKey) { return next({ status: 404, message: 'Tautulli API not configured.', }); } try { const user = await getRepository(User).findOneOrFail({ where: { id: Number(req.params.id) }, select: { id: true, plexId: true }, }); const tautulli = new TautulliAPI(settings); const watchStats = await tautulli.getUserWatchStats(user); const watchHistory = await tautulli.getUserWatchHistory(user); const recentlyWatched = sortBy( await getRepository(Media).find({ where: [ { mediaType: MediaType.MOVIE, ratingKey: In( watchHistory .filter((record) => record.media_type === 'movie') .map((record) => record.rating_key) ), }, { mediaType: MediaType.MOVIE, ratingKey4k: In( watchHistory .filter((record) => record.media_type === 'movie') .map((record) => record.rating_key) ), }, { mediaType: MediaType.TV, ratingKey: In( watchHistory .filter((record) => record.media_type === 'episode') .map((record) => record.grandparent_rating_key) ), }, { mediaType: MediaType.TV, ratingKey4k: In( watchHistory .filter((record) => record.media_type === 'episode') .map((record) => record.grandparent_rating_key) ), }, ], }), [ (media) => findIndex( watchHistory, (record) => (!!media.ratingKey && parseInt(media.ratingKey) === (record.media_type === 'movie' ? record.rating_key : record.grandparent_rating_key)) || (!!media.ratingKey4k && parseInt(media.ratingKey4k) === (record.media_type === 'movie' ? record.rating_key : record.grandparent_rating_key)) ), ] ); return res.status(200).json({ recentlyWatched, playCount: watchStats.total_plays, }); } catch (e) { logger.error('Something went wrong fetching user watch data', { label: 'API', errorMessage: e.message, userId: req.params.id, }); next({ status: 500, message: 'Failed to fetch user watch data.', }); } } ); router.get<{ id: string }, WatchlistResponse>( '/:id/watchlist', async (req, res, next) => { if ( Number(req.params.id) !== req.user?.id && !req.user?.hasPermission( [Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW], { type: 'or', } ) ) { return next({ status: 403, message: "You do not have permission to view this user's Watchlist.", }); } const itemsPerPage = 20; const page = req.query.page ? Number(req.query.page) : 1; const offset = (page - 1) * itemsPerPage; const user = await getRepository(User).findOneOrFail({ where: { id: Number(req.params.id) }, select: ['id', 'plexToken'], }); if (user) { const [result, total] = await getRepository(Watchlist).findAndCount({ where: { requestedBy: { id: user?.id } }, relations: { /*requestedBy: true,media:true*/ }, // loadRelationIds: true, take: itemsPerPage, skip: offset, }); if (total) { return res.json({ page: page, totalPages: Math.ceil(total / itemsPerPage), totalResults: total, results: result, }); } } // We will just return an empty array if the user has no Plex token if (!user.plexToken) { return res.json({ page: 1, totalPages: 1, totalResults: 0, results: [], }); } const plexTV = new PlexTvAPI(user.plexToken); const watchlist = await plexTV.getWatchlist({ offset }); return res.json({ page, totalPages: Math.ceil(watchlist.totalSize / itemsPerPage), totalResults: watchlist.totalSize, results: watchlist.items.map((item) => ({ id: item.tmdbId, ratingKey: item.ratingKey, title: item.title, mediaType: item.type === 'show' ? 'tv' : 'movie', tmdbId: item.tmdbId, })), }); } ); export default router; ================================================ FILE: server/routes/user/usersettings.ts ================================================ import JellyfinAPI from '@server/api/jellyfin'; import PlexTvAPI from '@server/api/plextv'; import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { UserSettings } from '@server/entity/UserSettings'; import type { UserSettingsGeneralResponse, UserSettingsNotificationsResponse, } from '@server/interfaces/api/userSettingsInterfaces'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { ApiError } from '@server/types/error'; import { getHostname } from '@server/utils/getHostname'; import { isOwnProfile, isOwnProfileOrAdmin, } from '@server/utils/profileMiddleware'; import { Router } from 'express'; import net from 'net'; import { Not } from 'typeorm'; import { canMakePermissionsChange } from '.'; const userSettingsRoutes = Router({ mergeParams: true }); userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( '/main', isOwnProfileOrAdmin(), async (req, res, next) => { const { main: { defaultQuotas }, } = getSettings(); const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } return res.status(200).json({ username: user.username, email: user.email, discordId: user.settings?.discordId, locale: user.settings?.locale, discoverRegion: user.settings?.discoverRegion, streamingRegion: user.settings?.streamingRegion, originalLanguage: user.settings?.originalLanguage, movieQuotaLimit: user.movieQuotaLimit, movieQuotaDays: user.movieQuotaDays, tvQuotaLimit: user.tvQuotaLimit, tvQuotaDays: user.tvQuotaDays, globalMovieQuotaDays: defaultQuotas.movie.quotaDays, globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit, globalTvQuotaDays: defaultQuotas.tv.quotaDays, globalTvQuotaLimit: defaultQuotas.tv.quotaLimit, watchlistSyncMovies: user.settings?.watchlistSyncMovies, watchlistSyncTv: user.settings?.watchlistSyncTv, }); } catch (e) { next({ status: 500, message: e.message }); } } ); userSettingsRoutes.post< { id: string }, UserSettingsGeneralResponse, UserSettingsGeneralResponse >('/main', isOwnProfileOrAdmin(), async (req, res, next) => { const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } // "Owner" user settings cannot be modified by other users if (user.id === 1 && req.user?.id !== 1) { return next({ status: 403, message: "You do not have permission to modify this user's settings.", }); } const oldEmail = user.email; user.username = req.body.username; if (user.userType !== UserType.PLEX) { user.email = req.body.email || user.jellyfinUsername || user.email; } const existingUser = await userRepository.findOne({ where: { email: user.email, id: Not(user.id) }, }); if (oldEmail !== user.email && existingUser) { throw new ApiError(400, ApiErrorCode.InvalidEmail); } // Update quota values only if the user has the correct permissions if ( !user.hasPermission(Permission.MANAGE_USERS) && req.user?.id !== user.id ) { user.movieQuotaDays = req.body.movieQuotaDays; user.movieQuotaLimit = req.body.movieQuotaLimit; user.tvQuotaDays = req.body.tvQuotaDays; user.tvQuotaLimit = req.body.tvQuotaLimit; } if (!user.settings) { user.settings = new UserSettings({ user: req.user, discordId: req.body.discordId, locale: req.body.locale, discoverRegion: req.body.discoverRegion, streamingRegion: req.body.streamingRegion, originalLanguage: req.body.originalLanguage, watchlistSyncMovies: req.body.watchlistSyncMovies, watchlistSyncTv: req.body.watchlistSyncTv, }); } else { user.settings.discordId = req.body.discordId; user.settings.locale = req.body.locale; user.settings.discoverRegion = req.body.discoverRegion; user.settings.streamingRegion = req.body.streamingRegion; user.settings.originalLanguage = req.body.originalLanguage; user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; user.settings.watchlistSyncTv = req.body.watchlistSyncTv; } const savedUser = await userRepository.save(user); return res.status(200).json({ username: savedUser.username, discordId: savedUser.settings?.discordId, locale: savedUser.settings?.locale, discoverRegion: savedUser.settings?.discoverRegion, streamingRegion: savedUser.settings?.streamingRegion, originalLanguage: savedUser.settings?.originalLanguage, watchlistSyncMovies: savedUser.settings?.watchlistSyncMovies, watchlistSyncTv: savedUser.settings?.watchlistSyncTv, email: savedUser.email, }); } catch (e) { if (e.errorCode) { return next({ status: e.statusCode, message: e.errorCode, }); } return next({ status: 500, message: e.message }); } }); userSettingsRoutes.get<{ id: string }, { hasPassword: boolean }>( '/password', isOwnProfileOrAdmin(), async (req, res, next) => { const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, select: ['id', 'password'], }); if (!user) { return next({ status: 404, message: 'User not found.' }); } return res.status(200).json({ hasPassword: !!user.password }); } catch (e) { next({ status: 500, message: e.message }); } } ); userSettingsRoutes.post< { id: string }, null, { currentPassword?: string; newPassword: string } >('/password', isOwnProfileOrAdmin(), async (req, res, next) => { const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, }); const userWithPassword = await userRepository.findOne({ select: ['id', 'password'], where: { id: Number(req.params.id) }, }); if (!user || !userWithPassword) { return next({ status: 404, message: 'User not found.' }); } if (req.body.newPassword.length < 8) { return next({ status: 400, message: 'Password must be at least 8 characters.', }); } if ( (user.id === 1 && req.user?.id !== 1) || (user.hasPermission(Permission.ADMIN) && user.id !== req.user?.id && req.user?.id !== 1) ) { return next({ status: 403, message: "You do not have permission to modify this user's password.", }); } // If the user has the permission to manage users and they are not // editing themselves, we will just set the new password if ( req.user?.hasPermission(Permission.MANAGE_USERS) && req.user?.id !== user.id ) { await user.setPassword(req.body.newPassword); await userRepository.save(user); logger.debug('Password overriden by user.', { label: 'User Settings', userEmail: user.email, changingUser: req.user.email, }); return res.status(204).send(); } // If the user has a password, we need to check the currentPassword is correct if ( user.password && (!req.body.currentPassword || !(await userWithPassword.passwordMatch(req.body.currentPassword))) ) { logger.debug( 'Attempt to change password for user failed. Invalid current password provided.', { label: 'User Settings', userEmail: user.email } ); return next({ status: 403, message: 'Current password is invalid.' }); } await user.setPassword(req.body.newPassword); await userRepository.save(user); return res.status(204).send(); } catch (e) { next({ status: 500, message: e.message }); } }); userSettingsRoutes.post<{ authToken: string }>( '/linked-accounts/plex', isOwnProfile(), async (req, res) => { const settings = getSettings(); const userRepository = getRepository(User); if (!req.user) { return res.status(404).json({ code: ApiErrorCode.Unauthorized }); } // Make sure Plex login is enabled if (settings.main.mediaServerType !== MediaServerType.PLEX) { return res.status(500).json({ message: 'Plex login is disabled' }); } // First we need to use this auth token to get the user's email from plex.tv const plextv = new PlexTvAPI(req.body.authToken); const account = await plextv.getUser(); // Do not allow linking of an already linked account if (await userRepository.exist({ where: { plexId: account.id } })) { return res.status(422).json({ message: 'This Plex account is already linked to a Seerr user', }); } const user = req.user; // Emails do not match if (user.email !== account.email) { return res.status(422).json({ message: 'This Plex account is registered under a different email address.', }); } // valid plex user found, link to current user user.userType = UserType.PLEX; user.plexId = account.id; user.plexUsername = account.username; user.plexToken = account.authToken; await userRepository.save(user); return res.status(204).send(); } ); userSettingsRoutes.delete<{ id: string }>( '/linked-accounts/plex', isOwnProfileOrAdmin(), async (req, res) => { const settings = getSettings(); const userRepository = getRepository(User); // Make sure Plex login is enabled if (settings.main.mediaServerType !== MediaServerType.PLEX) { return res.status(500).json({ message: 'Plex login is disabled' }); } try { const user = await userRepository .createQueryBuilder('user') .addSelect('user.password') .where({ id: Number(req.params.id), }) .getOne(); if (!user) { return res.status(404).json({ message: 'User not found.' }); } if (user.id === 1) { return res.status(400).json({ message: 'Cannot unlink media server accounts for the primary administrator.', }); } if (!user.email || !user.password) { return res.status(400).json({ message: 'User does not have a local email or password set.', }); } user.userType = UserType.LOCAL; user.plexId = null; user.plexUsername = null; user.plexToken = null; await userRepository.save(user); return res.status(204).send(); } catch (e) { return res.status(500).json({ message: e.message }); } } ); userSettingsRoutes.post<{ username: string; password: string }>( '/linked-accounts/jellyfin', isOwnProfile(), async (req, res) => { const settings = getSettings(); const userRepository = getRepository(User); if (!req.user) { return res.status(401).json({ code: ApiErrorCode.Unauthorized }); } // Make sure jellyfin login is enabled if ( settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.EMBY ) { return res .status(500) .json({ message: 'Jellyfin/Emby login is disabled' }); } // Do not allow linking of an already linked account if ( await userRepository.exist({ where: { jellyfinUsername: req.body.username }, }) ) { return res.status(422).json({ message: 'The specified account is already linked to a Seerr user', }); } const hostname = getHostname(); const deviceId = Buffer.from( req.user?.id === 1 ? 'BOT_seerr' : `BOT_seerr_${req.user.username ?? ''}` ).toString('base64'); const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId); const ip = req.ip; let clientIp: string | undefined; if (ip) { if (net.isIPv4(ip)) { clientIp = ip; } else if (net.isIPv6(ip)) { clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; } } try { const account = await jellyfinserver.login( req.body.username, req.body.password, clientIp ); // Do not allow linking of an already linked account if ( await userRepository.exist({ where: { jellyfinUserId: account.User.Id }, }) ) { return res.status(422).json({ message: 'The specified account is already linked to a Seerr user', }); } const user = req.user; // valid jellyfin user found, link to current user user.userType = settings.main.mediaServerType === MediaServerType.EMBY ? UserType.EMBY : UserType.JELLYFIN; user.jellyfinUserId = account.User.Id; user.jellyfinUsername = account.User.Name; user.jellyfinAuthToken = account.AccessToken; user.jellyfinDeviceId = deviceId; await userRepository.save(user); return res.status(204).send(); } catch (e) { logger.error('Failed to link account to user.', { label: 'API', ip: req.ip, error: e, }); if ( e instanceof ApiError && e.errorCode === ApiErrorCode.InvalidCredentials ) { return res.status(401).json({ code: e.errorCode }); } return res.status(500).send(); } } ); userSettingsRoutes.delete<{ id: string }>( '/linked-accounts/jellyfin', isOwnProfileOrAdmin(), async (req, res) => { const settings = getSettings(); const userRepository = getRepository(User); // Make sure jellyfin login is enabled if ( settings.main.mediaServerType !== MediaServerType.JELLYFIN && settings.main.mediaServerType !== MediaServerType.EMBY ) { return res .status(500) .json({ message: 'Jellyfin/Emby login is disabled' }); } try { const user = await userRepository .createQueryBuilder('user') .addSelect('user.password') .where({ id: Number(req.params.id), }) .getOne(); if (!user) { return res.status(404).json({ message: 'User not found.' }); } if (user.id === 1) { return res.status(400).json({ message: 'Cannot unlink media server accounts for the primary administrator.', }); } if (!user.email || !user.password) { return res.status(400).json({ message: 'User does not have a local email or password set.', }); } user.userType = UserType.LOCAL; user.jellyfinUserId = null; user.jellyfinUsername = null; user.jellyfinAuthToken = null; user.jellyfinDeviceId = null; await userRepository.save(user); return res.status(204).send(); } catch (e) { return res.status(500).json({ message: e.message }); } } ); userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), async (req, res, next) => { const userRepository = getRepository(User); const settings = getSettings()?.notifications.agents; try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } return res.status(200).json({ emailEnabled: settings.email.enabled, pgpKey: user.settings?.pgpKey, discordEnabled: settings?.discord.enabled && settings.discord.options.enableMentions, discordEnabledTypes: settings?.discord.enabled && settings.discord.options.enableMentions ? settings.discord.types : 0, discordId: user.settings?.discordId, pushbulletAccessToken: user.settings?.pushbulletAccessToken, pushoverApplicationToken: user.settings?.pushoverApplicationToken, pushoverUserKey: user.settings?.pushoverUserKey, pushoverSound: user.settings?.pushoverSound, telegramEnabled: settings.telegram.enabled, telegramBotUsername: settings.telegram.options.botUsername, telegramChatId: user.settings?.telegramChatId, telegramMessageThreadId: user.settings?.telegramMessageThreadId, telegramSendSilently: user.settings?.telegramSendSilently, webPushEnabled: settings.webpush.enabled, notificationTypes: user.settings?.notificationTypes ?? {}, }); } catch (e) { next({ status: 500, message: e.message }); } } ); userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), async (req, res, next) => { const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } // "Owner" user settings cannot be modified by other users if (user.id === 1 && req.user?.id !== 1) { return next({ status: 403, message: "You do not have permission to modify this user's settings.", }); } if (!user.settings) { user.settings = new UserSettings({ user: req.user, pgpKey: req.body.pgpKey, discordId: req.body.discordId, pushbulletAccessToken: req.body.pushbulletAccessToken, pushoverApplicationToken: req.body.pushoverApplicationToken, pushoverUserKey: req.body.pushoverUserKey, telegramChatId: req.body.telegramChatId, telegramMessageThreadId: req.body.telegramMessageThreadId, telegramSendSilently: req.body.telegramSendSilently, notificationTypes: req.body.notificationTypes, }); } else { user.settings.pgpKey = req.body.pgpKey; user.settings.discordId = req.body.discordId; user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken; user.settings.pushoverApplicationToken = req.body.pushoverApplicationToken; user.settings.pushoverUserKey = req.body.pushoverUserKey; user.settings.pushoverSound = req.body.pushoverSound; user.settings.telegramChatId = req.body.telegramChatId; user.settings.telegramMessageThreadId = req.body.telegramMessageThreadId; user.settings.telegramSendSilently = req.body.telegramSendSilently; user.settings.notificationTypes = Object.assign( {}, user.settings.notificationTypes, req.body.notificationTypes ); } userRepository.save(user); return res.status(200).json({ pgpKey: user.settings.pgpKey, discordId: user.settings.discordId, pushbulletAccessToken: user.settings.pushbulletAccessToken, pushoverApplicationToken: user.settings.pushoverApplicationToken, pushoverUserKey: user.settings.pushoverUserKey, pushoverSound: user.settings.pushoverSound, telegramChatId: user.settings.telegramChatId, telegramMessageThreadId: user.settings.telegramMessageThreadId, telegramSendSilently: user.settings.telegramSendSilently, notificationTypes: user.settings.notificationTypes, }); } catch (e) { next({ status: 500, message: e.message }); } } ); userSettingsRoutes.get<{ id: string }, { permissions?: number }>( '/permissions', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } return res.status(200).json({ permissions: user.permissions }); } catch (e) { next({ status: 500, message: e.message }); } } ); userSettingsRoutes.post< { id: string }, { permissions?: number }, { permissions: number } >( '/permissions', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { const userRepository = getRepository(User); try { const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, }); if (!user) { return next({ status: 404, message: 'User not found.' }); } // "Owner" user permissions cannot be modified, and users cannot set their own permissions if (user.id === 1 || req.user?.id === user.id) { return next({ status: 403, message: 'You do not have permission to modify this user', }); } if (!canMakePermissionsChange(req.body.permissions, req.user)) { return next({ status: 403, message: 'You do not have permission to grant this level of access', }); } user.permissions = req.body.permissions; await userRepository.save(user); return res.status(200).json({ permissions: user.permissions }); } catch (e) { next({ status: 500, message: e.message }); } } ); export default userSettingsRoutes; ================================================ FILE: server/routes/watchlist.ts ================================================ import { DuplicateWatchlistRequestError, NotFoundError, Watchlist, } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { Router } from 'express'; import { QueryFailedError } from 'typeorm'; import { MediaType } from '@server/constants/media'; import { watchlistCreate } from '@server/interfaces/api/watchlistCreate'; const watchlistRoutes = Router(); watchlistRoutes.post( '/', async (req, res, next) => { try { if (!req.user) { return next({ status: 401, message: 'You must be logged in to add watchlist.', }); } const values = watchlistCreate.parse(req.body); const request = await Watchlist.createWatchlist({ watchlistRequest: values, user: req.user, }); return res.status(201).json(request); } catch (error) { if (!(error instanceof Error)) { return; } switch (error.constructor) { case QueryFailedError: logger.warn('Something wrong with data watchlist', { tmdbId: req.body.tmdbId, mediaType: req.body.mediaType, label: 'Watchlist', }); return next({ status: 409, message: 'Something wrong' }); case DuplicateWatchlistRequestError: return next({ status: 409, message: error.message }); default: return next({ status: 500, message: error.message }); } } } ); watchlistRoutes.delete('/:tmdbId', async (req, res, next) => { if (!req.user) { return next({ status: 401, message: 'You must be logged in to delete watchlist data.', }); } try { const mediaType = req.query.mediaType; if (mediaType !== MediaType.MOVIE && mediaType !== MediaType.TV) { return next({ status: 400, message: 'Invalid mediaType query parameter.', }); } await Watchlist.deleteWatchlist( Number(req.params.tmdbId), mediaType, req.user ); return res.status(204).send(); } catch (e) { if (e instanceof NotFoundError) { return next({ status: 404, message: e.message, }); } return next({ status: 500, message: e.message }); } }); export default watchlistRoutes; ================================================ FILE: server/scripts/prepareTestDb.ts ================================================ import { seedTestDb } from '@server/utils/seedTestDb'; import { copyFileSync } from 'fs'; import path from 'path'; const prepareDb = async () => { // Copy over test settings.json copyFileSync( path.join(__dirname, '../../cypress/config/settings.cypress.json'), path.join(__dirname, '../../config/settings.json') ); await seedTestDb({ preserveDb: process.env.PRESERVE_DB === 'true', withMigrations: process.env.WITH_MIGRATIONS === 'true', }); }; prepareDb(); ================================================ FILE: server/subscriber/IssueCommentSubscriber.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import { IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import IssueComment from '@server/entity/IssueComment'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { sortBy } from 'lodash'; import type { EntitySubscriberInterface, InsertEvent } from 'typeorm'; import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class IssueCommentSubscriber implements EntitySubscriberInterface { public listenTo(): typeof IssueComment { return IssueComment; } private async sendIssueCommentNotification(entity: IssueComment) { let title: string; let image: string; const tmdb = new TheMovieDb(); try { const issue = ( await getRepository(IssueComment).findOneOrFail({ where: { id: entity.id }, relations: { issue: true }, }) ).issue; const createdBy = await getRepository(User).findOneOrFail({ where: { id: issue.createdBy.id }, }); const media = await getRepository(Media).findOneOrFail({ where: { id: issue.media.id }, }); if (media.mediaType === MediaType.MOVIE) { const movie = await tmdb.getMovie({ movieId: media.tmdbId }); title = `${movie.title}${ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; } else { const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId }); title = `${tvshow.name}${ tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; } const [firstComment] = sortBy(issue.comments, 'id'); if (entity.id !== firstComment.id) { // Send notifications to all issue managers notificationManager.sendNotification(Notification.ISSUE_COMMENT, { event: `New Comment on ${ issue.issueType !== IssueType.OTHER ? `${IssueTypeName[issue.issueType]} ` : '' }Issue`, subject: title, message: firstComment.message, comment: entity, issue, media, image, notifyAdmin: true, notifySystem: true, notifyUser: !createdBy.hasPermission(Permission.MANAGE_ISSUES) && createdBy.id !== entity.user.id ? createdBy : undefined, }); } } catch (e) { logger.error( 'Something went wrong sending issue comment notification(s)', { label: 'Notifications', errorMessage: e.message, commentId: entity.id, } ); } } public afterInsert(event: InsertEvent): void { if (!event.entity) { return; } this.sendIssueCommentNotification(event.entity); } } ================================================ FILE: server/subscriber/IssueSubscriber.ts ================================================ import TheMovieDb from '@server/api/themoviedb'; import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import Issue from '@server/entity/Issue'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { sortBy } from 'lodash'; import type { EntitySubscriberInterface, InsertEvent, UpdateEvent, } from 'typeorm'; import { EventSubscriber } from 'typeorm'; @EventSubscriber() export class IssueSubscriber implements EntitySubscriberInterface { public listenTo(): typeof Issue { return Issue; } private async sendIssueNotification(entity: Issue, type: Notification) { let title: string; let image: string; const tmdb = new TheMovieDb(); try { if (entity.media.mediaType === MediaType.MOVIE) { const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId }); title = `${movie.title}${ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; } else { const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); title = `${tvshow.name}${ tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; } const [firstComment] = sortBy(entity.comments, 'id'); const extra: { name: string; value: string }[] = []; if (entity.media.mediaType === MediaType.TV && entity.problemSeason > 0) { extra.push({ name: 'Affected Season', value: entity.problemSeason.toString(), }); if (entity.problemEpisode > 0) { extra.push({ name: 'Affected Episode', value: entity.problemEpisode.toString(), }); } } notificationManager.sendNotification(type, { event: type === Notification.ISSUE_CREATED ? `New ${ entity.issueType !== IssueType.OTHER ? `${IssueTypeName[entity.issueType]} ` : '' }Issue Reported` : type === Notification.ISSUE_RESOLVED ? `${ entity.issueType !== IssueType.OTHER ? `${IssueTypeName[entity.issueType]} ` : '' }Issue Resolved` : `${ entity.issueType !== IssueType.OTHER ? `${IssueTypeName[entity.issueType]} ` : '' }Issue Reopened`, subject: title, message: firstComment.message, issue: entity, media: entity.media, image, extra, notifyAdmin: true, notifySystem: true, notifyUser: !entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) && entity.modifiedBy?.id !== entity.createdBy.id && (type === Notification.ISSUE_RESOLVED || type === Notification.ISSUE_REOPENED) ? entity.createdBy : undefined, }); } catch (e) { logger.error('Something went wrong sending issue notification(s)', { label: 'Notifications', errorMessage: e.message, issueId: entity.id, }); } } public afterInsert(event: InsertEvent): void { if (!event.entity) { return; } this.sendIssueNotification(event.entity, Notification.ISSUE_CREATED); } public beforeUpdate(event: UpdateEvent): void { if (!event.entity) { return; } if ( event.entity.status === IssueStatus.RESOLVED && event.databaseEntity.status !== IssueStatus.RESOLVED ) { this.sendIssueNotification( event.entity as Issue, Notification.ISSUE_RESOLVED ); } else if ( event.entity.status === IssueStatus.OPEN && event.databaseEntity.status !== IssueStatus.OPEN ) { this.sendIssueNotification( event.entity as Issue, Notification.ISSUE_REOPENED ); } } } ================================================ FILE: server/subscriber/MediaRequestSubscriber.ts ================================================ import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr'; import type { AddSeriesOptions, SonarrSeries, } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { MediaRequestStatus, MediaStatus, MediaType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; import notificationManager, { Notification } from '@server/lib/notifications'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isEqual, truncate } from 'lodash'; import type { EntityManager, EntitySubscriberInterface, InsertEvent, RemoveEvent, UpdateEvent, } from 'typeorm'; import { EventSubscriber, Not } from 'typeorm'; const sanitizeDisplayName = (displayName: string): string => { return displayName .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/gi, '') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); }; @EventSubscriber() export class MediaRequestSubscriber implements EntitySubscriberInterface { private async notifyAvailableMovie( entity: MediaRequest, event?: UpdateEvent ) { // Get fresh media state using event manager let latestMedia: Media | null = null; if (event?.manager) { latestMedia = await event.manager.findOne(Media, { where: { id: entity.media.id }, }); } if (!latestMedia) { const mediaRepository = getRepository(Media); latestMedia = await mediaRepository.findOne({ where: { id: entity.media.id }, }); } // Check availability using fresh media state if ( !latestMedia || latestMedia[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE ) { return; } const tmdb = new TheMovieDb(); try { const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId, }); notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`, notifyAdmin: false, notifySystem: true, notifyUser: entity.requestedBy, subject: `${movie.title}${ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' }`, message: truncate(movie.overview, { length: 500, separator: /\s/, omission: '…', }), media: latestMedia, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, request: entity, }); } catch (e) { logger.error('Something went wrong sending media notification(s)', { label: 'Notifications', errorMessage: e.message, mediaId: entity.id, }); } } private async notifyAvailableSeries( entity: MediaRequest, event?: UpdateEvent ) { // Get fresh media state with seasons using event manager let latestMedia: Media | null = null; if (event?.manager) { latestMedia = await event.manager.findOne(Media, { where: { id: entity.media.id }, relations: { seasons: true }, }); } if (!latestMedia) { const mediaRepository = getRepository(Media); latestMedia = await mediaRepository.findOne({ where: { id: entity.media.id }, relations: { seasons: true }, }); } if (!latestMedia) { return; } // Check availability using fresh media state const requestedSeasons = entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? []; const availableSeasons = latestMedia.seasons.filter( (season) => season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE && requestedSeasons.includes(season.seasonNumber) ); const isMediaAvailable = availableSeasons.length > 0 && availableSeasons.length === requestedSeasons.length; if (!isMediaAvailable) { return; } const tmdb = new TheMovieDb(); try { const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`, subject: `${tv.name}${ tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' }`, message: truncate(tv.overview, { length: 500, separator: /\s/, omission: '…', }), notifyAdmin: false, notifySystem: true, notifyUser: entity.requestedBy, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, media: latestMedia, extra: [ { name: 'Requested Seasons', value: entity.seasons .map((season) => season.seasonNumber) .join(', '), }, ], request: entity, }); } catch (e) { logger.error('Something went wrong sending media notification(s)', { label: 'Notifications', errorMessage: e.message, mediaId: entity.id, }); } } public async sendToRadarr(entity: MediaRequest): Promise { if ( entity.status === MediaRequestStatus.APPROVED && entity.type === MediaType.MOVIE ) { try { const mediaRepository = getRepository(Media); const settings = getSettings(); if (settings.radarr.length === 0 && !settings.radarr[0]) { logger.info( 'No Radarr server configured, skipping request processing', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); return; } let radarrSettings = settings.radarr.find( (radarr) => radarr.isDefault && radarr.is4k === entity.is4k ); if ( entity.serverId !== null && entity.serverId >= 0 && radarrSettings?.id !== entity.serverId ) { radarrSettings = settings.radarr.find( (radarr) => radarr.id === entity.serverId ); logger.info( `Request has an override server: ${radarrSettings?.name}`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); } if (!radarrSettings) { logger.warn( `There is no default ${ entity.is4k ? '4K ' : '' }Radarr server configured. Did you set any of your ${ entity.is4k ? '4K ' : '' }Radarr servers as default?`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); return; } let rootFolder = radarrSettings.activeDirectory; let qualityProfile = radarrSettings.activeProfileId; let tags = radarrSettings.tags ? [...radarrSettings.tags] : []; if ( entity.rootFolder && entity.rootFolder !== '' && entity.rootFolder !== radarrSettings.activeDirectory ) { rootFolder = entity.rootFolder; logger.info(`Request has an override root folder: ${rootFolder}`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); } if ( entity.profileId && entity.profileId !== radarrSettings.activeProfileId ) { qualityProfile = entity.profileId; logger.info( `Request has an override quality profile ID: ${qualityProfile}`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); } if (entity.tags && !isEqual(entity.tags, radarrSettings.tags)) { tags = entity.tags; logger.info(`Request has override tags`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, tagIds: tags, }); } const tmdb = new TheMovieDb(); const radarr = new RadarrAPI({ apiKey: radarrSettings.apiKey, url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId }); const media = await mediaRepository.findOne({ where: { id: entity.media.id }, }); if (!media) { logger.error('Media data not found', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); return; } if (radarrSettings.tagRequests) { const radarrTags = await radarr.getTags(); // old tags had space around the hyphen let userTag = radarrTags.find((v) => v.label.startsWith(entity.requestedBy.id + ' - ') ); // new tags do not have spaces around the hyphen, since spaces are not allowed anymore if (!userTag) { userTag = radarrTags.find((v) => v.label.startsWith(entity.requestedBy.id + '-') ); } if (!userTag) { logger.info(`Requester has no active tag. Creating new`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, userId: entity.requestedBy.id, newTag: entity.requestedBy.id + '-' + sanitizeDisplayName(entity.requestedBy.displayName), }); userTag = await radarr.createTag({ label: entity.requestedBy.id + '-' + sanitizeDisplayName(entity.requestedBy.displayName), }); } if (userTag.id) { if (!tags?.find((v) => v === userTag?.id)) { tags?.push(userTag.id); } } else { logger.warn(`Requester has no tag and failed to add one`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, userId: entity.requestedBy.id, radarrServer: radarrSettings.hostname + ':' + radarrSettings.port, }); } } if ( media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ) { logger.warn('Media already exists, marking request as COMPLETED', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); const requestRepository = getRepository(MediaRequest); entity.status = MediaRequestStatus.COMPLETED; await requestRepository.save(entity); return; } const radarrMovieOptions: RadarrMovieOptions = { profileId: qualityProfile, qualityProfileId: qualityProfile, rootFolderPath: rootFolder, minimumAvailability: radarrSettings.minimumAvailability, title: movie.title, tmdbId: movie.id, year: Number(movie.release_date.slice(0, 4)), monitored: true, tags, searchNow: !radarrSettings.preventSearch, }; // Run entity asynchronously so we don't wait for it on the UI side radarr .addMovie(radarrMovieOptions) .then(async (radarrMovie) => { // We grab media again here to make sure we have the latest version of it const media = await mediaRepository.findOne({ where: { id: entity.media.id }, }); if (!media) { throw new Error('Media data not found'); } media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] = radarrMovie.id; media[ entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' ] = radarrMovie.titleSlug; media[entity.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id; await mediaRepository.save(media); }) .catch(async () => { try { const requestRepository = getRepository(MediaRequest); if (entity.status !== MediaRequestStatus.FAILED) { entity.status = MediaRequestStatus.FAILED; await requestRepository.save(entity); } } catch (saveError) { logger.error('Failed to mark request as FAILED', { label: 'Media Request', requestId: entity.id, errorMessage: saveError instanceof Error ? saveError.message : String(saveError), }); } logger.warn( 'Something went wrong sending movie request to Radarr, marking status as FAILED', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, radarrMovieOptions, } ); MediaRequest.sendNotification( entity, media, Notification.MEDIA_FAILED ); }) .finally(() => { radarr.clearCache({ tmdbId: movie.id, externalId: entity.is4k ? media.externalServiceId4k : media.externalServiceId, }); }); logger.info('Sent request to Radarr', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); } catch (e) { const requestRepository = getRepository(MediaRequest); const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: entity.media.id }, }); if (media) { entity.status = MediaRequestStatus.FAILED; await requestRepository.save(entity); logger.warn( 'Failed to send movie request to Radarr due to connection or configuration error, marking status as FAILED', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, errorMessage: e.message, } ); MediaRequest.sendNotification( entity, media, Notification.MEDIA_FAILED ); } } } } public async sendToSonarr(entity: MediaRequest): Promise { if ( entity.status === MediaRequestStatus.APPROVED && entity.type === MediaType.TV ) { try { const mediaRepository = getRepository(Media); const settings = getSettings(); if (settings.sonarr.length === 0 && !settings.sonarr[0]) { logger.warn( 'No Sonarr server configured, skipping request processing', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); return; } let sonarrSettings = settings.sonarr.find( (sonarr) => sonarr.isDefault && sonarr.is4k === entity.is4k ); if ( entity.serverId !== null && entity.serverId >= 0 && sonarrSettings?.id !== entity.serverId ) { sonarrSettings = settings.sonarr.find( (sonarr) => sonarr.id === entity.serverId ); logger.info( `Request has an override server: ${sonarrSettings?.name}`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); } if (!sonarrSettings) { logger.warn( `There is no default ${ entity.is4k ? '4K ' : '' }Sonarr server configured. Did you set any of your ${ entity.is4k ? '4K ' : '' }Sonarr servers as default?`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); return; } const media = await mediaRepository.findOne({ where: { id: entity.media.id }, }); if (!media) { throw new Error('Media data not found'); } if ( media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ) { logger.warn('Media already exists, marking request as COMPLETED', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); const requestRepository = getRepository(MediaRequest); entity.status = MediaRequestStatus.COMPLETED; entity.seasons.forEach((season) => { season.status = MediaRequestStatus.COMPLETED; }); await requestRepository.save(entity); return; } const tmdb = new TheMovieDb(); const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; if (!tvdbId) { const requestRepository = getRepository(MediaRequest); await mediaRepository.remove(media); await requestRepository.remove(entity); throw new Error('TVDB ID not found'); } let seriesType: SonarrSeries['seriesType'] = 'standard'; // Change series type to anime if the anime keyword is present on tmdb if ( series.keywords.results.some( (keyword) => keyword.id === ANIME_KEYWORD_ID ) ) { seriesType = sonarrSettings.animeSeriesType ?? 'anime'; } let rootFolder = seriesType === 'anime' && sonarrSettings.activeAnimeDirectory ? sonarrSettings.activeAnimeDirectory : sonarrSettings.activeDirectory; let qualityProfile = seriesType === 'anime' && sonarrSettings.activeAnimeProfileId ? sonarrSettings.activeAnimeProfileId : sonarrSettings.activeProfileId; let languageProfile = seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId ? sonarrSettings.activeAnimeLanguageProfileId : sonarrSettings.activeLanguageProfileId; let tags = seriesType === 'anime' ? sonarrSettings.animeTags ? [...sonarrSettings.animeTags] : [] : sonarrSettings.tags ? [...sonarrSettings.tags] : []; if ( entity.rootFolder && entity.rootFolder !== '' && entity.rootFolder !== rootFolder ) { rootFolder = entity.rootFolder; logger.info(`Request has an override root folder: ${rootFolder}`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); } if (entity.profileId && entity.profileId !== qualityProfile) { qualityProfile = entity.profileId; logger.info( `Request has an override quality profile ID: ${qualityProfile}`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); } if ( entity.languageProfileId && entity.languageProfileId !== languageProfile ) { languageProfile = entity.languageProfileId; logger.info( `Request has an override language profile ID: ${languageProfile}`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, } ); } if (entity.tags && !isEqual(entity.tags, tags)) { tags = entity.tags; logger.info(`Request has override tags`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, tagIds: tags, }); } if (sonarrSettings.tagRequests) { const sonarrTags = await sonarr.getTags(); // old tags had space around the hyphen let userTag = sonarrTags.find((v) => v.label.startsWith(entity.requestedBy.id + ' - ') ); // new tags do not have spaces around the hyphen, since spaces are not allowed anymore if (!userTag) { userTag = sonarrTags.find((v) => v.label.startsWith(entity.requestedBy.id + '-') ); } if (!userTag) { logger.info(`Requester has no active tag. Creating new`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, userId: entity.requestedBy.id, newTag: entity.requestedBy.id + '-' + sanitizeDisplayName(entity.requestedBy.displayName), }); userTag = await sonarr.createTag({ label: entity.requestedBy.id + '-' + sanitizeDisplayName(entity.requestedBy.displayName), }); } if (userTag.id) { if (!tags?.find((v) => v === userTag?.id)) { tags?.push(userTag.id); } } else { logger.warn(`Requester has no tag and failed to add one`, { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, userId: entity.requestedBy.id, sonarrServer: sonarrSettings.hostname + ':' + sonarrSettings.port, }); } } const sonarrSeriesOptions: AddSeriesOptions = { profileId: qualityProfile, languageProfileId: languageProfile, rootFolderPath: rootFolder, title: series.name, tvdbid: tvdbId, seasons: entity.seasons.map((season) => season.seasonNumber), seasonFolder: sonarrSettings.enableSeasonFolders, seriesType, tags, monitored: true, monitorNewItems: sonarrSettings.monitorNewItems, searchNow: !sonarrSettings.preventSearch, }; // Run entity asynchronously so we don't wait for it on the UI side sonarr .addSeries(sonarrSeriesOptions) .then(async (sonarrSeries) => { // We grab media again here to make sure we have the latest version of it const media = await mediaRepository.findOne({ where: { id: entity.media.id }, }); if (!media) { throw new Error('Media data not found'); } media[entity.is4k ? 'externalServiceId4k' : 'externalServiceId'] = sonarrSeries.id; media[ entity.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug' ] = sonarrSeries.titleSlug; media[entity.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id; await mediaRepository.save(media); }) .catch(async () => { try { const requestRepository = getRepository(MediaRequest); if (entity.status !== MediaRequestStatus.FAILED) { entity.status = MediaRequestStatus.FAILED; await requestRepository.save(entity); } } catch (saveError) { logger.error('Failed to mark request as FAILED', { label: 'Media Request', requestId: entity.id, errorMessage: saveError instanceof Error ? saveError.message : String(saveError), }); } logger.warn( 'Something went wrong sending series request to Sonarr, marking status as FAILED', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, sonarrSeriesOptions, } ); MediaRequest.sendNotification( entity, media, Notification.MEDIA_FAILED ); }) .finally(() => { sonarr.clearCache({ tvdbId, externalId: entity.is4k ? media.externalServiceId4k : media.externalServiceId, title: series.name, }); }); logger.info('Sent request to Sonarr', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); } catch (e) { const requestRepository = getRepository(MediaRequest); const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: entity.media.id }, }); if (media) { entity.status = MediaRequestStatus.FAILED; await requestRepository.save(entity); logger.warn( 'Failed to send series request to Sonarr due to connection or configuration error, marking status as FAILED', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, errorMessage: e.message, } ); MediaRequest.sendNotification( entity, media, Notification.MEDIA_FAILED ); } } } } public async updateParentStatus(entity: MediaRequest): Promise { const mediaRepository = getRepository(Media); const media = await mediaRepository.findOne({ where: { id: entity.media.id }, }); if (!media) { logger.error('Media data not found', { label: 'Media Request', requestId: entity.id, mediaId: entity.media.id, }); return; } const statusKey = entity.is4k ? 'status4k' : 'status'; const seasonRequestRepository = getRepository(SeasonRequest); const requestRepository = getRepository(MediaRequest); if ( entity.status === MediaRequestStatus.APPROVED && // Do not update the status if the item is already partially available or available media[statusKey] !== MediaStatus.AVAILABLE && media[statusKey] !== MediaStatus.PARTIALLY_AVAILABLE && media[statusKey] !== MediaStatus.PROCESSING ) { media[statusKey] = MediaStatus.PROCESSING; await mediaRepository.save(media); } if ( media.mediaType === MediaType.MOVIE && entity.status === MediaRequestStatus.DECLINED && media[statusKey] !== MediaStatus.DELETED ) { media[statusKey] = MediaStatus.UNKNOWN; await mediaRepository.save(media); } /** * If the media type is TV, and we are declining a request, * we must check if its the only pending request and that * there the current media status is just pending (meaning no * other requests have yet to be approved) */ if ( media.mediaType === MediaType.TV && entity.status === MediaRequestStatus.DECLINED && media[statusKey] === MediaStatus.PENDING ) { const pendingCount = await requestRepository.count({ where: { media: { id: media.id }, status: MediaRequestStatus.PENDING, is4k: entity.is4k, id: Not(entity.id), }, }); if (pendingCount === 0) { // Re-fetch media without requests to avoid cascade issues const freshMedia = await mediaRepository.findOne({ where: { id: media.id }, }); if (freshMedia) { freshMedia[statusKey] = MediaStatus.UNKNOWN; await mediaRepository.save(freshMedia); } } } // Reset season statuses when a TV request is declined if ( media.mediaType === MediaType.TV && entity.status === MediaRequestStatus.DECLINED ) { const seasonRepository = getRepository(Season); const actualSeasons = await seasonRepository.find({ where: { media: { id: media.id } }, }); for (const seasonRequest of entity.seasons) { seasonRequest.status = MediaRequestStatus.DECLINED; await seasonRequestRepository.save(seasonRequest); const season = actualSeasons.find( (s) => s.seasonNumber === seasonRequest.seasonNumber ); if (season && season[statusKey] === MediaStatus.PENDING) { const otherActiveRequests = await requestRepository .createQueryBuilder('request') .leftJoinAndSelect('request.seasons', 'season') .where('request.mediaId = :mediaId', { mediaId: media.id }) .andWhere('request.id != :requestId', { requestId: entity.id }) .andWhere('request.is4k = :is4k', { is4k: entity.is4k }) .andWhere('request.status NOT IN (:...statuses)', { statuses: [ MediaRequestStatus.DECLINED, MediaRequestStatus.COMPLETED, ], }) .andWhere('season.seasonNumber = :seasonNumber', { seasonNumber: season.seasonNumber, }) .getCount(); if (otherActiveRequests === 0) { season[statusKey] = MediaStatus.UNKNOWN; await seasonRepository.save(season); } } } } // Approve child seasons if parent is approved if ( media.mediaType === MediaType.TV && entity.status === MediaRequestStatus.APPROVED ) { entity.seasons.forEach((season) => { season.status = MediaRequestStatus.APPROVED; seasonRequestRepository.save(season); }); } } public async handleRemoveParentUpdate( manager: EntityManager, entity: MediaRequest ): Promise { const fullMedia = await manager.findOneOrFail(Media, { where: { id: entity.media.id }, relations: { requests: true }, }); const needsStatusUpdate = !fullMedia.requests.some((request) => !request.is4k) && fullMedia.status !== MediaStatus.AVAILABLE; const needs4kStatusUpdate = !fullMedia.requests.some((request) => request.is4k) && fullMedia.status4k !== MediaStatus.AVAILABLE; if (needsStatusUpdate || needs4kStatusUpdate) { // Re-fetch WITHOUT requests to avoid cascade issues on save const cleanMedia = await manager.findOneOrFail(Media, { where: { id: entity.media.id }, }); if (needsStatusUpdate) { cleanMedia.status = MediaStatus.UNKNOWN; } if (needs4kStatusUpdate) { cleanMedia.status4k = MediaStatus.UNKNOWN; } await manager.save(cleanMedia); } } public async afterUpdate(event: UpdateEvent): Promise { if (!event.entity) { return; } try { await this.sendToRadarr(event.entity as MediaRequest); await this.sendToSonarr(event.entity as MediaRequest); } catch (e) { logger.error('Error while sending to *arr in afterUpdate subscriber', { label: 'Media Request', requestId: (event.entity as MediaRequest).id, errorMessage: e instanceof Error ? e.message : String(e), }); } try { await this.updateParentStatus(event.entity as MediaRequest); if (event.entity.status === MediaRequestStatus.COMPLETED) { if (event.entity.media.mediaType === MediaType.MOVIE) { await this.notifyAvailableMovie(event.entity as MediaRequest, event); } if (event.entity.media.mediaType === MediaType.TV) { await this.notifyAvailableSeries(event.entity as MediaRequest, event); } } } catch (e) { logger.error( 'Error while updating parent status in afterUpdate subscriber', { label: 'Media Request', requestId: (event.entity as MediaRequest).id, errorMessage: e instanceof Error ? e.message : String(e), } ); } } public async afterInsert(event: InsertEvent): Promise { if (!event.entity) { return; } try { await this.sendToRadarr(event.entity as MediaRequest); await this.sendToSonarr(event.entity as MediaRequest); } catch (e) { logger.error('Error while sending to *arr in afterInsert subscriber', { label: 'Media Request', requestId: (event.entity as MediaRequest).id, errorMessage: e instanceof Error ? e.message : String(e), }); } try { await this.updateParentStatus(event.entity as MediaRequest); } catch (e) { logger.error( 'Error while updating parent status in afterInsert subscriber', { label: 'Media Request', requestId: (event.entity as MediaRequest).id, errorMessage: e instanceof Error ? e.message : String(e), } ); } } public async afterRemove(event: RemoveEvent): Promise { if (!event.entity) { return; } await this.handleRemoveParentUpdate( event.manager as EntityManager, event.entity as MediaRequest ); } public listenTo(): typeof MediaRequest { return MediaRequest; } } ================================================ FILE: server/subscriber/MediaSubscriber.ts ================================================ import { MediaRequestStatus, MediaStatus, MediaType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; import type { EntitySubscriberInterface, UpdateEvent } from 'typeorm'; import { EventSubscriber, In } from 'typeorm'; @EventSubscriber() export class MediaSubscriber implements EntitySubscriberInterface { private async updateChildRequestStatus(event: Media, is4k: boolean) { const requestRepository = getRepository(MediaRequest); const requests = await requestRepository.find({ where: { media: { id: event.id } }, }); for (const request of requests) { if ( request.is4k === is4k && request.status === MediaRequestStatus.PENDING ) { request.status = MediaRequestStatus.APPROVED; await requestRepository.save(request); } } } private async updateRelatedMediaRequest( event: Media, databaseEvent: Media, is4k: boolean ) { const requestRepository = getRepository(MediaRequest); const seasonRequestRepository = getRepository(SeasonRequest); const relatedRequests = await requestRepository.find({ relations: { media: true, }, where: { media: { id: event.id }, status: In([MediaRequestStatus.APPROVED, MediaRequestStatus.FAILED]), is4k, }, }); // Check the media entity status and if available // or deleted, set the related request to completed if (relatedRequests.length > 0) { const completedRequests: MediaRequest[] = []; for (const request of relatedRequests) { let shouldComplete = false; if ( (event[request.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || event[request.is4k ? 'status4k' : 'status'] === MediaStatus.DELETED) && event.mediaType === MediaType.MOVIE ) { shouldComplete = true; } else if (event.mediaType === 'tv') { const allSeasonResults = await Promise.all( request.seasons.map(async (requestSeason) => { const matchingSeason = event.seasons.find( (mediaSeason) => mediaSeason.seasonNumber === requestSeason.seasonNumber ); const matchingOldSeason = databaseEvent.seasons.find( (oldSeason) => oldSeason.seasonNumber === requestSeason.seasonNumber ); if (!matchingSeason) { return false; } const currentSeasonStatus = matchingSeason[request.is4k ? 'status4k' : 'status']; const previousSeasonStatus = matchingOldSeason?.[request.is4k ? 'status4k' : 'status']; const hasStatusChanged = currentSeasonStatus !== previousSeasonStatus; const shouldUpdate = (hasStatusChanged || requestSeason.status === MediaRequestStatus.COMPLETED) && (currentSeasonStatus === MediaStatus.AVAILABLE || currentSeasonStatus === MediaStatus.DELETED); if (shouldUpdate) { requestSeason.status = MediaRequestStatus.COMPLETED; await seasonRequestRepository.save(requestSeason); return true; } return false; }) ); const allSeasonsReady = allSeasonResults.every((result) => result); shouldComplete = allSeasonsReady; } if (shouldComplete) { request.status = MediaRequestStatus.COMPLETED; completedRequests.push(request); } } await requestRepository.save(completedRequests); } } public async beforeUpdate(event: UpdateEvent): Promise { if (!event.entity) { return; } if ( event.entity.status === MediaStatus.AVAILABLE && event.databaseEntity.status === MediaStatus.PENDING ) { this.updateChildRequestStatus(event.entity as Media, false); } if ( event.entity.status4k === MediaStatus.AVAILABLE && event.databaseEntity.status4k === MediaStatus.PENDING ) { this.updateChildRequestStatus(event.entity as Media, true); } // Manually load related seasons into databaseEntity // for seasonStatusCheck in afterUpdate const seasons = await event.manager .getRepository(Season) .createQueryBuilder('season') .leftJoin('season.media', 'media') .where('media.id = :id', { id: event.databaseEntity.id }) .getMany(); event.databaseEntity.seasons = seasons; } public async afterUpdate(event: UpdateEvent): Promise { if (!event.entity) { return; } const validStatuses = [ MediaStatus.PARTIALLY_AVAILABLE, MediaStatus.AVAILABLE, MediaStatus.DELETED, ]; const seasonStatusCheck = (is4k: boolean) => { return event.entity?.seasons?.some((season: Season, index: number) => { const previousSeason = event.databaseEntity.seasons[index]; return ( season[is4k ? 'status4k' : 'status'] !== previousSeason?.[is4k ? 'status4k' : 'status'] ); }); }; if ( (event.entity.status !== event.databaseEntity?.status || (event.entity.mediaType === MediaType.TV && seasonStatusCheck(false))) && validStatuses.includes(event.entity.status) ) { this.updateRelatedMediaRequest( event.entity as Media, event.databaseEntity as Media, false ); } if ( (event.entity.status4k !== event.databaseEntity?.status4k || (event.entity.mediaType === MediaType.TV && seasonStatusCheck(true))) && validStatuses.includes(event.entity.status4k) ) { this.updateRelatedMediaRequest( event.entity as Media, event.databaseEntity as Media, true ); } } public listenTo(): typeof Media { return Media; } } ================================================ FILE: server/templates/email/generatedpassword/html.pug ================================================ doctype html head meta(charset='utf-8') meta(name='x-apple-disable-message-reformatting') meta(http-equiv='x-ua-compatible' content='ie=edge') meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') style. .title:hover * { text-decoration: underline; } @media only screen and (max-width:600px) { table { font-size: 20px !important; width: 100% !important; } } div(style='display: block; background-color: #111827; padding: 2.5rem 0;') table(style='margin: 0 auto; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") if applicationUrl a(href=applicationUrl style='margin: 0 1rem;') img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') else div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') | #{applicationTitle} if recipientName !== recipientEmail tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | An account has been created for you at #{applicationTitle}. tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | Your password is: div(style='font-size: 1.25em; font-weight: 500; line-height: 2.25em;') span(style='padding: 0.5rem; font-weight: 500; border: 1px solid rgb(100,100,100); font-family: monospace;') | #{password} if applicationUrl tr td a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') | Open #{applicationTitle} ================================================ FILE: server/templates/email/generatedpassword/subject.pug ================================================ != `Account Information [${applicationTitle}]` ================================================ FILE: server/templates/email/media-issue/html.pug ================================================ doctype html head meta(charset='utf-8') meta(name='x-apple-disable-message-reformatting') meta(http-equiv='x-ua-compatible' content='ie=edge') meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') style. .title:hover * { text-decoration: underline; } @media only screen and (max-width:600px) { table { font-size: 20px !important; width: 100% !important; } } div(style='display: block; background-color: #111827; padding: 2.5rem 0;') table(style='margin: 0 auto; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") if applicationUrl a(href=applicationUrl style='margin: 0 1rem;') img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') else div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') | #{applicationTitle} if recipientName !== recipientEmail tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | #{body} if issueComment tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | #{issueComment} else if issueDescription tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | #{issueDescription} if actionUrl tr td a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') | View Issue in #{applicationTitle} ================================================ FILE: server/templates/email/media-issue/subject.pug ================================================ != `${event} - ${mediaName} [${applicationTitle}]` ================================================ FILE: server/templates/email/media-request/html.pug ================================================ doctype html head meta(charset='utf-8') meta(name='x-apple-disable-message-reformatting') meta(http-equiv='x-ua-compatible' content='ie=edge') meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') style. .title:hover * { text-decoration: underline; } @media only screen and (max-width:600px) { table { font-size: 20px !important; width: 100% !important; } } div(style='display: block; background-color: #111827; padding: 2.5rem 0;') table(style='margin: 0 auto; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") if applicationUrl a(href=applicationUrl style='margin: 0 1rem;') img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') else div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') | #{applicationTitle} if recipientName !== recipientEmail tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | #{body} tr td div(style='box-sizing: border-box; margin: 1.5rem 0 0; width: 100%; color: #fff; border-radius: .75rem; padding: 1rem; border: 1px solid rgb(100,100,100); background: linear-gradient(135deg, rgba(17,24,39,0.47) 0%, rgb(17,24,39) 75%), url(' + imageUrl + ') center 25%/cover') table(style='color: #fff; width: 100%;') tr td(style='vertical-align: top;') a(href=actionUrl style='display: block; max-width: 20rem; color: #fff; font-weight: 700; text-decoration: none; margin: 0 1rem 0.25rem 0; font-size: 1.3em; line-height: 1.25em; margin-bottom: 5px;' class='title') | #{mediaName} div(style='overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d1d5db; font-size: .975em; line-height: 1.45em; padding-top: .25rem; padding-bottom: .25rem;') span(style='display: block;') b(style='color: #9ca3af; font-weight: 700;') | Requested By  | #{requestedBy.replace(/\.|@/g, ((x) => x + '\ufeff'))} each extra in mediaExtra span(style='display: block;') b(style='color: #9ca3af; font-weight: 700;') | #{extra.name}  | #{extra.value} if imageUrl td(rowspan='2' style='width: 7rem;') a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl) div(style='overflow: hidden; box-sizing: border-box; margin: 0px;') img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;') tr td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem') span | #{timestamp} if actionUrl tr td a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') | View Media in #{applicationTitle} ================================================ FILE: server/templates/email/media-request/subject.pug ================================================ != `${event} - ${mediaName} [${applicationTitle}]` ================================================ FILE: server/templates/email/resetpassword/html.pug ================================================ doctype html head meta(charset='utf-8') meta(name='x-apple-disable-message-reformatting') meta(http-equiv='x-ua-compatible' content='ie=edge') meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') style. .title:hover * { text-decoration: underline; } @media only screen and (max-width:600px) { table { font-size: 20px !important; width: 100% !important; } } div(style='display: block; background-color: #111827; padding: 2.5rem 0;') table(style='margin: 0 auto; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") if applicationUrl a(href=applicationUrl style='margin: 0 1rem;') img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') else div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') | #{applicationTitle} if recipientName !== recipientEmail tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | A request has been received to change the password for your #{applicationTitle} account. tr td a(href=resetPasswordLink style='display: block; margin: 1.5rem 3rem; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') | Reset Password tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | The above link will expire in 24 hours. tr td(style='text-align: center;') div(style='margin: 1rem 1rem 0; font-size: 1.25em;') | If you did not initiate this request, you may safely disregard this message. ================================================ FILE: server/templates/email/resetpassword/subject.pug ================================================ != `Password Reset [${applicationTitle}]` ================================================ FILE: server/templates/email/test-email/html.pug ================================================ doctype html head meta(charset='utf-8') meta(name='x-apple-disable-message-reformatting') meta(http-equiv='x-ua-compatible' content='ie=edge') meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') style. .title:hover * { text-decoration: underline; } @media only screen and (max-width:600px) { table { font-size: 20px !important; width: 100% !important; } } div(style='display: block; background-color: #111827; padding: 2.5rem 0;') table(style='margin: 0 auto; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;') tr td(style="text-align: center;") if applicationUrl a(href=applicationUrl style='margin: 0 1rem;') img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;') else div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;') | #{applicationTitle} if recipientName !== recipientEmail tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}! tr td(style='text-align: center;') div(style='margin: 1rem 0 0; font-size: 1.25em;') | #{body} if applicationUrl tr td a(href=applicationUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') | Open #{applicationTitle} ================================================ FILE: server/templates/email/test-email/subject.pug ================================================ != `Test Notification [${applicationTitle}]` ================================================ FILE: server/test/db.ts ================================================ import { resetTestDb, seedTestDb } from '@server/utils/seedTestDb'; import { before, beforeEach } from 'node:test'; export function setupTestDb() { before(async () => { await seedTestDb(); }); beforeEach(async () => { await resetTestDb(); }); } ================================================ FILE: server/test/index.mts ================================================ // Runs unit tests using the `node:test` runner. import { Command, Option } from 'commander'; import { createWriteStream } from 'node:fs'; import { glob } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { run } from 'node:test'; import * as reporters from 'node:test/reporters'; import { fileURLToPath } from 'node:url'; const resolveImport = (specifier: string) => fileURLToPath(import.meta.resolve(specifier)); const BASE_DIR = join(import.meta.dirname, '../..'); const program = new Command(); program .name('test') .argument('[file...]', 'Test file(s) to run (default: all)') .option( '-m, --test-name-pattern ', 'Run tests matching the given pattern', (v, acc: string[]) => [...acc, v], [] as string[] ) .option( '--test-reporter ', 'Test reporter to use (repeatable)', (v, acc: string[]) => [...acc, v], [] as string[] ) .option( '--test-reporter-destination ', 'Test reporter destination: stdout, stderr, or a file path (repeatable)', (v, acc: string[]) => [...acc, v], [] as string[] ) .option( '--coverage, --experimental-test-coverage', 'Enable code coverage collection' ) // ignore additional options passed by vscode test runner .addOption(new Option('--test').hideHelp()) .parse(); const positionals: string[] = program.args; const opts = program.opts<{ testNamePattern: string[]; testReporter: string[]; testReporterDestination: string[]; experimentalTestCoverage: boolean; }>(); let files: string[]; if (positionals.length > 0) { files = positionals.map((f) => resolve(f)); } else { files = []; for await (const entry of glob(join(BASE_DIR, 'server/**/*.test.ts'))) { files.push(resolve(entry)); } files.sort(); } // @ts-ignore process.env.NODE_ENV = 'test'; // configure ts process.env.TS_NODE_PROJECT = resolveImport('../tsconfig.json'); process.env.TS_NODE_FILES = 'true'; const stream = run({ files, execArgv: [ '--experimental-test-module-mocks', '-r', 'ts-node/register', '-r', 'tsconfig-paths/register', '-r', resolveImport('./setup.ts'), ], coverage: opts.experimentalTestCoverage, coverageExcludeGlobs: [ join(BASE_DIR, 'server/test/**'), join(BASE_DIR, 'server/migration/**'), ], testNamePatterns: opts.testNamePattern, }); // In CI, write a JUnit report to a file for use by GitHub if (process.env.CI) { const reportStream = createWriteStream(join(BASE_DIR, 'report.xml')); stream.compose(reporters.junit).pipe(reportStream); } if (opts.testReporter.length > 0) { for (let i = 0; i < opts.testReporter.length; i++) { const reporterName = opts.testReporter[i]; // check built-in reporters, otherwise import const reporter = reporterName in reporters ? reporters[reporterName as keyof typeof reporters] : await import(reporterName).then((m) => m.default); if (reporter == null) { console.error('Invalid test reporter: ', reporterName); process.exit(1); } const destArg = opts.testReporterDestination[i]; const dest = destArg === 'stdout' || destArg == null ? process.stdout : destArg === 'stderr' ? process.stderr : createWriteStream(destArg); stream.compose(reporter).pipe(dest); } } else { stream.compose(reporters.spec).pipe(process.stdout); } ================================================ FILE: server/test/setup.ts ================================================ import logger from '@server/logger'; import { after, before } from 'node:test'; before(() => { if (process.env.VERBOSE != 'true') logger.silent = true; }); after(() => { if (process.env.VERBOSE != 'true') logger.silent = false; }); ================================================ FILE: server/tsconfig.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "target": "ES2020", "module": "commonjs", "outDir": "../dist", "noEmit": false, "incremental": true, "baseUrl": ".", "paths": { "@server/*": ["*"] } }, "include": ["**/*.ts"] } ================================================ FILE: server/types/custom.d.ts ================================================ declare module '@dr.pogodin/csurf' { import csrf from 'csurf'; export = csrf; } ================================================ FILE: server/types/error.ts ================================================ import type { ApiErrorCode } from '@server/constants/error'; export class ApiError extends Error { constructor( public statusCode: number, public errorCode: ApiErrorCode ) { super(); this.name = 'apiError'; } } ================================================ FILE: server/types/express-session.d.ts ================================================ import 'express-session'; // Declaration merging to apply our own types to SessionData // See: (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-session/index.d.ts#L23) declare module 'express-session' { interface SessionData { userId: number; } } ================================================ FILE: server/types/express.d.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import type { User } from '@server/entity/User'; import type { NextFunction, Request, Response } from 'express'; import 'express-session'; declare global { namespace Express { export interface Request { user?: User; locale?: string; } } export type Middleware = ( req: Request, res: Response, next: NextFunction ) => Promise | void | NextFunction; } ================================================ FILE: server/types/languages.d.ts ================================================ export type AvailableLocale = | 'ar' | 'bg' | 'ca' | 'cs' | 'da' | 'de' | 'en' | 'el' | 'es' | 'es-MX' | 'fi' | 'fr' | 'hr' | 'he' | 'hi' | 'hu' | 'it' | 'ja' | 'ko' | 'lb' | 'lt' | 'nb-NO' | 'nl' | 'pl' | 'pt-BR' | 'pt-PT' | 'ro' | 'ru' | 'sq' | 'sr' | 'sv' | 'tr' | 'uk' | 'zh-CN' | 'zh-TW' | 'vi'; ================================================ FILE: server/utils/DbColumnHelper.ts ================================================ import { isPgsql } from '@server/datasource'; import type { ColumnOptions, ColumnType } from 'typeorm'; import { Column } from 'typeorm'; const pgTypeMapping: { [key: string]: ColumnType } = { datetime: 'timestamp with time zone', }; export function resolveDbType(pgType: ColumnType): ColumnType { if (isPgsql && pgType.toString() in pgTypeMapping) { return pgTypeMapping[pgType.toString()]; } return pgType; } export function DbAwareColumn(columnOptions: ColumnOptions) { if (columnOptions.type) { columnOptions.type = resolveDbType(columnOptions.type); } return Column(columnOptions); } ================================================ FILE: server/utils/appDataVolume.ts ================================================ import { accessSync, existsSync } from 'fs'; import path from 'path'; const CONFIG_PATH = process.env.CONFIG_DIRECTORY ? process.env.CONFIG_DIRECTORY : path.join(__dirname, '../../config'); const DOCKER_PATH = `${CONFIG_PATH}/DOCKER`; export const appDataStatus = (): boolean => { return !existsSync(DOCKER_PATH); }; export const appDataPath = (): string => { return CONFIG_PATH; }; export const appDataPermissions = (): boolean => { try { accessSync(CONFIG_PATH); return true; } catch { return false; } }; ================================================ FILE: server/utils/appVersion.ts ================================================ import logger from '@server/logger'; import { existsSync } from 'fs'; import path from 'path'; const COMMIT_TAG_PATH = path.join(__dirname, '../../committag.json'); let commitTag = 'local'; if (existsSync(COMMIT_TAG_PATH)) { // eslint-disable-next-line @typescript-eslint/no-require-imports commitTag = require(COMMIT_TAG_PATH).commitTag; logger.info(`Commit Tag: ${commitTag}`); } export const getCommitTag = (): string => { return commitTag; }; export const getAppVersion = (): string => { // eslint-disable-next-line @typescript-eslint/no-require-imports const { version } = require('../../package.json'); let finalVersion = version; if (version === '0.1.0') { finalVersion = `develop-${getCommitTag()}`; } return finalVersion; }; ================================================ FILE: server/utils/asyncLock.ts ================================================ import { EventEmitter } from 'events'; // whenever you need to run async code on tv show or movie that does "get existing" / "check if need to create new" / "save" // then you need to put all of that code in "await asyncLock.dispatch" callback based on media id // this will guarantee that only one part of code will run at the same for this media id to avoid code // trying to create two or more entries for same movie/tvshow (which would result in sqlite unique constraint failrue) class AsyncLock { private locked: { [key: string]: boolean } = {}; private ee = new EventEmitter(); constructor() { this.ee.setMaxListeners(0); } private acquire = async (key: string) => { return new Promise((resolve) => { if (!this.locked[key]) { this.locked[key] = true; return resolve(undefined); } const nextAcquire = () => { if (!this.locked[key]) { this.locked[key] = true; this.ee.removeListener(key, nextAcquire); return resolve(undefined); } }; this.ee.on(key, nextAcquire); }); }; private release = (key: string): void => { delete this.locked[key]; setImmediate(() => this.ee.emit(key)); }; public dispatch = async ( key: string | number, callback: () => Promise ) => { const skey = String(key); await this.acquire(skey); try { await callback(); } finally { this.release(skey); } }; } export default AsyncLock; ================================================ FILE: server/utils/customProxyAgent.ts ================================================ import type { ProxySettings } from '@server/lib/settings'; import logger from '@server/logger'; import axios, { type InternalAxiosRequestConfig } from 'axios'; import { HttpProxyAgent } from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import type { Dispatcher } from 'undici'; import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; export let requestInterceptorFunction: ( config: InternalAxiosRequestConfig ) => InternalAxiosRequestConfig; export default async function createCustomProxyAgent( proxySettings: ProxySettings, forceIpv4First?: boolean ) { const defaultAgent = new Agent({ keepAliveTimeout: 5000, connections: 50, connect: forceIpv4First ? { family: 4 } : undefined, }); const skipUrl = (url: string | URL) => { const hostname = typeof url === 'string' ? new URL(url).hostname : url.hostname; if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) { return true; } for (const address of proxySettings.bypassFilter.split(',')) { const trimmedAddress = address.trim(); if (!trimmedAddress) { continue; } if (trimmedAddress.startsWith('*')) { const domain = trimmedAddress.slice(1); if (hostname.endsWith(domain)) { return true; } } else if (hostname === trimmedAddress) { return true; } } return false; }; const noProxyInterceptor = ( dispatch: Dispatcher['dispatch'] ): Dispatcher['dispatch'] => { return (opts, handler) => { return opts.origin && skipUrl(opts.origin) ? defaultAgent.dispatch(opts, handler) : dispatch(opts, handler); }; }; const token = proxySettings.user && proxySettings.password ? `Basic ${Buffer.from( `${proxySettings.user}:${proxySettings.password}` ).toString('base64')}` : undefined; try { const proxyUrl = `${proxySettings.useSsl ? 'https' : 'http'}://${ proxySettings.hostname }:${proxySettings.port}`; const proxyAgent = new ProxyAgent({ uri: proxyUrl, token, keepAliveTimeout: 5000, connections: 50, connect: forceIpv4First ? { family: 4 } : undefined, }); setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor)); const agentOptions = { headers: token ? { 'proxy-authorization': token } : undefined, keepAlive: true, maxSockets: 50, maxFreeSockets: 10, timeout: 5000, scheduling: 'lifo' as const, family: forceIpv4First ? 4 : undefined, }; axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, agentOptions); axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, agentOptions); requestInterceptorFunction = (config) => { const url = config.baseURL ? new URL(config.baseURL + (config.url || '')) : config.url; if (url && skipUrl(url)) { config.httpAgent = false; config.httpsAgent = false; } return config; }; axios.interceptors.request.use(requestInterceptorFunction); } catch (e) { logger.error('Failed to connect to the proxy: ' + e.message, { label: 'Proxy', }); setGlobalDispatcher(defaultAgent); return; } try { await axios.head('https://www.google.com'); logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' }); } catch (e) { logger.error( 'Failed to connect to the proxy: ' + e.message + ': ' + e.cause, { label: 'Proxy' } ); setGlobalDispatcher(defaultAgent); } } function isLocalAddress(hostname: string) { if ( hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' ) { return true; } const privateIpRanges = [ /^10\./, // 10.x.x.x /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.x.x - 172.31.x.x /^192\.168\./, // 192.168.x.x ]; if (privateIpRanges.some((regex) => regex.test(hostname))) { return true; } return false; } ================================================ FILE: server/utils/dateHelpers.ts ================================================ import { addYears } from 'date-fns'; import { Between } from 'typeorm'; export const AfterDate = (date: Date) => Between(date, addYears(date, 100)); ================================================ FILE: server/utils/dnsCache.ts ================================================ import logger from '@server/logger'; import { DnsCacheManager } from 'dns-caching'; export let dnsCache: DnsCacheManager | undefined; export function initializeDnsCache({ forceMinTtl, forceMaxTtl, }: { forceMinTtl?: number; forceMaxTtl?: number; }) { if (dnsCache) { logger.warn('DNS Cache is already initialized', { label: 'DNS Cache' }); return; } logger.info('Initializing DNS Cache', { label: 'DNS Cache' }); dnsCache = new DnsCacheManager({ logger, forceMinTtl: typeof forceMinTtl === 'number' ? forceMinTtl * 1000 : 0, forceMaxTtl: typeof forceMaxTtl === 'number' ? forceMaxTtl * 1000 : -1, }); dnsCache.initialize(); } ================================================ FILE: server/utils/getHostname.ts ================================================ import { getSettings } from '@server/lib/settings'; interface HostnameParams { useSsl?: boolean; ip?: string; port?: number; urlBase?: string; } export const getHostname = (params?: HostnameParams): string => { const settings = params ? params : getSettings().jellyfin; const { useSsl, ip, port, urlBase } = settings; const hostname = `${useSsl ? 'https' : 'http'}://${ip}:${port}${urlBase}`; return hostname; }; ================================================ FILE: server/utils/jellyfin.ts ================================================ export function normalizeJellyfinGuid( value: string | null | undefined ): string | null { if (!value) { return null; } const normalized = value.replace(/-/g, '').toLowerCase(); if (!/^[0-9a-f]{32}$/.test(normalized)) { return null; } return normalized; } ================================================ FILE: server/utils/profileMiddleware.ts ================================================ import { Permission } from '@server/lib/permissions'; export const isOwnProfile = (): Middleware => { return (req, res, next) => { if (req.user?.id !== Number(req.params.id)) { return next({ status: 403, message: "You do not have permission to view this user's settings.", }); } next(); }; }; export const isOwnProfileOrAdmin = (): Middleware => { const authMiddleware: Middleware = (req, res, next) => { if ( !req.user?.hasPermission(Permission.MANAGE_USERS) && req.user?.id !== Number(req.params.id) ) { return next({ status: 403, message: "You do not have permission to view this user's settings.", }); } next(); }; return authMiddleware; }; ================================================ FILE: server/utils/restartFlag.ts ================================================ import type { AllSettings, NetworkSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; class RestartFlag { private networkSettings: NetworkSettings; public initializeSettings(settings: AllSettings): void { this.networkSettings = { ...settings.network, proxy: { ...settings.network.proxy }, }; } public isSet(): boolean { const networkSettings = getSettings().network; return ( this.networkSettings.csrfProtection !== networkSettings.csrfProtection || this.networkSettings.trustProxy !== networkSettings.trustProxy || this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled ); } } const restartFlag = new RestartFlag(); export default restartFlag; ================================================ FILE: server/utils/seedTestDb.ts ================================================ import { UserType } from '@server/constants/user'; import dataSource, { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import gravatarUrl from 'gravatar-url'; export interface SeedDbOptions { /** If true, preserves existing data instead of dropping the database */ preserveDb?: boolean; /** If true, runs migrations instead of synchronizing schema */ withMigrations?: boolean; } // Precomputed bcrypt hash of 'test1234'. We precompute this to avoid // having to hash the password every time we seed the database. const TEST_USER_PASSWORD_HASH = '$2b$12$Z5V2P5HZgmx4/AnWFMZN1.aD5AM1NucNi.mhNTSQ9oVtmdzu7Le/a'; /** * Seeds test users into the database. * Assumes the database schema is already set up. */ async function seedTestUsers(): Promise { const userRepository = getRepository(User); const admin = await userRepository.findOne({ select: { id: true, plexId: true }, where: { id: 1 }, }); // Create the admin user const user = (await userRepository.findOne({ where: { email: 'admin@seerr.dev' }, })) ?? new User(); user.plexId = admin?.plexId ?? 1; user.plexToken = '1234'; user.plexUsername = 'admin'; user.username = 'admin'; user.email = 'admin@seerr.dev'; user.userType = UserType.PLEX; user.password = TEST_USER_PASSWORD_HASH; user.permissions = 2; user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 }); await userRepository.save(user); // Create the other user const otherUser = (await userRepository.findOne({ where: { email: 'friend@seerr.dev' }, })) ?? new User(); otherUser.plexId = admin?.plexId ?? 1; otherUser.plexToken = '1234'; otherUser.plexUsername = 'friend'; otherUser.username = 'friend'; otherUser.email = 'friend@seerr.dev'; otherUser.userType = UserType.PLEX; otherUser.password = TEST_USER_PASSWORD_HASH; otherUser.permissions = 32; otherUser.avatar = gravatarUrl('friend@seerr.dev', { default: 'mm', size: 200, }); await userRepository.save(otherUser); } /** * Initializes the database connection and seeds test users. * Used by both Cypress tests and Vitest unit tests. */ export async function seedTestDb(options: SeedDbOptions = {}): Promise { const dbConnection = dataSource.isInitialized ? dataSource : await dataSource.initialize(); if (!options.preserveDb) { await dbConnection.dropDatabase(); } if (options.withMigrations) { await dbConnection.runMigrations(); } else { await dbConnection.synchronize(); } await seedTestUsers(); } /** * Resets the database to a clean state with seeded test users. * Used between tests to ensure isolation. * Assumes DB has been initialized. */ export async function resetTestDb(): Promise { await dataSource.synchronize(true); await seedTestUsers(); } ================================================ FILE: server/utils/typeHelpers.ts ================================================ import type { TmdbCollectionResult, TmdbMovieDetails, TmdbMovieResult, TmdbPersonDetails, TmdbPersonResult, TmdbTvDetails, TmdbTvResult, } from '@server/api/themoviedb/interfaces'; export const isMovie = ( movie: | TmdbMovieResult | TmdbTvResult | TmdbPersonResult | TmdbCollectionResult ): movie is TmdbMovieResult => { return (movie as TmdbMovieResult).title !== undefined; }; export const isPerson = ( person: | TmdbMovieResult | TmdbTvResult | TmdbPersonResult | TmdbCollectionResult ): person is TmdbPersonResult => { return (person as TmdbPersonResult).known_for !== undefined; }; export const isCollection = ( collection: | TmdbMovieResult | TmdbTvResult | TmdbPersonResult | TmdbCollectionResult ): collection is TmdbCollectionResult => { return (collection as TmdbCollectionResult).media_type === 'collection'; }; export const isMovieDetails = ( movie: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails ): movie is TmdbMovieDetails => { return (movie as TmdbMovieDetails).title !== undefined; }; export const isTvDetails = ( tv: TmdbMovieDetails | TmdbTvDetails | TmdbPersonDetails ): tv is TmdbTvDetails => { return (tv as TmdbTvDetails).number_of_seasons !== undefined; }; ================================================ FILE: src/components/AirDateBadge/index.tsx ================================================ import Badge from '@app/components/Common/Badge'; import defineMessages from '@app/utils/defineMessages'; import { FormattedRelativeTime, useIntl } from 'react-intl'; const messages = defineMessages('components.AirDateBadge', { airedrelative: 'Aired {relativeTime}', airsrelative: 'Airing {relativeTime}', }); type AirDateBadgeProps = { airDate: string; }; const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { const WEEK = 1000 * 60 * 60 * 24 * 8; const intl = useIntl(); const dAirDate = new Date(airDate); const nowDate = new Date(); const alreadyAired = dAirDate.getTime() < nowDate.getTime(); const compareWeek = new Date( alreadyAired ? Date.now() - WEEK : Date.now() + WEEK ); let showRelative = false; if ( (alreadyAired && dAirDate.getTime() > compareWeek.getTime()) || (!alreadyAired && dAirDate.getTime() < compareWeek.getTime()) ) { showRelative = true; } const diffInDays = Math.round( (dAirDate.getTime() - nowDate.getTime()) / (1000 * 60 * 60 * 24) ); return (
{intl.formatDate(dAirDate, { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC', })} {showRelative && ( {intl.formatMessage( alreadyAired ? messages.airedrelative : messages.airsrelative, { relativeTime: ( ), } )} )}
); }; export default AirDateBadge; ================================================ FILE: src/components/AppDataWarning/index.tsx ================================================ import Alert from '@app/components/Common/Alert'; import defineMessages from '@app/utils/defineMessages'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; const messages = defineMessages('components.AppDataWarning', { dockerVolumeMissingDescription: 'The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.', }); const AppDataWarning = () => { const intl = useIntl(); const { data, error } = useSWR<{ appData: boolean; appDataPath: string }>( '/api/v1/status/appdata' ); if (!data && !error) { return null; } if (!data) { return null; } return ( <> {!data.appData && ( ( {msg} ), appDataPath: data.appDataPath, })} /> )} ); }; export default AppDataWarning; ================================================ FILE: src/components/Blocklist/index.tsx ================================================ import BlocklistedTagsBadge from '@app/components/BlocklistedTagsBadge'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import ConfirmButton from '@app/components/Common/ConfirmButton'; import Header from '@app/components/Common/Header'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import useDebouncedState from '@app/hooks/useDebouncedState'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import ErrorPage from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; import { ChevronLeftIcon, ChevronRightIcon, FunnelIcon, MagnifyingGlassIcon, TrashIcon, } from '@heroicons/react/24/solid'; import type { BlocklistItem, BlocklistResultsResponse, } from '@server/interfaces/api/blocklistInterfaces'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; import { useRouter } from 'next/router'; import type { ChangeEvent } from 'react'; import { useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; const messages = defineMessages('components.Blocklist', { blocklistsettings: 'Blocklist Settings', blocklistSettingsDescription: 'Manage blocklisted media.', mediaName: 'Name', mediaType: 'Type', mediaTmdbId: 'tmdb Id', blocklistdate: 'date', blocklistedby: '{date} by {user}', blocklistNotFoundError: '{title} is not blocklisted.', filterManual: 'Manual', filterBlocklistedTags: 'Blocklisted Tags', showAllBlocklisted: 'Show All Blocklisted Media', }); enum Filter { ALL = 'all', MANUAL = 'manual', BLOCKLISTEDTAGS = 'blocklistedTags', } const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; const Blocklist = () => { const [currentPageSize, setCurrentPageSize] = useState(10); const [searchFilter, debouncedSearchFilter, setSearchFilter] = useDebouncedState(''); const [currentFilter, setCurrentFilter] = useState(Filter.MANUAL); const router = useRouter(); const intl = useIntl(); const page = router.query.page ? Number(router.query.page) : 1; const pageIndex = page - 1; const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); const { data, error, mutate: revalidate, } = useSWR( `/api/v1/blocklist/?take=${currentPageSize}&skip=${ pageIndex * currentPageSize }&filter=${currentFilter}${ debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : '' }`, { refreshInterval: 0, revalidateOnFocus: false, } ); // check if there's no data and no errors in the table // so as to show a spinner inside the table and not refresh the whole component if (!data && error) { return ; } const searchItem = (e: ChangeEvent) => { // Remove the "page" query param from the URL // so that the "skip" query param on line 62 is empty // and the search returns results without skipping items if (router.query.page) router.replace(router.basePath); setSearchFilter(e.target.value as string); }; const hasNextPage = data && data.pageInfo.pages > pageIndex + 1; const hasPrevPage = pageIndex > 0; return ( <>
{intl.formatMessage(globalMessages.blocklist)}
searchItem(e)} />
{!data ? ( ) : data.results.length === 0 ? (
{intl.formatMessage(globalMessages.noresults)} {currentFilter !== Filter.ALL && (
)}
) : ( data.results.map((item: BlocklistItem) => { return (
); }) )}
); }; export default Blocklist; interface BlocklistedItemProps { item: BlocklistItem; revalidateList: () => void; } const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => { const [isUpdating, setIsUpdating] = useState(false); const { addToast } = useToasts(); const { ref, inView } = useInView({ triggerOnce: true, }); const intl = useIntl(); const { hasPermission } = useUser(); const url = item.mediaType === 'movie' ? `/api/v1/movie/${item.tmdbId}` : `/api/v1/tv/${item.tmdbId}`; const { data: title, error } = useSWR( inView ? url : null ); if (!title && !error) { return (
); } const removeFromBlocklist = async (tmdbId: number, title?: string) => { setIsUpdating(true); try { await axios.delete( `/api/v1/blocklist/${tmdbId}?mediaType=${item.mediaType}` ); addToast( {intl.formatMessage(globalMessages.removeFromBlocklistSuccess, { title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); } catch { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, }); } revalidateList(); setIsUpdating(false); }; return (
{title && title.backdropPath && (
)}
{title && (isMovie(title) ? title.releaseDate : title.firstAirDate )?.slice(0, 4)}
{title && (isMovie(title) ? title.title : title.name)}
Status {intl.formatMessage(globalMessages.blocklisted)}
{item.createdAt && (
{intl.formatMessage(globalMessages.blocklisted)} {intl.formatMessage(messages.blocklistedby, { date: ( ), user: item.user ? ( {item.user.displayName} ) : item.blocklistedTags ? ( ) : ( ??? ), })}
)}
{item.mediaType === 'movie' ? (
{intl.formatMessage(globalMessages.movie)}
) : (
{intl.formatMessage(globalMessages.tvshow)}
)}
{hasPermission(Permission.MANAGE_BLOCKLIST) && ( removeFromBlocklist( item.tmdbId, title && (isMovie(title) ? title.title : title.name) ) } confirmText={intl.formatMessage( isUpdating ? globalMessages.deleting : globalMessages.areyousure )} className={`w-full ${ isUpdating ? 'pointer-events-none opacity-50' : '' }`} > {intl.formatMessage(globalMessages.removefromBlocklist)} )}
); }; ================================================ FILE: src/components/BlocklistBlock/index.tsx ================================================ import BlocklistedTagsBadge from '@app/components/BlocklistedTagsBadge'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Tooltip from '@app/components/Common/Tooltip'; import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid'; import type { MediaType } from '@server/constants/media'; import type { Blocklist } from '@server/entity/Blocklist'; import axios from 'axios'; import Link from 'next/link'; import { useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; const messages = defineMessages('component.BlocklistBlock', { blocklistedby: 'Blocklisted By', blocklistdate: 'Blocklisted date', }); interface BlocklistBlockProps { tmdbId: number; mediaType: MediaType; onUpdate?: () => void; onDelete?: () => void; } const BlocklistBlock = ({ tmdbId, mediaType, onUpdate, onDelete, }: BlocklistBlockProps) => { const { user } = useUser(); const intl = useIntl(); const [isUpdating, setIsUpdating] = useState(false); const { addToast } = useToasts(); const { data } = useSWR( `/api/v1/blocklist/${tmdbId}?mediaType=${mediaType}` ); const removeFromBlocklist = async (tmdbId: number, title?: string) => { setIsUpdating(true); try { await axios.delete(`/api/v1/blocklist/${tmdbId}?mediaType=${mediaType}`); addToast( {intl.formatMessage(globalMessages.removeFromBlocklistSuccess, { title, strong: (msg: React.ReactNode) => {msg}, })} , { appearance: 'success', autoDismiss: true } ); } catch { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, }); } onUpdate?.(); onDelete?.(); setIsUpdating(false); }; if (!data) { return ( <> ); } return (
{data.user ? ( <> {data.user.displayName} ) : data.blocklistedTags ? ( <> {intl.formatMessage(messages.blocklistedby)}:  ) : null}
{intl.formatMessage(globalMessages.blocklisted)}
{intl.formatDate(data.createdAt, { year: 'numeric', month: 'long', day: 'numeric', })}
); }; export default BlocklistBlock; ================================================ FILE: src/components/BlocklistModal/index.tsx ================================================ import Modal from '@app/components/Common/Modal'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; interface BlocklistModalProps { tmdbId: number; type: 'movie' | 'tv' | 'collection'; show: boolean; onComplete?: () => void; onCancel?: () => void; isUpdating?: boolean; } const messages = defineMessages('component.BlocklistModal', { blocklisting: 'Blocklisting', }); const isMovie = ( movie: MovieDetails | TvDetails | null ): movie is MovieDetails => { if (!movie) return false; return (movie as MovieDetails).title !== undefined; }; const BlocklistModal = ({ tmdbId, type, show, onComplete, onCancel, isUpdating, }: BlocklistModalProps) => { const intl = useIntl(); const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { (async () => { if (!show) return; try { setError(null); const response = await axios.get(`/api/v1/${type}/${tmdbId}`); setData(response.data); } catch (err) { setError(err); } })(); }, [show, tmdbId, type]); return ( ); }; export default BlocklistModal; ================================================ FILE: src/components/BlocklistedTagsBadge/index.tsx ================================================ import Badge from '@app/components/Common/Badge'; import Tooltip from '@app/components/Common/Tooltip'; import defineMessages from '@app/utils/defineMessages'; import { TagIcon } from '@heroicons/react/20/solid'; import type { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces'; import type { Keyword } from '@server/models/common'; import axios from 'axios'; import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Settings', { blocklistedTagsText: 'Blocklisted Tags', }); interface BlocklistedTagsBadgeProps { data: BlocklistItem; } const BlocklistedTagsBadge = ({ data }: BlocklistedTagsBadgeProps) => { const [tagNamesBlocklistedFor, setTagNamesBlocklistedFor] = useState('Loading...'); const intl = useIntl(); useEffect(() => { if (!data.blocklistedTags) { return; } const keywordIds = data.blocklistedTags.slice(1, -1).split(','); Promise.all( keywordIds.map(async (keywordId) => { const { data } = await axios.get( `/api/v1/keyword/${keywordId}` ); return data?.name || `[Invalid: ${keywordId}]`; }) ).then((keywords) => { setTagNamesBlocklistedFor(keywords.join(', ')); }); }, [data.blocklistedTags]); return ( {intl.formatMessage(messages.blocklistedTagsText)} ); }; export default BlocklistedTagsBadge; ================================================ FILE: src/components/BlocklistedTagsSelector/index.tsx ================================================ import Modal from '@app/components/Common/Modal'; import Tooltip from '@app/components/Common/Tooltip'; import CopyButton from '@app/components/Settings/CopyButton'; import { encodeURIExtraParams } from '@app/hooks/useDiscover'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; import { ArrowDownIcon } from '@heroicons/react/24/solid'; import type { TmdbKeyword, TmdbKeywordSearchResponse, } from '@server/api/themoviedb/interfaces'; import type { Keyword } from '@server/models/common'; import axios from 'axios'; import { useFormikContext } from 'formik'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { useIntl } from 'react-intl'; import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select'; import { components } from 'react-select'; import AsyncSelect from 'react-select/async'; const messages = defineMessages('components.Settings', { copyBlocklistedTags: 'Copied blocklisted tags to clipboard.', copyBlocklistedTagsTip: 'Copy blocklisted tag configuration', copyBlocklistedTagsEmpty: 'Nothing to copy', importBlocklistedTagsTip: 'Import blocklisted tag configuration', clearBlocklistedTagsConfirm: 'Are you sure you want to clear the blocklisted tags?', yes: 'Yes', no: 'No', searchKeywords: 'Search keywords…', starttyping: 'Starting typing to search.', nooptions: 'No results.', blocklistedTagImportTitle: 'Import Blocklisted Tag Configuration', blocklistedTagImportInstructions: 'Paste blocklist tag configuration below.', valueRequired: 'You must provide a value.', noSpecialCharacters: 'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.', invalidKeyword: '{keywordId} is not a TMDB keyword.', }); type SingleVal = { label: string; value: number; }; type BlocklistedTagsSelectorProps = { defaultValue?: string; }; const BlocklistedTagsSelector = ({ defaultValue, }: BlocklistedTagsSelectorProps) => { const { setFieldValue } = useFormikContext(); const [value, setValue] = useState(defaultValue); const intl = useIntl(); const [selectorValue, setSelectorValue] = useState | null>(null); const update = useCallback( (value: MultiValue | null) => { const strVal = value?.map((v) => v.value).join(','); setSelectorValue(value); setValue(strVal); setFieldValue('blocklistedTags', strVal); }, [setSelectorValue, setValue, setFieldValue] ); const copyDisabled = value === null || value?.length === 0; return ( <> ); }; type BaseSelectorMultiProps = { defaultValue?: string; value: MultiValue | null; onChange: (value: MultiValue | null) => void; components?: Partial; }; const ControlledKeywordSelector = ({ defaultValue, onChange, components, value, }: BaseSelectorMultiProps) => { const intl = useIntl(); useEffect(() => { const loadDefaultKeywords = async (): Promise => { if (!defaultValue) { return; } const keywords = await Promise.all( defaultValue.split(',').map(async (keywordId) => { const { data } = await axios.get( `/api/v1/keyword/${keywordId}` ); return data; }) ); const validKeywords: TmdbKeyword[] = keywords.filter( (keyword): keyword is TmdbKeyword => keyword !== null ); onChange( validKeywords.map((keyword) => ({ label: keyword.name, value: keyword.id, })) ); }; loadDefaultKeywords(); }, [defaultValue, onChange]); const loadKeywordOptions = async (inputValue: string) => { const { data } = await axios.get( `/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}` ); return data.results.map((result) => ({ label: result.name, value: result.id, })); }; return ( inputValue === '' ? intl.formatMessage(messages.starttyping) : intl.formatMessage(messages.nooptions) } value={value} loadOptions={loadKeywordOptions} placeholder={intl.formatMessage(messages.searchKeywords)} onChange={onChange} components={components} /> ); }; type BlocklistedTagsImportButtonProps = { setSelector: (value: MultiValue) => void; }; const BlocklistedTagsImportButton = ({ setSelector, }: BlocklistedTagsImportButtonProps) => { const [show, setShow] = useState(false); const formRef = useRef(null); const intl = useIntl(); const onConfirm = useCallback(async () => { if (formRef.current) { if (await formRef.current.submitForm()) { setShow(false); } } }, []); const onClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setShow(true); }, []); return ( <> setShow(false)} > ); }; type BlocklistedTagImportFormProps = BlocklistedTagsImportButtonProps; const BlocklistedTagImportForm = forwardRef< Partial, BlocklistedTagImportFormProps >((props, ref) => { const { setSelector } = props; const intl = useIntl(); const [formValue, setFormValue] = useState(''); const [errors, setErrors] = useState([]); useImperativeHandle(ref, () => ({ submitForm: handleSubmit, formValue, })); const validate = async () => { if (formValue.length === 0) { setErrors([intl.formatMessage(messages.valueRequired)]); return false; } if (!/^(?:\d+,)*\d+$/.test(formValue)) { setErrors([intl.formatMessage(messages.noSpecialCharacters)]); return false; } const keywords = await Promise.allSettled( formValue.split(',').map(async (keywordId) => { try { const { data } = await axios.get( `/api/v1/keyword/${keywordId}` ); return { label: data.name, value: data.id, }; } catch { throw intl.formatMessage(messages.invalidKeyword, { keywordId }); } }) ); const failures = keywords.filter( (res) => res.status === 'rejected' ) as PromiseRejectedResult[]; if (failures.length > 0) { setErrors(failures.map((failure) => `${failure.reason}`)); return false; } setSelector( (keywords as PromiseFulfilledResult[]).map( (keyword) => keyword.value ) ); setErrors([]); return true; }; const handleSubmit = validate; return (