feat: 4K Requests (#559)

pull/633/head
sct 3 years ago committed by GitHub
parent 79629645aa
commit 6b2df24a2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

2
.gitignore vendored

@ -32,7 +32,7 @@ yarn-error.log*
.vercel
# database
config/db/db.sqlite3
config/db/*.sqlite3
config/settings.json
# logs

@ -36,14 +36,13 @@
- User profiles.
- User settings page (to give users the ability to modify their Overseerr experience to their liking).
- 4K requests (Includes multi-radarr/sonarr management for media)
- Local user system (for those who don't use Plex).
## Planned Features
- More notification types.
- Issues system. This will allow users to report issues with content on your media server.
- Local user system (for those who don't use Plex).
- Compatibility APIs (to work with existing tools in your system).
- And a ton more! Check out our [issue tracker](https://github.com/sct/overseerr/issues) to see what features people have already requested.
## Getting Started
@ -142,4 +141,5 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->

@ -701,6 +701,15 @@ components:
- $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
@ -2364,6 +2373,15 @@ paths:
type: array
items:
type: number
is4k:
type: boolean
example: false
serverId:
type: number
profileId:
type: number
rootFolder:
type: string
required:
- mediaType
- mediaId

@ -48,6 +48,23 @@ export interface PlexMetadata {
parentIndex?: number;
leafCount: number;
viewedLeafCount: 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 {

@ -80,6 +80,9 @@ class Media {
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status: MediaStatus;
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status4k: MediaStatus;
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
public requests: MediaRequest[];

@ -65,6 +65,18 @@ export class MediaRequest {
})
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;
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
@ -181,7 +193,11 @@ export class MediaRequest {
}
const seasonRequestRepository = getRepository(SeasonRequest);
if (this.status === MediaRequestStatus.APPROVED) {
media.status = MediaStatus.PROCESSING;
if (this.is4k) {
media.status4k = MediaStatus.PROCESSING;
} else {
media.status = MediaStatus.PROCESSING;
}
mediaRepository.save(media);
}
@ -189,7 +205,11 @@ export class MediaRequest {
this.media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED
) {
media.status = MediaStatus.UNKNOWN;
if (this.is4k) {
media.status4k = MediaStatus.UNKNOWN;
} else {
media.status = MediaStatus.UNKNOWN;
}
mediaRepository.save(media);
}
@ -224,15 +244,28 @@ export class MediaRequest {
}
@AfterRemove()
private async _handleRemoveParentUpdate() {
public async handleRemoveParentUpdate(): Promise<void> {
const mediaRepository = getRepository(Media);
const fullMedia = await mediaRepository.findOneOrFail({
where: { id: this.media.id },
relations: ['requests'],
});
if (!fullMedia.requests || fullMedia.requests.length === 0) {
if (
!fullMedia.requests.some((request) => !request.is4k) &&
fullMedia.status !== MediaStatus.AVAILABLE
) {
fullMedia.status = MediaStatus.UNKNOWN;
mediaRepository.save(fullMedia);
}
if (
!fullMedia.requests.some((request) => request.is4k) &&
fullMedia.status4k !== MediaStatus.AVAILABLE
) {
fullMedia.status4k = MediaStatus.UNKNOWN;
}
mediaRepository.save(fullMedia);
}
private async _sendToRadarr() {
@ -252,12 +285,14 @@ export class MediaRequest {
}
const radarrSettings = settings.radarr.find(
(radarr) => radarr.isDefault && !radarr.is4k
(radarr) => radarr.isDefault && this.is4k
);
if (!radarrSettings) {
logger.info(
'There is no default radarr configured. Did you set any of your Radarr servers as default?',
`There is no default ${
this.is4k ? '4K ' : ''
}radarr configured. Did you set any of your Radarr servers as default?`,
{ label: 'Media Request' }
);
return;
@ -342,12 +377,14 @@ export class MediaRequest {
}
const sonarrSettings = settings.sonarr.find(
(sonarr) => sonarr.isDefault && !sonarr.is4k
(sonarr) => sonarr.isDefault && this.is4k
);
if (!sonarrSettings) {
logger.info(
'There is no default sonarr configured. Did you set any of your Sonarr servers as default?',
`There is no default ${
this.is4k ? '4K ' : ''
}sonarr configured. Did you set any of your Sonarr servers as default?`,
{ label: 'Media Request' }
);
return;

@ -20,6 +20,9 @@ class Season {
@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' })
public media: Promise<Media>;

@ -98,6 +98,7 @@ app
};
next();
});
server.use('/api/v1', routes);
server.get('*', (req, res) => handle(req, res));
server.use(

@ -4,3 +4,9 @@ export interface SettingsAboutResponse {
totalMediaItems: number;
tz?: string;
}
export interface PublicSettingsResponse {
initialized: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
}

@ -45,6 +45,8 @@ class JobPlexSync {
private currentLibrary: Library;
private running = false;
private isRecentOnly = false;
private enable4kMovie = false;
private enable4kShow = false;
private asyncLock = new AsyncLock();
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
@ -86,23 +88,59 @@ class JobPlexSync {
}
});
const has4k = metadata.Media.some(
(media) => media.videoResolution === '4k'
);
const hasOtherResolution = metadata.Media.some(
(media) => media.videoResolution !== '4k'
);
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
const existing = await this.getExisting(
newMedia.tmdbId,
MediaType.MOVIE
);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${metadata.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
existing.status = MediaStatus.AVAILABLE;
mediaRepository.save(existing);
this.log(
`Request for ${metadata.title} exists. Setting status AVAILABLE`,
'info'
);
if (existing) {
let changedExisting = false;
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Request for ${metadata.title} exists. New media types set to AVAILABLE`,
'info'
);
} else {
this.log(
`Title already exists and no new media types found ${metadata.title}`
);
}
} else {
newMedia.status = MediaStatus.AVAILABLE;
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia);
this.log(`Saved ${plexitem.title}`);
@ -150,16 +188,47 @@ class JobPlexSync {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbMovieId, async () => {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${plexitem.title}`);
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
existing.status = MediaStatus.AVAILABLE;
await mediaRepository.save(existing);
this.log(
`Request for ${plexitem.title} exists. Setting status AVAILABLE`,
'info'
);
const has4k = metadata.Media.some(
(media) => media.videoResolution === '4k'
);
const hasOtherResolution = metadata.Media.some(
(media) => media.videoResolution !== '4k'
);
if (existing) {
let changedExisting = false;
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Request for ${metadata.title} exists. New media types set to AVAILABLE`,
'info'
);
} else {
this.log(
`Title already exists and no new media types found ${metadata.title}`
);
}
} else {
// If we have a tmdb movie guid but it didn't already exist, only then
// do we request the movie from tmdb (to reduce api requests)
@ -169,7 +238,14 @@ class JobPlexSync {
const newMedia = new Media();
newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
newMedia.tmdbId = tmdbMovie.id;
newMedia.status = MediaStatus.AVAILABLE;
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia);
this.log(`Saved ${tmdbMovie.title}`);
@ -316,13 +392,18 @@ class JobPlexSync {
const newSeasons: Season[] = [];
const currentSeasonAvailable = (
const currentStandardSeasonAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
seasons.forEach((season) => {
for (const season of seasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);
@ -332,68 +413,136 @@ class JobPlexSync {
);
// Check if we found the matching season and it has all the available episodes
if (
matchedPlexSeason &&
Number(matchedPlexSeason.leafCount) === season.episode_count
) {
if (existingSeason) {
existingSeason.status = MediaStatus.AVAILABLE;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number,
status: MediaStatus.AVAILABLE,
})
);
}
} else if (matchedPlexSeason) {
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) =>
episode.Media.some((media) => media.videoResolution !== '4k')
).length;
// Total episodes that are in 4k
const total4k = episodes.filter((episode) =>
episode.Media.some((media) => media.videoResolution === '4k')
).length;
if (existingSeason) {
existingSeason.status = MediaStatus.PARTIALLY_AVAILABLE;
// These ternary statements look super confusing, but they are simply
// setting the status to AVAILABLE if all of a type is there, partially if some,
// and then not modifying the status if there are 0 items
existingSeason.status =
totalStandard === season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
total4k === season.episode_count
? MediaStatus.AVAILABLE
: total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number,
status: MediaStatus.PARTIALLY_AVAILABLE,
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
// if we dont have any items for the season
status:
totalStandard === season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
total4k === season.episode_count
? MediaStatus.AVAILABLE
: total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
);
}
}
});
}
// Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0
);
const isAllSeasons =
newSeasons.length + (media?.seasons.length ?? 0) >=
const isAllStandardSeasons =
newSeasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
const isAll4kSeasons =
newSeasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newSeasonAvailable = (
const newStandardSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonAvailable = (
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 (newSeasonAvailable > currentSeasonAvailable) {
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
this.log(
`Detected ${
newSeasonAvailable - currentSeasonAvailable
} new season(s) for ${tvShow.name}`,
newStandardSeasonAvailable - currentStandardSeasonAvailable
} new standard season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
media.status = isAllSeasons
if (new4kSeasonAvailable > current4kSeasonAvailable) {
this.log(
`Detected ${
new4kSeasonAvailable - current4kSeasonAvailable
} new 4K season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
media.status = isAllStandardSeasons
? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE;
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k = isAll4kSeasons
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
@ -402,9 +551,20 @@ class JobPlexSync {
seasons: newSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
status: isAllSeasons
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k: isAll4kSeasons
? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE,
: newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
});
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
@ -508,6 +668,22 @@ class JobPlexSync {
(library) => library.enabled
);
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected, so 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, so 4K series detection is now enabled',
'info'
);
}
const hasHama = await this.hasHamaAgent();
if (hasHama) {
await animeList.sync();

@ -9,6 +9,9 @@ export enum Permission {
AUTO_APPROVE = 128,
AUTO_APPROVE_MOVIE = 256,
AUTO_APPROVE_TV = 512,
REQUEST_4K = 1024,
REQUEST_4K_MOVIE = 2048,
REQUEST_4K_TV = 4096,
}
/**

@ -55,6 +55,11 @@ interface PublicSettings {
initialized: boolean;
}
interface FullPublicSettings extends PublicSettings {
movie4kEnabled: boolean;
series4kEnabled: boolean;
}
export interface NotificationAgentConfig {
enabled: boolean;
types: number;
@ -246,6 +251,18 @@ class Settings {
this.data.public = data;
}
get fullPublicSettings(): FullPublicSettings {
return {
...this.data.public,
movie4kEnabled: this.data.radarr.some(
(radarr) => radarr.is4k && radarr.isDefault
),
series4kEnabled: this.data.sonarr.some(
(sonarr) => sonarr.is4k && sonarr.isDefault
),
};
}
get notifications(): NotificationSettings {
return this.data.notifications;
}

@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Add4kStatusFields1610370640747 implements MigrationInterface {
name = 'Add4kStatusFields1610370640747';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

@ -30,7 +30,7 @@ router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user);
router.get('/settings/public', (_req, res) => {
const settings = getSettings();
return res.status(200).json(settings.public);
return res.status(200).json(settings.fullPublicSettings);
});
router.use(
'/settings',

@ -110,15 +110,21 @@ requestRoutes.post(
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: tmdbMedia.external_ids.tvdb_id,
status: MediaStatus.PENDING,
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: req.body.mediaType,
});
await mediaRepository.save(media);
} else {
if (media.status === MediaStatus.UNKNOWN) {
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
media.status = MediaStatus.PENDING;
await mediaRepository.save(media);
}
if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
media.status4k = MediaStatus.PENDING;
await mediaRepository.save(media);
}
}
if (req.body.mediaType === 'movie') {
@ -137,6 +143,10 @@ requestRoutes.post(
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
? req.user
: undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
});
await requestRepository.save(request);
@ -149,13 +159,15 @@ requestRoutes.post(
// 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.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber
);
return [...seasons, ...combinedSeasons];
}, [] as number[]);
existingSeasons = media.requests
.filter((request) => request.is4k === req.body.is4k)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber
);
return [...seasons, ...combinedSeasons];
}, [] as number[]);
}
const finalSeasons = requestedSeasons.filter(
@ -186,6 +198,7 @@ requestRoutes.post(
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? req.user
: undefined,
is4k: req.body.is4k,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({

@ -0,0 +1,582 @@
import axios from 'axios';
import React, { useContext, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../../server/constants/media';
import Media from '../../../../server/entity/Media';
import { MediaRequest } from '../../../../server/entity/MediaRequest';
import { SettingsContext } from '../../../context/SettingsContext';
import { Permission, useUser } from '../../../hooks/useUser';
import ButtonWithDropdown from '../../Common/ButtonWithDropdown';
import RequestModal from '../../RequestModal';
const messages = defineMessages({
viewrequest: 'View Request',
viewrequest4k: 'View 4K Request',
request: 'Request',
request4k: 'Request 4K',
requestmore: 'Request More',
requestmore4k: 'Request More 4K',
approverequest: 'Approve Request',
approverequest4k: 'Approve 4K Request',
declinerequest: 'Decline Request',
declinerequest4k: 'Decline 4K Request',
approverequests:
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
declinerequests:
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
approve4krequests:
'Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
decline4krequests:
'Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
});
interface ButtonOption {
id: string;
text: string;
action: () => void;
svg?: React.ReactNode;
}
interface RequestButtonProps {
mediaType: 'movie' | 'tv';
onUpdate: () => void;
tmdbId: number;
media?: Media;
isShowComplete?: boolean;
is4kShowComplete?: boolean;
}
const RequestButton: React.FC<RequestButtonProps> = ({
tmdbId,
onUpdate,
media,
mediaType,
isShowComplete = false,
is4kShowComplete = false,
}) => {
const intl = useIntl();
const settings = useContext(SettingsContext);
const { hasPermission } = useUser();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showRequest4kModal, setShowRequest4kModal] = useState(false);
const activeRequest = media?.requests.find(
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
);
const active4kRequest = media?.requests.find(
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
);
// All pending
const activeRequests = media?.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
);
const active4kRequests = media?.requests.filter(
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
);
const modifyRequest = async (
request: MediaRequest,
type: 'approve' | 'decline'
) => {
const response = await axios.get(`/api/v1/request/${request.id}/${type}`);
if (response) {
onUpdate();
}
};
const modifyRequests = async (
requests: MediaRequest[],
type: 'approve' | 'decline'
): Promise<void> => {
if (!requests) {
return;
}
await Promise.all(
requests.map(async (request) => {
return axios.get(`/api/v1/request/${request.id}/${type}`);
})
);
onUpdate();
};
const buttons: ButtonOption[] = [];
if (
(!media || media.status === MediaStatus.UNKNOWN) &&
hasPermission(Permission.REQUEST)
) {
buttons.push({
id: 'request',
text: intl.formatMessage(messages.request),
action: () => {
setShowRequestModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
hasPermission(Permission.REQUEST) &&
mediaType === 'tv' &&
media &&
media.status !== MediaStatus.AVAILABLE &&
media.status !== MediaStatus.UNKNOWN &&
!isShowComplete
) {
buttons.push({
id: 'request-more',
text: intl.formatMessage(messages.requestmore),
action: () => {
setShowRequestModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
(!media || media.status4k === MediaStatus.UNKNOWN) &&
(hasPermission(Permission.REQUEST_4K) ||
(mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) ||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
(settings.currentSettings.series4kEnabled && mediaType === 'tv'))
) {
buttons.push({
id: 'request4k',
text: intl.formatMessage(messages.request4k),
action: () => {
setShowRequest4kModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
mediaType === 'tv' &&
(hasPermission(Permission.REQUEST_4K) ||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
media &&
media.status4k !== MediaStatus.AVAILABLE &&
media.status4k !== MediaStatus.UNKNOWN &&
!is4kShowComplete &&
settings.currentSettings.series4kEnabled
) {
buttons.push({
id: 'request-more-4k',
text: intl.formatMessage(messages.requestmore4k),
action: () => {
setShowRequest4kModal(true);
},
svg: (
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
});
}
if (
activeRequest &&
mediaType === 'movie' &&
hasPermission(Permission.REQUEST)
) {
buttons.push({
id: 'active-request',
text: intl.formatMessage(messages.viewrequest),
action: () => setShowRequestModal(true),
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
});
}
if (
active4kRequest &&
mediaType === 'movie' &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))
) {
buttons.push({
id: 'active-4k-request',
text: intl.formatMessage(messages.viewrequest4k),
action: () => setShowRequest4kModal(true),
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
),
});
}
if (
activeRequest &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'movie'
) {
buttons.push(
{
id: 'approve-request',
text: intl.formatMessage(messages.approverequest),
action: () => {
modifyRequest(activeRequest, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: 'decline-request',
text: intl.formatMessage(messages.declinerequest),
action: () => {
modifyRequest(activeRequest, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
}
);
}
if (
activeRequests &&
activeRequests.length > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'tv'
) {
buttons.push(
{
id: 'approve-request-batch',
text: intl.formatMessage(messages.approverequests, {
requestCount: activeRequests.length,
}),
action: () => {
modifyRequests(activeRequests, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: 'decline-request-batch',
text: intl.formatMessage(messages.declinerequests, {
requestCount: activeRequests.length,
}),
action: () => {
modifyRequests(activeRequests, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
}
);
}
if (
active4kRequest &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'movie'
) {
buttons.push(
{
id: 'approve-4k-request',
text: intl.formatMessage(messages.approverequest4k),
action: () => {
modifyRequest(active4kRequest, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: 'decline-4k-request',
text: intl.formatMessage(messages.declinerequest4k),
action: () => {
modifyRequest(active4kRequest, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
}
);
}
if (
active4kRequests &&
active4kRequests.length > 0 &&
hasPermission(Permission.MANAGE_REQUESTS) &&
mediaType === 'tv'
) {
buttons.push(
{
id: 'approve-request-batch',
text: intl.formatMessage(messages.approve4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'approve');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
),
},
{
id: 'decline-request-batch',
text: intl.formatMessage(messages.decline4krequests, {
requestCount: active4kRequests.length,
}),
action: () => {
modifyRequests(active4kRequests, 'decline');
},
svg: (
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
),
}
);
}
const [buttonOne, ...others] = buttons;
if (!buttonOne) {
return null;
}
return (
<>
<RequestModal
tmdbId={tmdbId}
show={showRequestModal}
type={mediaType}
onComplete={() => {
onUpdate();
setShowRequestModal(false);
}}
onCancel={() => setShowRequestModal(false)}
/>
<RequestModal
tmdbId={tmdbId}
show={showRequest4kModal}
type={mediaType}
is4k
onComplete={() => {
onUpdate();
setShowRequest4kModal(false);
}}
onCancel={() => setShowRequest4kModal(false)}
/>
<ButtonWithDropdown
text={
<>
{buttonOne.svg ?? null}
{buttonOne.text}
</>
}
onClick={buttonOne.action}
className="ml-2"
>
{others && others.length > 0
? others.map((button) => (
<ButtonWithDropdown.Item
onClick={button.action}
key={`request-option-${button.id}`}
>
{button.svg}
{button.text}
</ButtonWithDropdown.Item>
))
: null}
{/* {hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<ButtonWithDropdown.Item onClick={() => modifyRequest('approve')}>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{intl.formatMessage(messages.approve)}
</ButtonWithDropdown.Item>
</>
)} */}
</ButtonWithDropdown>
</>
);
};
export default RequestButton;

@ -18,12 +18,7 @@ import PersonCard from '../PersonCard';
import { LanguageContext } from '../../context/LanguageContext';
import LoadingSpinner from '../Common/LoadingSpinner';
import { useUser, Permission } from '../../hooks/useUser';
import {
MediaStatus,
MediaRequestStatus,
} from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import { MediaStatus } from '../../../server/constants/media';
import axios from 'axios';
import SlideOver from '../Common/SlideOver';
import RequestBlock from '../RequestBlock';
@ -38,6 +33,7 @@ import Head from 'next/head';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge';
import RequestButton from './RequestButton';
const messages = defineMessages({
releasedate: 'Release Date',
@ -55,8 +51,6 @@ const messages = defineMessages({
cancelrequest: 'Cancel Request',
available: 'Available',
unavailable: 'Unavailable',
request: 'Request',
viewrequest: 'View Request',
pending: 'Pending',
overviewunavailable: 'Overview unavailable',
manageModalTitle: 'Manage Movie',
@ -88,7 +82,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const router = useRouter();
const intl = useIntl();
const { locale } = useContext(LanguageContext);
const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState(false);
const { data, error, revalidate } = useSWR<MovieDetailsType>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`,
@ -118,25 +111,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return <Error statusCode={404} />;
}
const activeRequest = data?.mediaInfo?.requests?.find(
(request) => request.status === MediaRequestStatus.PENDING
);
const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size)
.pop()?.url;
const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get(
`/api/v1/request/${activeRequest?.id}/${type}`
);
if (response) {
revalidate();
}
};
const deleteMedia = async () => {
if (data?.mediaInfo?.id) {
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
@ -155,16 +134,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<Head>
<title>{data.title} - Overseerr</title>
</Head>
<RequestModal
tmdbId={data.id}
show={showRequestModal}
type="movie"
onComplete={() => {
revalidate();
setShowRequestModal(false);
}}
onCancel={() => setShowRequestModal(false)}
/>
<SlideOver
show={showManager}
title={intl.formatMessage(messages.manageModalTitle)}
@ -216,7 +186,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2">
<StatusBadge status={data.mediaInfo?.status} />
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="mr-2">
<StatusBadge status={data.mediaInfo?.status} />
</span>
)}
<span>
<StatusBadge status={data.mediaInfo?.status4k} is4k />
</span>
</div>
<h1 className="text-2xl lg:text-4xl">
{data.title}{' '}
@ -263,121 +240,12 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</Button>
</a>
)}
{(!data.mediaInfo ||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
<Button
buttonType="primary"
className="ml-2"
onClick={() => setShowRequestModal(true)}
>
{activeRequest ? (
<svg
className="w-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className="w-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
)}
<FormattedMessage {...messages.request} />
</Button>
)}
{activeRequest && (
<ButtonWithDropdown
dropdownIcon={
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
}
text={
<>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.viewrequest} />
</>
}
onClick={() => setShowRequestModal(true)}
className="ml-2"
>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<ButtonWithDropdown.Item
onClick={() => modifyRequest('approve')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{intl.formatMessage(messages.approve)}
</ButtonWithDropdown.Item>
<ButtonWithDropdown.Item
onClick={() => modifyRequest('decline')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
{intl.formatMessage(messages.decline)}
</ButtonWithDropdown.Item>
</>
)}
</ButtonWithDropdown>
)}
<RequestButton
mediaType="movie"
media={data.mediaInfo}
tmdbId={data.id}
onUpdate={() => revalidate()}
/>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"

@ -45,8 +45,8 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
<div className="block">
<div className="px-4 py-4">
<div className="flex items-center justify-between">
<div className="mr-6 flex-col items-center text-sm leading-5 text-gray-300 flex-1 min-w-0">
<div className="flex flex-nowrap mb-1 white">
<div className="flex-col items-center flex-1 min-w-0 mr-6 text-sm leading-5 text-gray-300">
<div className="flex mb-1 flex-nowrap white">
<svg
className="min-w-0 flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
fill="currentColor"
@ -59,7 +59,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
clipRule="evenodd"
/>
</svg>
<span className="truncate w-40 md:w-auto">
<span className="w-40 truncate md:w-auto">
{request.requestedBy.username}
</span>
</div>
@ -78,13 +78,13 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
clipRule="evenodd"
/>
</svg>
<span className="truncate w-40 md:w-auto">
<span className="w-40 truncate md:w-auto">
{request.modifiedBy?.username}
</span>
</div>
)}
</div>
<div className="ml-2 flex-shrink-0 flex flex-wrap">
<div className="flex flex-wrap flex-shrink-0 ml-2">
{request.status === MediaRequestStatus.PENDING && (
<>
<span className="mr-1">
@ -153,11 +153,11 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<div className="mr-6 flex items-center text-sm leading-5 text-gray-300">
{request.status === MediaRequestStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
<div className="flex items-center mr-6 text-sm leading-5 text-gray-300">
{request.is4k && (
<span className="mr-1">
<Badge badgeType="warning">4K</Badge>
</span>
)}
{request.status === MediaRequestStatus.APPROVED && (
<Badge badgeType="success">
@ -176,7 +176,7 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
)}
</div>
</div>
<div className="mt-2 flex items-center text-sm leading-5 text-gray-300 sm:mt-0">
<div className="flex items-center mt-2 text-sm leading-5 text-gray-300 sm:mt-0">
<svg
className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-300"
xmlns="http://www.w3.org/2000/svg"
@ -195,13 +195,13 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
</div>
</div>
{(request.seasons ?? []).length > 0 && (
<div className="mt-2 text-sm flex flex-col">
<div className="flex flex-col mt-2 text-sm">
<div className="mb-2">{intl.formatMessage(messages.seasons)}</div>
<div>
{request.seasons.map((season) => (
<span
key={`season-${season.id}`}
className="mr-2 mb-1 inline-block"
className="inline-block mb-1 mr-2"
>
<Badge>{season.seasonNumber}</Badge>
</span>

@ -28,7 +28,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
const RequestCardPlaceholder: React.FC = () => {
return (
<div className="w-72 sm:w-96 relative animate-pulse rounded-lg bg-gray-700 p-4">
<div className="relative p-4 bg-gray-700 rounded-lg w-72 sm:w-96 animate-pulse">
<div className="w-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }} />
</div>
@ -88,13 +88,13 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
return (
<div
className="relative w-72 sm:w-96 p-4 bg-gray-800 rounded-md flex bg-cover bg-center text-gray-400"
className="relative flex p-4 text-gray-400 bg-gray-800 bg-center bg-cover rounded-md w-72 sm:w-96"
style={{
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath})`,
}}
>
<div className="flex-1 pr-4 min-w-0 flex flex-col">
<h2 className="text-base sm:text-lg overflow-ellipsis overflow-hidden whitespace-nowrap text-white cursor-pointer hover:underline">
<div className="flex flex-col flex-1 min-w-0 pr-4">
<h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
<Link
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
as={
@ -106,18 +106,21 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
{isMovie(title) ? title.title : title.name}
</Link>
</h2>
<div className="text-xs sm:text-sm truncate">
<div className="text-xs truncate sm:text-sm">
{intl.formatMessage(messages.requestedby, {
username: requestData.requestedBy.username,
})}
</div>
{requestData.media.status && (
<div className="mt-1 sm:mt-2">
<StatusBadge status={requestData.media.status} />
<StatusBadge
status={requestData.media.status}
is4k={requestData.is4k}
/>
</div>
)}
{request.seasons.length > 0 && (
<div className="hidden mt-2 text-sm sm:flex items-center">
<div className="items-center hidden mt-2 text-sm sm:flex">
<span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
{!isMovie(title) &&
title.seasons.filter((season) => season.seasonNumber !== 0)
@ -126,7 +129,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
<Badge>{intl.formatMessage(messages.all)}</Badge>
</span>
) : (
<div className="hide-scrollbar overflow-x-scroll">
<div className="overflow-x-scroll hide-scrollbar">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
@ -138,7 +141,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="flex-1 flex items-end">
<div className="flex items-end flex-1">
<span className="mr-2">
<Button
buttonType="success"
@ -200,7 +203,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
alt=""
className="w-20 sm:w-28 rounded-md shadow-sm cursor-pointer transition transform-gpu duration-300 scale-100 hover:scale-105 hover:shadow-md"
className="w-20 transition duration-300 scale-100 rounded-md shadow-sm cursor-pointer sm:w-28 transform-gpu hover:scale-105 hover:shadow-md"
/>
</Link>
</div>

@ -22,17 +22,22 @@ const messages = defineMessages({
requestSuccess: '<strong>{title}</strong> successfully requested!',
requestCancel: 'Request for <strong>{title}</strong> cancelled',
requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K',
close: 'Close',
cancel: 'Cancel Request',
cancelling: 'Cancelling...',
pendingrequest: 'Pending request for {title}',
pending4krequest: 'Pending request for {title} in 4K',
requesting: 'Requesting...',
request: 'Request',
request4k: 'Request 4K',
requestfrom: 'There is currently a pending request from {username}',
request4kfrom: 'There is currently a pending 4K request from {username}',
});
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
tmdbId: number;
is4k?: boolean;
onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void;
@ -43,6 +48,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
onComplete,
tmdbId,
onUpdating,
is4k,
}) => {
const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts();
@ -63,6 +69,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: data?.id,
mediaType: 'movie',
is4k,
});
if (response.data) {
@ -89,7 +96,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
}
}, [data, onComplete, addToast]);
const activeRequest = data?.mediaInfo?.requests?.[0];
const activeRequest = data?.mediaInfo?.requests?.find(
(request) => request.is4k === !!is4k
);
const cancelRequest = async () => {
setIsUpdating(true);
@ -133,9 +142,12 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
onOk={isOwner ? () => cancelRequest() : undefined}
okDisabled={isUpdating}
title={intl.formatMessage(messages.pendingrequest, {
title: data?.title,
})}
title={intl.formatMessage(
is4k ? messages.pending4krequest : messages.pendingrequest,
{
title: data?.title,
}
)}
okText={
isUpdating
? intl.formatMessage(messages.cancelling)
@ -145,9 +157,12 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
cancelText={intl.formatMessage(messages.close)}
iconSvg={<DownloadIcon className="w-6 h-6" />}
>
{intl.formatMessage(messages.requestfrom, {
username: activeRequest.requestedBy.username,
})}
{intl.formatMessage(
is4k ? messages.request4kfrom : messages.requestfrom,
{
username: activeRequest.requestedBy.username,
}
)}
</Modal>
);
}
@ -159,11 +174,14 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
onOk={sendRequest}
okDisabled={isUpdating}
title={intl.formatMessage(messages.requesttitle, { title: data?.title })}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.title }
)}
okText={
isUpdating
? intl.formatMessage(messages.requesting)
: intl.formatMessage(messages.request)
: intl.formatMessage(is4k ? messages.request4k : messages.request)
}
okButtonType={'primary'}
iconSvg={<DownloadIcon className="w-6 h-6" />}

@ -23,6 +23,7 @@ const messages = defineMessages({
requestSuccess: '<strong>{title}</strong> successfully requested!',
requestCancel: 'Request for <strong>{title}</strong> cancelled',
requesttitle: 'Request {title}',
request4ktitle: 'Request {title} in 4K',
requesting: 'Requesting...',
requestseasons:
'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}',
@ -40,6 +41,7 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void;
is4k?: boolean;
}
const TvRequestModal: React.FC<RequestModalProps> = ({
@ -47,6 +49,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
onComplete,
tmdbId,
onUpdating,
is4k = false,
}) => {
const { addToast } = useToasts();
const { data, error } = useSWR<TvDetails>(`/api/v1/tv/${tmdbId}`);
@ -65,6 +68,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
mediaId: data?.id,
tvdbId: data?.externalIds.tvdbId,
mediaType: 'tv',
is4k,
seasons: selectedSeasons,
});
@ -90,21 +94,21 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
};
const getAllRequestedSeasons = (): number[] => {
const requestedSeasons = (data?.mediaInfo?.requests ?? []).reduce(
(requestedSeasons, request) => {
const requestedSeasons = (data?.mediaInfo?.requests ?? [])
.filter((request) => request.is4k === is4k)
.reduce((requestedSeasons, request) => {
return [
...requestedSeasons,
...request.seasons.map((sr) => sr.seasonNumber),
];
},
[] as number[]
);
}, [] as number[]);
const availableSeasons = (data?.mediaInfo?.seasons ?? [])
.filter(
(season) =>
(season.status === MediaStatus.AVAILABLE ||
season.status === MediaStatus.PARTIALLY_AVAILABLE) &&
(season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE ||
season[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE) &&
!requestedSeasons.includes(season.seasonNumber)
)
.map((season) => season.seasonNumber);
@ -176,14 +180,21 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
seasonNumber: number
): SeasonRequest | undefined => {
let seasonRequest: SeasonRequest | undefined;
if (data?.mediaInfo && (data.mediaInfo.requests || []).length > 0) {
data.mediaInfo.requests.forEach((request) => {
if (!seasonRequest) {
seasonRequest = request.seasons.find(
(season) => season.seasonNumber === seasonNumber
);
}
});
if (
data?.mediaInfo &&
(data.mediaInfo.requests || []).filter((request) => request.is4k === is4k)
.length > 0
) {
data.mediaInfo.requests
.filter((request) => request.is4k === is4k)
.forEach((request) => {
if (!seasonRequest) {
seasonRequest = request.seasons.find(
(season) => season.seasonNumber === seasonNumber
);
}
});
}
return seasonRequest;
@ -195,7 +206,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
backgroundClickable
onCancel={onCancel}
onOk={() => sendRequest()}
title={intl.formatMessage(messages.requesttitle, { title: data?.name })}
title={intl.formatMessage(
is4k ? messages.request4ktitle : messages.requesttitle,
{ title: data?.name }
)}
okText={
selectedSeasons.length === 0
? intl.formatMessage(messages.selectseason)
@ -256,13 +270,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</th>
<th className="px-1 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-1 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
{intl.formatMessage(messages.season)}
</th>
<th className="px-5 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-5 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
{intl.formatMessage(messages.numberofepisodes)}
</th>
<th className="px-2 md:px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
<th className="px-2 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500 md:px-6">
{intl.formatMessage(messages.status)}
</th>
</tr>
@ -275,7 +289,10 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
season.seasonNumber
);
const mediaSeason = data?.mediaInfo?.seasons.find(
(sn) => sn.seasonNumber === season.seasonNumber
(sn) =>
sn.seasonNumber === season.seasonNumber &&
sn[is4k ? 'status4k' : 'status'] !==
MediaStatus.UNKNOWN
);
return (
<tr key={`season-${season.id}`}>
@ -320,17 +337,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</td>
<td className="px-1 md:px-6 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
<td className="px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 whitespace-nowrap">
{season.seasonNumber === 0
? intl.formatMessage(messages.extras)
: intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber,
})}
</td>
<td className="px-5 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
<td className="px-5 py-4 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
{season.episodeCount}
</td>
<td className="pr-2 md:px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
<td className="py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6 whitespace-nowrap">
{!seasonRequest && !mediaSeason && (
<Badge>
{intl.formatMessage(messages.notrequested)}
@ -357,7 +374,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{mediaSeason?.status ===
{mediaSeason?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.PARTIALLY_AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(
@ -365,7 +382,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
)}
</Badge>
)}
{mediaSeason?.status === MediaStatus.AVAILABLE && (
{mediaSeason?.[is4k ? 'status4k' : 'status'] ===
MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>

@ -8,6 +8,7 @@ interface RequestModalProps {
show: boolean;
type: 'movie' | 'tv';
tmdbId: number;
is4k?: boolean;
onComplete?: (newStatus: MediaStatus) => void;
onError?: (error: string) => void;
onCancel?: () => void;
@ -18,6 +19,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
type,
show,
tmdbId,
is4k,
onComplete,
onUpdating,
onCancel,
@ -38,6 +40,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
tmdbId={tmdbId}
onUpdating={onUpdating}
is4k={is4k}
/>
</Transition>
);
@ -58,6 +61,7 @@ const RequestModal: React.FC<RequestModalProps> = ({
onCancel={onCancel}
tmdbId={tmdbId}
onUpdating={onUpdating}
is4k={is4k}
/>
</Transition>
);

@ -89,6 +89,30 @@ const SettingsMain: React.FC = () => {
description: intl.formatMessage(permissionMessages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'request4k',
name: intl.formatMessage(permissionMessages.request4k),
description: intl.formatMessage(permissionMessages.request4kDescription),
permission: Permission.REQUEST_4K,
children: [
{
id: 'request4k-movies',
name: intl.formatMessage(permissionMessages.request4kMovies),
description: intl.formatMessage(
permissionMessages.request4kMoviesDescription
),
permission: Permission.REQUEST_4K_MOVIE,
},
{
id: 'request4k-tv',
name: intl.formatMessage(permissionMessages.request4kTv),
description: intl.formatMessage(
permissionMessages.request4kTvDescription
),
permission: Permission.REQUEST_4K_TV,
},
],
},
{
id: 'autoapprove',
name: intl.formatMessage(permissionMessages.autoapprove),

@ -35,7 +35,6 @@ const messages = defineMessages({
nodefault: 'No default server selected!',
nodefaultdescription:
'At least one server must be marked as default before any requests will make it to your services.',
no4kimplemented: '(Default 4K servers are not currently implemented)',
});
interface ServerInstanceProps {
@ -63,10 +62,10 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
}) => {
return (
<li className="col-span-1 bg-gray-700 rounded-lg shadow">
<div className="w-full flex items-center justify-between p-6 space-x-6">
<div className="flex items-center justify-between w-full p-6 space-x-6">
<div className="flex-1 truncate">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-white text-sm leading-5 font-medium truncate">
<div className="flex items-center mb-2 space-x-3">
<h3 className="text-sm font-medium leading-5 text-white truncate">
{name}
</h3>
{isDefault && (
@ -85,31 +84,31 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</Badge>
)}
</div>
<p className="mt-1 text-gray-300 text-sm leading-5 truncate">
<span className="font-bold mr-2">
<p className="mt-1 text-sm leading-5 text-gray-300 truncate">
<span className="mr-2 font-bold">
<FormattedMessage {...messages.address} />
</span>
{address}
</p>
<p className="mt-1 text-gray-300 text-sm leading-5 truncate">
<span className="font-bold mr-2">
<p className="mt-1 text-sm leading-5 text-gray-300 truncate">
<span className="mr-2 font-bold">
<FormattedMessage {...messages.activeProfile} />
</span>{' '}
{profileName}
</p>
</div>
<img
className="w-10 h-10 flex-shrink-0"
className="flex-shrink-0 w-10 h-10"
src={`/images/${isSonarr ? 'sonarr' : 'radarr'}_logo.png`}
alt=""
/>
</div>
<div className="border-t border-gray-800">
<div className="-mt-px flex">
<div className="w-0 flex-1 flex border-r border-gray-800">
<div className="flex -mt-px">
<div className="flex flex-1 w-0 border-r border-gray-800">
<button
onClick={() => onEdit()}
className="relative -mr-px w-0 flex-1 inline-flex items-center justify-center py-4 text-sm leading-5 text-gray-200 font-medium border border-transparent rounded-bl-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10 transition ease-in-out duration-150"
className="relative inline-flex items-center justify-center flex-1 w-0 py-4 -mr-px text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out border border-transparent rounded-bl-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10"
>
<svg
className="w-5 h-5"
@ -124,10 +123,10 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</span>
</button>
</div>
<div className="-ml-px w-0 flex-1 flex">
<div className="flex flex-1 w-0 -ml-px">
<button
onClick={() => onDelete()}
className="relative w-0 flex-1 inline-flex items-center justify-center py-4 text-sm leading-5 text-gray-200 font-medium border border-transparent rounded-br-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10 transition ease-in-out duration-150"
className="relative inline-flex items-center justify-center flex-1 w-0 py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out border border-transparent rounded-br-lg hover:text-white focus:outline-none focus:ring-blue focus:border-gray-500 focus:z-10"
>
<svg
className="w-5 h-5"
@ -200,10 +199,10 @@ const SettingsServices: React.FC = () => {
return (
<>
<div>
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
<FormattedMessage {...messages.radarrsettings} />
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
<FormattedMessage {...messages.radarrSettingsDescription} />
</p>
</div>
@ -262,9 +261,6 @@ const SettingsServices: React.FC = () => {
) && (
<Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
<p className="mt-2">
{intl.formatMessage(messages.no4kimplemented)}
</p>
</Alert>
)}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@ -287,7 +283,7 @@ const SettingsServices: React.FC = () => {
}
/>
))}
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
<li className="h-32 col-span-1 border-2 border-gray-400 border-dashed rounded-lg shadow sm:h-32">
<div className="flex items-center justify-center w-full h-full">
<Button
buttonType="ghost"
@ -316,10 +312,10 @@ const SettingsServices: React.FC = () => {
)}
</div>
<div className="mt-10">
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
<FormattedMessage {...messages.sonarrsettings} />
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
<FormattedMessage {...messages.sonarrSettingsDescription} />
</p>
</div>
@ -333,9 +329,6 @@ const SettingsServices: React.FC = () => {
) && (
<Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
<p className="mt-2">
{intl.formatMessage(messages.no4kimplemented)}
</p>
</Alert>
)}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@ -359,7 +352,7 @@ const SettingsServices: React.FC = () => {
}
/>
))}
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
<li className="h-32 col-span-1 border-2 border-gray-400 border-dashed rounded-lg shadow sm:h-32">
<div className="flex items-center justify-center w-full h-full">
<Button
buttonType="ghost"

@ -1,16 +1,60 @@
import React from 'react';
import { MediaStatus } from '../../../server/constants/media';
import Badge from '../Common/Badge';
import { useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
status4k: '4K {status}',
});
interface StatusBadgeProps {
status?: MediaStatus;
is4k?: boolean;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, is4k }) => {
const intl = useIntl();
if (is4k) {
switch (status) {
case MediaStatus.AVAILABLE:
return (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.available),
})}
</Badge>
);
case MediaStatus.PARTIALLY_AVAILABLE:
return (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.partiallyavailable),
})}
</Badge>
);
case MediaStatus.PROCESSING:
return (
<Badge badgeType="primary">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.requested),
})}
</Badge>
);
case MediaStatus.PENDING:
return (
<Badge badgeType="warning">
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.pending),
})}
</Badge>
);
default:
return null;
}
}
switch (status) {
case MediaStatus.AVAILABLE:
return (

@ -19,7 +19,6 @@ import { useUser, Permission } from '../../hooks/useUser';
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import axios from 'axios';
import SlideOver from '../Common/SlideOver';
import RequestBlock from '../RequestBlock';
@ -36,6 +35,7 @@ import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import { Crew } from '../../../server/models/common';
import StatusBadge from '../StatusBadge';
import RequestButton from '../MovieDetails/RequestButton';
const messages = defineMessages({
firstAirDate: 'First Air Date',
@ -50,14 +50,8 @@ const messages = defineMessages({
watchtrailer: 'Watch Trailer',
available: 'Available',
unavailable: 'Unavailable',
request: 'Request',
requestmore: 'Request More',
pending: 'Pending',
overviewunavailable: 'Overview unavailable',
approverequests:
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
declinerequests:
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
manageModalTitle: 'Manage Series',
manageModalRequests: 'Requests',
manageModalNoRequests: 'No Requests',
@ -83,13 +77,6 @@ interface SearchResult {
results: TvResult[];
}
enum MediaRequestStatus {
PENDING = 1,
APPROVED,
DECLINED,
AVAILABLE,
}
const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const { hasPermission } = useUser();
const router = useRouter();
@ -126,29 +113,11 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
return <Error statusCode={404} />;
}
const activeRequests = data.mediaInfo?.requests?.filter(
(request) => request.status === MediaRequestStatus.PENDING
);
const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size)
.pop()?.url;
const modifyRequests = async (type: 'approve' | 'decline'): Promise<void> => {
if (!activeRequests) {
return;
}
await Promise.all(
activeRequests.map(async (request) => {
return axios.get(`/api/v1/request/${request.id}/${type}`);
})
);
revalidate();
};
const deleteMedia = async () => {
if (data?.mediaInfo?.id) {
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
@ -164,6 +133,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
) ?? []
).length;
const is4kComplete =
data.seasons.filter((season) => season.seasonNumber !== 0).length <=
(
data.mediaInfo?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
return (
<div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover"
@ -236,7 +213,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2">
<StatusBadge status={data.mediaInfo?.status} />
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="mr-2">
<StatusBadge status={data.mediaInfo?.status} />
</span>
)}
<span>
<StatusBadge status={data.mediaInfo?.status4k} is4k />
</span>
</div>
<h1 className="text-2xl lg:text-4xl">
<span>{data.name}</span>
@ -278,116 +262,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</Button>
</a>
)}
{(!data.mediaInfo ||
data.mediaInfo.status === MediaStatus.UNKNOWN) && (
<Button
className="ml-2"
buttonType="primary"
onClick={() => setShowRequestModal(true)}
>
<svg
className="w-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<FormattedMessage {...messages.request} />
</Button>
)}
{data.mediaInfo &&
data.mediaInfo.status !== MediaStatus.UNKNOWN &&
!isComplete && (
<ButtonWithDropdown
dropdownIcon={
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
}
text={
<>
<svg
className="w-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage {...messages.requestmore} />
</>
}
className="ml-2"
onClick={() => setShowRequestModal(true)}
>
{hasPermission(Permission.MANAGE_REQUESTS) &&
activeRequests &&
activeRequests.length > 0 && (
<>
<ButtonWithDropdown.Item
onClick={() => modifyRequests('approve')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage
{...messages.approverequests}
values={{ requestCount: activeRequests.length }}
/>
</ButtonWithDropdown.Item>
<ButtonWithDropdown.Item
onClick={() => modifyRequests('decline')}
>
<svg
className="w-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
<FormattedMessage
{...messages.declinerequests}
values={{ requestCount: activeRequests.length }}
/>
</ButtonWithDropdown.Item>
</>
)}
</ButtonWithDropdown>
)}
<RequestButton
mediaType="tv"
onUpdate={() => revalidate()}
tmdbId={data?.id}
media={data?.mediaInfo}
isShowComplete={isComplete}
is4kShowComplete={is4kComplete}
/>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"

@ -41,6 +41,12 @@ export const messages = defineMessages({
autoapproveSeries: 'Auto Approve Series',
autoapproveSeriesDescription:
'Grants auto approve for series requests made by this user.',
request4k: 'Request 4K',
request4kDescription: 'Grants permission to request 4K movies and series.',
request4kMovies: 'Request 4K Movies',
request4kMoviesDescription: 'Grants permission to request 4K movies.',
request4kTv: 'Request 4K Series',
request4kTvDescription: 'Grants permission to request 4K Series.',
save: 'Save',
saving: 'Saving...',
usersaved: 'User saved',
@ -127,6 +133,26 @@ const UserEdit: React.FC = () => {
description: intl.formatMessage(messages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'request4k',
name: intl.formatMessage(messages.request4k),
description: intl.formatMessage(messages.request4kDescription),
permission: Permission.REQUEST_4K,
children: [
{
id: 'request4k-movies',
name: intl.formatMessage(messages.request4kMovies),
description: intl.formatMessage(messages.request4kMoviesDescription),
permission: Permission.REQUEST_4K_MOVIE,
},
{
id: 'request4k-tv',
name: intl.formatMessage(messages.request4kTv),
description: intl.formatMessage(messages.request4kTvDescription),
permission: Permission.REQUEST_4K_TV,
},
],
},
{
id: 'autoapprove',
name: intl.formatMessage(messages.autoapprove),

@ -0,0 +1,39 @@
import React from 'react';
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
import useSWR from 'swr';
interface SettingsContextProps {
currentSettings: PublicSettingsResponse;
}
const defaultSettings = {
initialized: false,
movie4kEnabled: false,
series4kEnabled: false,
};
export const SettingsContext = React.createContext<SettingsContextProps>({
currentSettings: defaultSettings,
});
export const SettingsProvider: React.FC<SettingsContextProps> = ({
children,
currentSettings,
}) => {
const { data, error } = useSWR<PublicSettingsResponse>(
'/api/v1/settings/public',
{ initialData: currentSettings }
);
let newSettings = defaultSettings;
if (data && !error) {
newSettings = data;
}
return (
<SettingsContext.Provider value={{ currentSettings: newSettings }}>
{children}
</SettingsContext.Provider>
);
};

@ -29,6 +29,20 @@
"components.Login.signinplex": "Sign in to continue",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
"components.MovieDetails.RequestButton.approve4krequests": "Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}",
"components.MovieDetails.RequestButton.approverequest": "Approve Request",
"components.MovieDetails.RequestButton.approverequest4k": "Approve 4K Request",
"components.MovieDetails.RequestButton.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.MovieDetails.RequestButton.decline4krequests": "Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}",
"components.MovieDetails.RequestButton.declinerequest": "Decline Request",
"components.MovieDetails.RequestButton.declinerequest4k": "Decline 4K Request",
"components.MovieDetails.RequestButton.declinerequests": "Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.MovieDetails.RequestButton.request": "Request",
"components.MovieDetails.RequestButton.request4k": "Request 4K",
"components.MovieDetails.RequestButton.requestmore": "Request More",
"components.MovieDetails.RequestButton.requestmore4k": "Request More 4K",
"components.MovieDetails.RequestButton.viewrequest": "View Request",
"components.MovieDetails.RequestButton.viewrequest4k": "View 4K Request",
"components.MovieDetails.approve": "Approve",
"components.MovieDetails.available": "Available",
"components.MovieDetails.budget": "Budget",
@ -47,7 +61,6 @@
"components.MovieDetails.recommendations": "Recommendations",
"components.MovieDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.MovieDetails.releasedate": "Release Date",
"components.MovieDetails.request": "Request",
"components.MovieDetails.revenue": "Revenue",
"components.MovieDetails.runtime": "{minutes} minutes",
"components.MovieDetails.similar": "Similar Titles",
@ -58,7 +71,6 @@
"components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.view": "View",
"components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.NotificationTypeSelector.mediaapproved": "Media Approved",
"components.NotificationTypeSelector.mediaapprovedDescription": "Sends a notification when media is approved.",
@ -105,8 +117,12 @@
"components.RequestModal.extras": "Extras",
"components.RequestModal.notrequested": "Not Requested",
"components.RequestModal.numberofepisodes": "# of Episodes",
"components.RequestModal.pending4krequest": "Pending request for {title} in 4K",
"components.RequestModal.pendingrequest": "Pending request for {title}",
"components.RequestModal.request": "Request",
"components.RequestModal.request4k": "Request 4K",
"components.RequestModal.request4kfrom": "There is currently a pending 4K request from {username}",
"components.RequestModal.request4ktitle": "Request {title} in 4K",
"components.RequestModal.requestCancel": "Request for <strong>{title}</strong> cancelled",
"components.RequestModal.requestSuccess": "<strong>{title}</strong> requested.",
"components.RequestModal.requestadmin": "Your request will be immediately approved.",
@ -303,7 +319,6 @@
"components.Settings.menuPlexSettings": "Plex",
"components.Settings.menuServices": "Services",
"components.Settings.nextexecution": "Next Execution",
"components.Settings.no4kimplemented": "(Default 4K servers are not currently implemented)",
"components.Settings.nodefault": "No default server selected!",
"components.Settings.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.",
"components.Settings.notificationsettings": "Notification Settings",
@ -344,6 +359,7 @@
"components.Setup.tip": "Tip",
"components.Setup.welcome": "Welcome to Overseerr",
"components.Slider.noresults": "No Results",
"components.StatusBadge.status4k": "4K {status}",
"components.StatusChacker.newversionDescription": "An update is now available. Click the button below to reload the application.",
"components.StatusChacker.newversionavailable": "New Version Available",
"components.StatusChacker.reloadOverseerr": "Reload Overseerr",
@ -353,12 +369,10 @@
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
"components.TvDetails.anime": "Anime",
"components.TvDetails.approve": "Approve",
"components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.available": "Available",
"components.TvDetails.cancelrequest": "Cancel Request",
"components.TvDetails.cast": "Cast",
"components.TvDetails.decline": "Decline",
"components.TvDetails.declinerequests": "Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.firstAirDate": "First Air Date",
"components.TvDetails.manageModalClearMedia": "Clear All Media Data",
"components.TvDetails.manageModalClearMediaWarning": "This will remove all media data including all requests for this item, irreversibly. If this item exists in your Plex library, the media info will be recreated next sync.",
@ -372,8 +386,6 @@
"components.TvDetails.pending": "Pending",
"components.TvDetails.recommendations": "Recommendations",
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.TvDetails.request": "Request",
"components.TvDetails.requestmore": "Request More",
"components.TvDetails.showtype": "Show Type",
"components.TvDetails.similar": "Similar Series",
"components.TvDetails.similarsubtext": "Other series similar to {title}",
@ -397,6 +409,12 @@
"components.UserEdit.managerequestsDescription": "Grants permission to manage Overseerr requests. This includes approving and denying requests.",
"components.UserEdit.permissions": "Permissions",
"components.UserEdit.request": "Request",
"components.UserEdit.request4k": "Request 4K",
"components.UserEdit.request4kDescription": "Grants permission to request 4K movies and series.",
"components.UserEdit.request4kMovies": "Request 4K Movies",
"components.UserEdit.request4kMoviesDescription": "Grants permission to request 4K movies.",
"components.UserEdit.request4kTv": "Request 4K Series",
"components.UserEdit.request4kTvDescription": "Grants permission to request 4K Series.",
"components.UserEdit.requestDescription": "Grants permission to request movies and series.",
"components.UserEdit.save": "Save",
"components.UserEdit.saving": "Saving…",

@ -14,6 +14,8 @@ import Head from 'next/head';
import Toast from '../components/Toast';
import { InteractionProvider } from '../context/InteractionContext';
import StatusChecker from '../components/StatusChacker';
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
import { SettingsProvider } from '../context/SettingsContext';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadLocaleData = (locale: string): Promise<any> => {
@ -55,6 +57,7 @@ interface ExtendedAppProps extends AppProps {
user: User;
messages: MessagesType;
locale: AvailableLocales;
currentSettings: PublicSettingsResponse;
}
if (typeof window === 'undefined') {
@ -68,6 +71,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
user,
messages,
locale,
currentSettings,
}: ExtendedAppProps) => {
let component: React.ReactNode;
const [loadedMessages, setMessages] = useState<MessagesType>(messages);
@ -103,15 +107,17 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
defaultLocale="en"
messages={loadedMessages}
>
<InteractionProvider>
<ToastProvider components={{ Toast }}>
<Head>
<title>Overseerr</title>
</Head>
<StatusChecker />
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</InteractionProvider>
<SettingsProvider currentSettings={currentSettings}>
<InteractionProvider>
<ToastProvider components={{ Toast }}>
<Head>
<title>Overseerr</title>
</Head>
<StatusChecker />
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</InteractionProvider>
</SettingsProvider>
</IntlProvider>
</LanguageContext.Provider>
</SWRConfig>
@ -121,15 +127,22 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
CoreApp.getInitialProps = async (initialProps) => {
const { ctx, router } = initialProps;
let user = undefined;
let currentSettings: PublicSettingsResponse = {
initialized: false,
movie4kEnabled: false,
series4kEnabled: false,
};
let locale = 'en';
if (ctx.res) {
// Check if app is initialized and redirect if necessary
const response = await axios.get<{ initialized: boolean }>(
const response = await axios.get<PublicSettingsResponse>(
`http://localhost:${process.env.PORT || 5055}/api/v1/settings/public`
);
currentSettings = response.data;
const initialized = response.data.initialized;
if (!initialized) {
@ -181,7 +194,7 @@ CoreApp.getInitialProps = async (initialProps) => {
const messages = await loadLocaleData(locale);
return { ...appInitialProps, user, messages, locale };
return { ...appInitialProps, user, messages, locale, currentSettings };
};
export default CoreApp;

Loading…
Cancel
Save