feat: 4K Requests (#559)

pull/633/head
sct 4 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 .vercel
# database # database
config/db/db.sqlite3 config/db/*.sqlite3
config/settings.json config/settings.json
# logs # logs

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

@ -701,6 +701,15 @@ components:
- $ref: '#/components/schemas/User' - $ref: '#/components/schemas/User'
- type: string - type: string
nullable: true nullable: true
is4k:
type: boolean
example: false
serverId:
type: number
profileId:
type: number
rootFolder:
type: string
required: required:
- id - id
- status - status
@ -2364,6 +2373,15 @@ paths:
type: array type: array
items: items:
type: number type: number
is4k:
type: boolean
example: false
serverId:
type: number
profileId:
type: number
rootFolder:
type: string
required: required:
- mediaType - mediaType
- mediaId - mediaId

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

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

@ -65,6 +65,18 @@ export class MediaRequest {
}) })
public seasons: SeasonRequest[]; 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>) { constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init); Object.assign(this, init);
} }
@ -181,7 +193,11 @@ export class MediaRequest {
} }
const seasonRequestRepository = getRepository(SeasonRequest); const seasonRequestRepository = getRepository(SeasonRequest);
if (this.status === MediaRequestStatus.APPROVED) { if (this.status === MediaRequestStatus.APPROVED) {
if (this.is4k) {
media.status4k = MediaStatus.PROCESSING;
} else {
media.status = MediaStatus.PROCESSING; media.status = MediaStatus.PROCESSING;
}
mediaRepository.save(media); mediaRepository.save(media);
} }
@ -189,7 +205,11 @@ export class MediaRequest {
this.media.mediaType === MediaType.MOVIE && this.media.mediaType === MediaType.MOVIE &&
this.status === MediaRequestStatus.DECLINED this.status === MediaRequestStatus.DECLINED
) { ) {
if (this.is4k) {
media.status4k = MediaStatus.UNKNOWN;
} else {
media.status = MediaStatus.UNKNOWN; media.status = MediaStatus.UNKNOWN;
}
mediaRepository.save(media); mediaRepository.save(media);
} }
@ -224,15 +244,28 @@ export class MediaRequest {
} }
@AfterRemove() @AfterRemove()
private async _handleRemoveParentUpdate() { public async handleRemoveParentUpdate(): Promise<void> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const fullMedia = await mediaRepository.findOneOrFail({ const fullMedia = await mediaRepository.findOneOrFail({
where: { id: this.media.id }, 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; 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() { private async _sendToRadarr() {
@ -252,12 +285,14 @@ export class MediaRequest {
} }
const radarrSettings = settings.radarr.find( const radarrSettings = settings.radarr.find(
(radarr) => radarr.isDefault && !radarr.is4k (radarr) => radarr.isDefault && this.is4k
); );
if (!radarrSettings) { if (!radarrSettings) {
logger.info( 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' } { label: 'Media Request' }
); );
return; return;
@ -342,12 +377,14 @@ export class MediaRequest {
} }
const sonarrSettings = settings.sonarr.find( const sonarrSettings = settings.sonarr.find(
(sonarr) => sonarr.isDefault && !sonarr.is4k (sonarr) => sonarr.isDefault && this.is4k
); );
if (!sonarrSettings) { if (!sonarrSettings) {
logger.info( 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' } { label: 'Media Request' }
); );
return; return;

@ -20,6 +20,9 @@ class Season {
@Column({ type: 'int', default: MediaStatus.UNKNOWN }) @Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status: MediaStatus; public status: MediaStatus;
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
public status4k: MediaStatus;
@ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' }) @ManyToOne(() => Media, (media) => media.seasons, { onDelete: 'CASCADE' })
public media: Promise<Media>; public media: Promise<Media>;

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

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

@ -45,6 +45,8 @@ class JobPlexSync {
private currentLibrary: Library; private currentLibrary: Library;
private running = false; private running = false;
private isRecentOnly = false; private isRecentOnly = false;
private enable4kMovie = false;
private enable4kShow = false;
private asyncLock = new AsyncLock(); private asyncLock = new AsyncLock();
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { 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 () => { await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
const existing = await this.getExisting( const existing = await this.getExisting(
newMedia.tmdbId, newMedia.tmdbId,
MediaType.MOVIE MediaType.MOVIE
); );
if (existing && existing.status === MediaStatus.AVAILABLE) { if (existing) {
this.log(`Title exists and is already available ${metadata.title}`); let changedExisting = false;
} else if (existing && existing.status !== MediaStatus.AVAILABLE) {
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE; existing.status = MediaStatus.AVAILABLE;
mediaRepository.save(existing); changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log( this.log(
`Request for ${metadata.title} exists. Setting status AVAILABLE`, `Request for ${metadata.title} exists. New media types set to AVAILABLE`,
'info' 'info'
); );
} else { } else {
newMedia.status = MediaStatus.AVAILABLE; this.log(
`Title already exists and no new media types found ${metadata.title}`
);
}
} else {
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE; newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved ${plexitem.title}`); this.log(`Saved ${plexitem.title}`);
@ -150,16 +188,47 @@ class JobPlexSync {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(tmdbMovieId, async () => { await this.asyncLock.dispatch(tmdbMovieId, async () => {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE); const existing = await this.getExisting(tmdbMovieId, MediaType.MOVIE);
if (existing && existing.status === MediaStatus.AVAILABLE) {
this.log(`Title exists and is already available ${plexitem.title}`); const has4k = metadata.Media.some(
} else if (existing && existing.status !== MediaStatus.AVAILABLE) { (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; 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); await mediaRepository.save(existing);
this.log( this.log(
`Request for ${plexitem.title} exists. Setting status AVAILABLE`, `Request for ${metadata.title} exists. New media types set to AVAILABLE`,
'info' 'info'
); );
} else {
this.log(
`Title already exists and no new media types found ${metadata.title}`
);
}
} else { } else {
// If we have a tmdb movie guid but it didn't already exist, only then // 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) // do we request the movie from tmdb (to reduce api requests)
@ -169,7 +238,14 @@ class JobPlexSync {
const newMedia = new Media(); const newMedia = new Media();
newMedia.imdbId = tmdbMovie.external_ids.imdb_id; newMedia.imdbId = tmdbMovie.external_ids.imdb_id;
newMedia.tmdbId = tmdbMovie.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; newMedia.mediaType = MediaType.MOVIE;
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved ${tmdbMovie.title}`); this.log(`Saved ${tmdbMovie.title}`);
@ -316,13 +392,18 @@ class JobPlexSync {
const newSeasons: Season[] = []; const newSeasons: Season[] = [];
const currentSeasonAvailable = ( const currentStandardSeasonAvailable = (
media?.seasons.filter( media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE (season) => season.status === MediaStatus.AVAILABLE
) ?? [] ) ?? []
).length; ).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( const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number (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 // Check if we found the matching season and it has all the available episodes
if ( if (matchedPlexSeason) {
matchedPlexSeason && // If we have a matched plex season, get its children metadata so we can check details
Number(matchedPlexSeason.leafCount) === season.episode_count const episodes = await this.plexClient.getChildrenMetadata(
) { matchedPlexSeason.ratingKey
if (existingSeason) {
existingSeason.status = MediaStatus.AVAILABLE;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number,
status: MediaStatus.AVAILABLE,
})
); );
} // Total episodes that are in standard definition (not 4k)
} else if (matchedPlexSeason) { 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) { 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 { } else {
newSeasons.push( newSeasons.push(
new Season({ new Season({
seasonNumber: season.season_number, 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 // Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter( const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0 (season) => season.season_number !== 0
); );
const isAllSeasons = const isAllStandardSeasons =
newSeasons.length + (media?.seasons.length ?? 0) >= 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; filteredSeasons.length;
if (media) { if (media) {
// Update existing // Update existing
media.seasons = [...media.seasons, ...newSeasons]; media.seasons = [...media.seasons, ...newSeasons];
const newSeasonAvailable = ( const newStandardSeasonAvailable = (
media.seasons.filter( media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE (season) => season.status === MediaStatus.AVAILABLE
) ?? [] ) ?? []
).length; ).length;
const new4kSeasonAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update // If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications // the lastSeasonChange field so we can trigger notifications
if (newSeasonAvailable > currentSeasonAvailable) { if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
this.log( this.log(
`Detected ${ `Detected ${
newSeasonAvailable - currentSeasonAvailable newStandardSeasonAvailable - currentStandardSeasonAvailable
} new season(s) for ${tvShow.name}`, } new standard season(s) for ${tvShow.name}`,
'debug' 'debug'
); );
media.lastSeasonChange = new Date(); 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
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k = isAll4kSeasons
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE; : media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media); await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`); this.log(`Updating existing title: ${tvShow.name}`);
} else { } else {
@ -402,9 +551,20 @@ class JobPlexSync {
seasons: newSeasons, seasons: newSeasons,
tmdbId: tvShow.id, tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_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.AVAILABLE
: MediaStatus.PARTIALLY_AVAILABLE, : newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
}); });
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`); this.log(`Saved ${tvShow.name}`);
@ -508,6 +668,22 @@ class JobPlexSync {
(library) => library.enabled (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(); const hasHama = await this.hasHamaAgent();
if (hasHama) { if (hasHama) {
await animeList.sync(); await animeList.sync();

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

@ -55,6 +55,11 @@ interface PublicSettings {
initialized: boolean; initialized: boolean;
} }
interface FullPublicSettings extends PublicSettings {
movie4kEnabled: boolean;
series4kEnabled: boolean;
}
export interface NotificationAgentConfig { export interface NotificationAgentConfig {
enabled: boolean; enabled: boolean;
types: number; types: number;
@ -246,6 +251,18 @@ class Settings {
this.data.public = data; 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 { get notifications(): NotificationSettings {
return this.data.notifications; 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) => { router.get('/settings/public', (_req, res) => {
const settings = getSettings(); const settings = getSettings();
return res.status(200).json(settings.public); return res.status(200).json(settings.fullPublicSettings);
}); });
router.use( router.use(
'/settings', '/settings',

@ -110,15 +110,21 @@ requestRoutes.post(
media = new Media({ media = new Media({
tmdbId: tmdbMedia.id, tmdbId: tmdbMedia.id,
tvdbId: tmdbMedia.external_ids.tvdb_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, mediaType: req.body.mediaType,
}); });
await mediaRepository.save(media); await mediaRepository.save(media);
} else { } else {
if (media.status === MediaStatus.UNKNOWN) { if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
media.status = MediaStatus.PENDING; media.status = MediaStatus.PENDING;
await mediaRepository.save(media); 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') { if (req.body.mediaType === 'movie') {
@ -137,6 +143,10 @@ requestRoutes.post(
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE) req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
? req.user ? req.user
: undefined, : undefined,
is4k: req.body.is4k,
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
}); });
await requestRepository.save(request); await requestRepository.save(request);
@ -149,7 +159,9 @@ requestRoutes.post(
// already requested. In the case they were, we just throw out any duplicates but still approve the request. // 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) // (Unless there are no seasons, in which case we abort)
if (media.requests) { if (media.requests) {
existingSeasons = media.requests.reduce((seasons, request) => { existingSeasons = media.requests
.filter((request) => request.is4k === req.body.is4k)
.reduce((seasons, request) => {
const combinedSeasons = request.seasons.map( const combinedSeasons = request.seasons.map(
(season) => season.seasonNumber (season) => season.seasonNumber
); );
@ -186,6 +198,7 @@ requestRoutes.post(
req.user?.hasPermission(Permission.AUTO_APPROVE_TV) req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? req.user ? req.user
: undefined, : undefined,
is4k: req.body.is4k,
seasons: finalSeasons.map( seasons: finalSeasons.map(
(sn) => (sn) =>
new SeasonRequest({ 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 { LanguageContext } from '../../context/LanguageContext';
import LoadingSpinner from '../Common/LoadingSpinner'; import LoadingSpinner from '../Common/LoadingSpinner';
import { useUser, Permission } from '../../hooks/useUser'; import { useUser, Permission } from '../../hooks/useUser';
import { import { MediaStatus } from '../../../server/constants/media';
MediaStatus,
MediaRequestStatus,
} from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import axios from 'axios'; import axios from 'axios';
import SlideOver from '../Common/SlideOver'; import SlideOver from '../Common/SlideOver';
import RequestBlock from '../RequestBlock'; import RequestBlock from '../RequestBlock';
@ -38,6 +33,7 @@ import Head from 'next/head';
import ExternalLinkBlock from '../ExternalLinkBlock'; import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers'; import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
import RequestButton from './RequestButton';
const messages = defineMessages({ const messages = defineMessages({
releasedate: 'Release Date', releasedate: 'Release Date',
@ -55,8 +51,6 @@ const messages = defineMessages({
cancelrequest: 'Cancel Request', cancelrequest: 'Cancel Request',
available: 'Available', available: 'Available',
unavailable: 'Unavailable', unavailable: 'Unavailable',
request: 'Request',
viewrequest: 'View Request',
pending: 'Pending', pending: 'Pending',
overviewunavailable: 'Overview unavailable', overviewunavailable: 'Overview unavailable',
manageModalTitle: 'Manage Movie', manageModalTitle: 'Manage Movie',
@ -88,7 +82,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { locale } = useContext(LanguageContext); const { locale } = useContext(LanguageContext);
const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState(false); const [showManager, setShowManager] = useState(false);
const { data, error, revalidate } = useSWR<MovieDetailsType>( const { data, error, revalidate } = useSWR<MovieDetailsType>(
`/api/v1/movie/${router.query.movieId}?language=${locale}`, `/api/v1/movie/${router.query.movieId}?language=${locale}`,
@ -118,25 +111,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return <Error statusCode={404} />; return <Error statusCode={404} />;
} }
const activeRequest = data?.mediaInfo?.requests?.find(
(request) => request.status === MediaRequestStatus.PENDING
);
const trailerUrl = data.relatedVideos const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer') ?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size) .sort((a, b) => a.size - b.size)
.pop()?.url; .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 () => { const deleteMedia = async () => {
if (data?.mediaInfo?.id) { if (data?.mediaInfo?.id) {
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`); await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
@ -155,16 +134,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<Head> <Head>
<title>{data.title} - Overseerr</title> <title>{data.title} - Overseerr</title>
</Head> </Head>
<RequestModal
tmdbId={data.id}
show={showRequestModal}
type="movie"
onComplete={() => {
revalidate();
setShowRequestModal(false);
}}
onCancel={() => setShowRequestModal(false)}
/>
<SlideOver <SlideOver
show={showManager} show={showManager}
title={intl.formatMessage(messages.manageModalTitle)} title={intl.formatMessage(messages.manageModalTitle)}
@ -216,7 +186,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div> </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="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2"> <div className="mb-2">
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="mr-2">
<StatusBadge status={data.mediaInfo?.status} /> <StatusBadge status={data.mediaInfo?.status} />
</span>
)}
<span>
<StatusBadge status={data.mediaInfo?.status4k} is4k />
</span>
</div> </div>
<h1 className="text-2xl lg:text-4xl"> <h1 className="text-2xl lg:text-4xl">
{data.title}{' '} {data.title}{' '}
@ -263,121 +240,12 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</Button> </Button>
</a> </a>
)} )}
{(!data.mediaInfo || <RequestButton
data.mediaInfo?.status === MediaStatus.UNKNOWN) && ( mediaType="movie"
<Button media={data.mediaInfo}
buttonType="primary" tmdbId={data.id}
className="ml-2" onUpdate={() => revalidate()}
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>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && ( {hasPermission(Permission.MANAGE_REQUESTS) && (
<Button <Button
buttonType="default" buttonType="default"

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

@ -28,7 +28,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
const RequestCardPlaceholder: React.FC = () => { const RequestCardPlaceholder: React.FC = () => {
return ( 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-20 sm:w-28">
<div className="w-full" style={{ paddingBottom: '150%' }} /> <div className="w-full" style={{ paddingBottom: '150%' }} />
</div> </div>
@ -88,13 +88,13 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
return ( return (
<div <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={{ 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})`, 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"> <div className="flex flex-col flex-1 min-w-0 pr-4">
<h2 className="text-base sm:text-lg overflow-ellipsis overflow-hidden whitespace-nowrap text-white cursor-pointer hover:underline"> <h2 className="overflow-hidden text-base text-white cursor-pointer sm:text-lg overflow-ellipsis whitespace-nowrap hover:underline">
<Link <Link
href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'} href={request.type === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'}
as={ as={
@ -106,18 +106,21 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
{isMovie(title) ? title.title : title.name} {isMovie(title) ? title.title : title.name}
</Link> </Link>
</h2> </h2>
<div className="text-xs sm:text-sm truncate"> <div className="text-xs truncate sm:text-sm">
{intl.formatMessage(messages.requestedby, { {intl.formatMessage(messages.requestedby, {
username: requestData.requestedBy.username, username: requestData.requestedBy.username,
})} })}
</div> </div>
{requestData.media.status && ( {requestData.media.status && (
<div className="mt-1 sm:mt-2"> <div className="mt-1 sm:mt-2">
<StatusBadge status={requestData.media.status} /> <StatusBadge
status={requestData.media.status}
is4k={requestData.is4k}
/>
</div> </div>
)} )}
{request.seasons.length > 0 && ( {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> <span className="mr-2">{intl.formatMessage(messages.seasons)}</span>
{!isMovie(title) && {!isMovie(title) &&
title.seasons.filter((season) => season.seasonNumber !== 0) title.seasons.filter((season) => season.seasonNumber !== 0)
@ -126,7 +129,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
<Badge>{intl.formatMessage(messages.all)}</Badge> <Badge>{intl.formatMessage(messages.all)}</Badge>
</span> </span>
) : ( ) : (
<div className="hide-scrollbar overflow-x-scroll"> <div className="overflow-x-scroll hide-scrollbar">
{request.seasons.map((season) => ( {request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2"> <span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge> <Badge>{season.seasonNumber}</Badge>
@ -138,7 +141,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
)} )}
{requestData.status === MediaRequestStatus.PENDING && {requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<div className="flex-1 flex items-end"> <div className="flex items-end flex-1">
<span className="mr-2"> <span className="mr-2">
<Button <Button
buttonType="success" buttonType="success"
@ -200,7 +203,7 @@ const RequestCard: React.FC<RequestCardProps> = ({ request }) => {
<img <img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`} src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`}
alt="" 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> </Link>
</div> </div>

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

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

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

@ -89,6 +89,30 @@ const SettingsMain: React.FC = () => {
description: intl.formatMessage(permissionMessages.requestDescription), description: intl.formatMessage(permissionMessages.requestDescription),
permission: Permission.REQUEST, 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', id: 'autoapprove',
name: intl.formatMessage(permissionMessages.autoapprove), name: intl.formatMessage(permissionMessages.autoapprove),

@ -35,7 +35,6 @@ const messages = defineMessages({
nodefault: 'No default server selected!', nodefault: 'No default server selected!',
nodefaultdescription: nodefaultdescription:
'At least one server must be marked as default before any requests will make it to your services.', '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 { interface ServerInstanceProps {
@ -63,10 +62,10 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
}) => { }) => {
return ( return (
<li className="col-span-1 bg-gray-700 rounded-lg shadow"> <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-1 truncate">
<div className="flex items-center space-x-3 mb-2"> <div className="flex items-center mb-2 space-x-3">
<h3 className="text-white text-sm leading-5 font-medium truncate"> <h3 className="text-sm font-medium leading-5 text-white truncate">
{name} {name}
</h3> </h3>
{isDefault && ( {isDefault && (
@ -85,31 +84,31 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</Badge> </Badge>
)} )}
</div> </div>
<p className="mt-1 text-gray-300 text-sm leading-5 truncate"> <p className="mt-1 text-sm leading-5 text-gray-300 truncate">
<span className="font-bold mr-2"> <span className="mr-2 font-bold">
<FormattedMessage {...messages.address} /> <FormattedMessage {...messages.address} />
</span> </span>
{address} {address}
</p> </p>
<p className="mt-1 text-gray-300 text-sm leading-5 truncate"> <p className="mt-1 text-sm leading-5 text-gray-300 truncate">
<span className="font-bold mr-2"> <span className="mr-2 font-bold">
<FormattedMessage {...messages.activeProfile} /> <FormattedMessage {...messages.activeProfile} />
</span>{' '} </span>{' '}
{profileName} {profileName}
</p> </p>
</div> </div>
<img <img
className="w-10 h-10 flex-shrink-0" className="flex-shrink-0 w-10 h-10"
src={`/images/${isSonarr ? 'sonarr' : 'radarr'}_logo.png`} src={`/images/${isSonarr ? 'sonarr' : 'radarr'}_logo.png`}
alt="" alt=""
/> />
</div> </div>
<div className="border-t border-gray-800"> <div className="border-t border-gray-800">
<div className="-mt-px flex"> <div className="flex -mt-px">
<div className="w-0 flex-1 flex border-r border-gray-800"> <div className="flex flex-1 w-0 border-r border-gray-800">
<button <button
onClick={() => onEdit()} 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 <svg
className="w-5 h-5" className="w-5 h-5"
@ -124,10 +123,10 @@ const ServerInstance: React.FC<ServerInstanceProps> = ({
</span> </span>
</button> </button>
</div> </div>
<div className="-ml-px w-0 flex-1 flex"> <div className="flex flex-1 w-0 -ml-px">
<button <button
onClick={() => onDelete()} 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 <svg
className="w-5 h-5" className="w-5 h-5"
@ -200,10 +199,10 @@ const SettingsServices: React.FC = () => {
return ( return (
<> <>
<div> <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} /> <FormattedMessage {...messages.radarrsettings} />
</h3> </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} /> <FormattedMessage {...messages.radarrSettingsDescription} />
</p> </p>
</div> </div>
@ -262,9 +261,6 @@ const SettingsServices: React.FC = () => {
) && ( ) && (
<Alert title={intl.formatMessage(messages.nodefault)}> <Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p> <p>{intl.formatMessage(messages.nodefaultdescription)}</p>
<p className="mt-2">
{intl.formatMessage(messages.no4kimplemented)}
</p>
</Alert> </Alert>
)} )}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <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"> <div className="flex items-center justify-center w-full h-full">
<Button <Button
buttonType="ghost" buttonType="ghost"
@ -316,10 +312,10 @@ const SettingsServices: React.FC = () => {
)} )}
</div> </div>
<div className="mt-10"> <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} /> <FormattedMessage {...messages.sonarrsettings} />
</h3> </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} /> <FormattedMessage {...messages.sonarrSettingsDescription} />
</p> </p>
</div> </div>
@ -333,9 +329,6 @@ const SettingsServices: React.FC = () => {
) && ( ) && (
<Alert title={intl.formatMessage(messages.nodefault)}> <Alert title={intl.formatMessage(messages.nodefault)}>
<p>{intl.formatMessage(messages.nodefaultdescription)}</p> <p>{intl.formatMessage(messages.nodefaultdescription)}</p>
<p className="mt-2">
{intl.formatMessage(messages.no4kimplemented)}
</p>
</Alert> </Alert>
)} )}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <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"> <div className="flex items-center justify-center w-full h-full">
<Button <Button
buttonType="ghost" buttonType="ghost"

@ -1,16 +1,60 @@
import React from 'react'; import React from 'react';
import { MediaStatus } from '../../../server/constants/media'; import { MediaStatus } from '../../../server/constants/media';
import Badge from '../Common/Badge'; import Badge from '../Common/Badge';
import { useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
const messages = defineMessages({
status4k: '4K {status}',
});
interface StatusBadgeProps { interface StatusBadgeProps {
status?: MediaStatus; status?: MediaStatus;
is4k?: boolean;
} }
const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => { const StatusBadge: React.FC<StatusBadgeProps> = ({ status, is4k }) => {
const intl = useIntl(); 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) { switch (status) {
case MediaStatus.AVAILABLE: case MediaStatus.AVAILABLE:
return ( return (

@ -19,7 +19,6 @@ import { useUser, Permission } from '../../hooks/useUser';
import { TvDetails as TvDetailsType } from '../../../server/models/Tv'; import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
import { MediaStatus } from '../../../server/constants/media'; import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal'; import RequestModal from '../RequestModal';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import axios from 'axios'; import axios from 'axios';
import SlideOver from '../Common/SlideOver'; import SlideOver from '../Common/SlideOver';
import RequestBlock from '../RequestBlock'; import RequestBlock from '../RequestBlock';
@ -36,6 +35,7 @@ import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers'; import { sortCrewPriority } from '../../utils/creditHelpers';
import { Crew } from '../../../server/models/common'; import { Crew } from '../../../server/models/common';
import StatusBadge from '../StatusBadge'; import StatusBadge from '../StatusBadge';
import RequestButton from '../MovieDetails/RequestButton';
const messages = defineMessages({ const messages = defineMessages({
firstAirDate: 'First Air Date', firstAirDate: 'First Air Date',
@ -50,14 +50,8 @@ const messages = defineMessages({
watchtrailer: 'Watch Trailer', watchtrailer: 'Watch Trailer',
available: 'Available', available: 'Available',
unavailable: 'Unavailable', unavailable: 'Unavailable',
request: 'Request',
requestmore: 'Request More',
pending: 'Pending', pending: 'Pending',
overviewunavailable: 'Overview unavailable', overviewunavailable: 'Overview unavailable',
approverequests:
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
declinerequests:
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
manageModalTitle: 'Manage Series', manageModalTitle: 'Manage Series',
manageModalRequests: 'Requests', manageModalRequests: 'Requests',
manageModalNoRequests: 'No Requests', manageModalNoRequests: 'No Requests',
@ -83,13 +77,6 @@ interface SearchResult {
results: TvResult[]; results: TvResult[];
} }
enum MediaRequestStatus {
PENDING = 1,
APPROVED,
DECLINED,
AVAILABLE,
}
const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => { const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const router = useRouter(); const router = useRouter();
@ -126,29 +113,11 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
return <Error statusCode={404} />; return <Error statusCode={404} />;
} }
const activeRequests = data.mediaInfo?.requests?.filter(
(request) => request.status === MediaRequestStatus.PENDING
);
const trailerUrl = data.relatedVideos const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer') ?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size) .sort((a, b) => a.size - b.size)
.pop()?.url; .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 () => { const deleteMedia = async () => {
if (data?.mediaInfo?.id) { if (data?.mediaInfo?.id) {
await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`); await axios.delete(`/api/v1/media/${data?.mediaInfo?.id}`);
@ -164,6 +133,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
) ?? [] ) ?? []
).length; ).length;
const is4kComplete =
data.seasons.filter((season) => season.seasonNumber !== 0).length <=
(
data.mediaInfo?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
return ( return (
<div <div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover" 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>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left"> <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"> <div className="mb-2">
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
<span className="mr-2">
<StatusBadge status={data.mediaInfo?.status} /> <StatusBadge status={data.mediaInfo?.status} />
</span>
)}
<span>
<StatusBadge status={data.mediaInfo?.status4k} is4k />
</span>
</div> </div>
<h1 className="text-2xl lg:text-4xl"> <h1 className="text-2xl lg:text-4xl">
<span>{data.name}</span> <span>{data.name}</span>
@ -278,116 +262,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</Button> </Button>
</a> </a>
)} )}
{(!data.mediaInfo || <RequestButton
data.mediaInfo.status === MediaStatus.UNKNOWN) && ( mediaType="tv"
<Button onUpdate={() => revalidate()}
className="ml-2" tmdbId={data?.id}
buttonType="primary" media={data?.mediaInfo}
onClick={() => setShowRequestModal(true)} isShowComplete={isComplete}
> is4kShowComplete={is4kComplete}
<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>
)}
{hasPermission(Permission.MANAGE_REQUESTS) && ( {hasPermission(Permission.MANAGE_REQUESTS) && (
<Button <Button
buttonType="default" buttonType="default"

@ -41,6 +41,12 @@ export const messages = defineMessages({
autoapproveSeries: 'Auto Approve Series', autoapproveSeries: 'Auto Approve Series',
autoapproveSeriesDescription: autoapproveSeriesDescription:
'Grants auto approve for series requests made by this user.', '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', save: 'Save',
saving: 'Saving...', saving: 'Saving...',
usersaved: 'User saved', usersaved: 'User saved',
@ -127,6 +133,26 @@ const UserEdit: React.FC = () => {
description: intl.formatMessage(messages.requestDescription), description: intl.formatMessage(messages.requestDescription),
permission: Permission.REQUEST, 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', id: 'autoapprove',
name: intl.formatMessage(messages.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.Login.signinplex": "Sign in to continue",
"components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCast.fullcast": "Full Cast",
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "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.approve": "Approve",
"components.MovieDetails.available": "Available", "components.MovieDetails.available": "Available",
"components.MovieDetails.budget": "Budget", "components.MovieDetails.budget": "Budget",
@ -47,7 +61,6 @@
"components.MovieDetails.recommendations": "Recommendations", "components.MovieDetails.recommendations": "Recommendations",
"components.MovieDetails.recommendationssubtext": "If you liked {title}, you might also like…", "components.MovieDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.MovieDetails.releasedate": "Release Date", "components.MovieDetails.releasedate": "Release Date",
"components.MovieDetails.request": "Request",
"components.MovieDetails.revenue": "Revenue", "components.MovieDetails.revenue": "Revenue",
"components.MovieDetails.runtime": "{minutes} minutes", "components.MovieDetails.runtime": "{minutes} minutes",
"components.MovieDetails.similar": "Similar Titles", "components.MovieDetails.similar": "Similar Titles",
@ -58,7 +71,6 @@
"components.MovieDetails.userrating": "User Rating", "components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.view": "View", "components.MovieDetails.view": "View",
"components.MovieDetails.viewfullcrew": "View Full Crew", "components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer", "components.MovieDetails.watchtrailer": "Watch Trailer",
"components.NotificationTypeSelector.mediaapproved": "Media Approved", "components.NotificationTypeSelector.mediaapproved": "Media Approved",
"components.NotificationTypeSelector.mediaapprovedDescription": "Sends a notification when media is approved.", "components.NotificationTypeSelector.mediaapprovedDescription": "Sends a notification when media is approved.",
@ -105,8 +117,12 @@
"components.RequestModal.extras": "Extras", "components.RequestModal.extras": "Extras",
"components.RequestModal.notrequested": "Not Requested", "components.RequestModal.notrequested": "Not Requested",
"components.RequestModal.numberofepisodes": "# of Episodes", "components.RequestModal.numberofepisodes": "# of Episodes",
"components.RequestModal.pending4krequest": "Pending request for {title} in 4K",
"components.RequestModal.pendingrequest": "Pending request for {title}", "components.RequestModal.pendingrequest": "Pending request for {title}",
"components.RequestModal.request": "Request", "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.requestCancel": "Request for <strong>{title}</strong> cancelled",
"components.RequestModal.requestSuccess": "<strong>{title}</strong> requested.", "components.RequestModal.requestSuccess": "<strong>{title}</strong> requested.",
"components.RequestModal.requestadmin": "Your request will be immediately approved.", "components.RequestModal.requestadmin": "Your request will be immediately approved.",
@ -303,7 +319,6 @@
"components.Settings.menuPlexSettings": "Plex", "components.Settings.menuPlexSettings": "Plex",
"components.Settings.menuServices": "Services", "components.Settings.menuServices": "Services",
"components.Settings.nextexecution": "Next Execution", "components.Settings.nextexecution": "Next Execution",
"components.Settings.no4kimplemented": "(Default 4K servers are not currently implemented)",
"components.Settings.nodefault": "No default server selected!", "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.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.",
"components.Settings.notificationsettings": "Notification Settings", "components.Settings.notificationsettings": "Notification Settings",
@ -344,6 +359,7 @@
"components.Setup.tip": "Tip", "components.Setup.tip": "Tip",
"components.Setup.welcome": "Welcome to Overseerr", "components.Setup.welcome": "Welcome to Overseerr",
"components.Slider.noresults": "No Results", "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.newversionDescription": "An update is now available. Click the button below to reload the application.",
"components.StatusChacker.newversionavailable": "New Version Available", "components.StatusChacker.newversionavailable": "New Version Available",
"components.StatusChacker.reloadOverseerr": "Reload Overseerr", "components.StatusChacker.reloadOverseerr": "Reload Overseerr",
@ -353,12 +369,10 @@
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
"components.TvDetails.anime": "Anime", "components.TvDetails.anime": "Anime",
"components.TvDetails.approve": "Approve", "components.TvDetails.approve": "Approve",
"components.TvDetails.approverequests": "Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.available": "Available", "components.TvDetails.available": "Available",
"components.TvDetails.cancelrequest": "Cancel Request", "components.TvDetails.cancelrequest": "Cancel Request",
"components.TvDetails.cast": "Cast", "components.TvDetails.cast": "Cast",
"components.TvDetails.decline": "Decline", "components.TvDetails.decline": "Decline",
"components.TvDetails.declinerequests": "Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.firstAirDate": "First Air Date", "components.TvDetails.firstAirDate": "First Air Date",
"components.TvDetails.manageModalClearMedia": "Clear All Media Data", "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.", "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.pending": "Pending",
"components.TvDetails.recommendations": "Recommendations", "components.TvDetails.recommendations": "Recommendations",
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…", "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.showtype": "Show Type",
"components.TvDetails.similar": "Similar Series", "components.TvDetails.similar": "Similar Series",
"components.TvDetails.similarsubtext": "Other series similar to {title}", "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.managerequestsDescription": "Grants permission to manage Overseerr requests. This includes approving and denying requests.",
"components.UserEdit.permissions": "Permissions", "components.UserEdit.permissions": "Permissions",
"components.UserEdit.request": "Request", "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.requestDescription": "Grants permission to request movies and series.",
"components.UserEdit.save": "Save", "components.UserEdit.save": "Save",
"components.UserEdit.saving": "Saving…", "components.UserEdit.saving": "Saving…",

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

Loading…
Cancel
Save