feat(media): add link to the item on plex (#735)

Co-authored-by: sct <sctsnipe@gmail.com>
pull/768/head
Jakob Ankarhem 3 years ago committed by GitHub
parent 946bd2db5e
commit 1d7150c24e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -127,15 +127,35 @@ class Media {
@Column({ nullable: true }) @Column({ nullable: true })
public externalServiceSlug4k?: string; public externalServiceSlug4k?: string;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true })
public ratingKey4k?: string;
public serviceUrl?: string; public serviceUrl?: string;
public serviceUrl4k?: string; public serviceUrl4k?: string;
public downloadStatus?: DownloadingItem[] = []; public downloadStatus?: DownloadingItem[] = [];
public downloadStatus4k?: DownloadingItem[] = []; public downloadStatus4k?: DownloadingItem[] = [];
public plexUrl?: string;
public plexUrl4k?: string;
constructor(init?: Partial<Media>) { constructor(init?: Partial<Media>) {
Object.assign(this, init); Object.assign(this, init);
} }
@AfterLoad()
public setPlexUrls(): void {
const machineId = getSettings().plex.machineId;
if (this.ratingKey) {
this.plexUrl = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey}`;
}
if (this.ratingKey4k) {
this.plexUrl4k = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}`;
}
}
@AfterLoad() @AfterLoad()
public setServiceUrl(): void { public setServiceUrl(): void {
if (this.mediaType === MediaType.MOVIE) { if (this.mediaType === MediaType.MOVIE) {

@ -68,6 +68,7 @@ class JobPlexSync {
private async processMovie(plexitem: PlexLibraryItem) { private async processMovie(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
try { try {
if (plexitem.guid.match(plexRegex)) { if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
@ -138,6 +139,23 @@ class JobPlexSync {
changedExisting = true; changedExisting = true;
} }
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.ratingKey !== plexitem.ratingKey
) {
existing.ratingKey = plexitem.ratingKey;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.ratingKey4k !== plexitem.ratingKey
) {
existing.ratingKey4k = plexitem.ratingKey;
changedExisting = true;
}
if (changedExisting) { if (changedExisting) {
await mediaRepository.save(existing); await mediaRepository.save(existing);
this.log( this.log(
@ -160,6 +178,12 @@ class JobPlexSync {
: MediaStatus.UNKNOWN; : MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE; newMedia.mediaType = MediaType.MOVIE;
newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000); newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
newMedia.ratingKey =
hasOtherResolution || (!this.enable4kMovie && has4k)
? plexitem.ratingKey
: undefined;
newMedia.ratingKey4k =
has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved ${plexitem.title}`); this.log(`Saved ${plexitem.title}`);
} }
@ -242,6 +266,23 @@ class JobPlexSync {
changedExisting = true; changedExisting = true;
} }
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.ratingKey !== plexitem.ratingKey
) {
existing.ratingKey = plexitem.ratingKey;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.ratingKey4k !== plexitem.ratingKey
) {
existing.ratingKey4k = plexitem.ratingKey;
changedExisting = true;
}
if (changedExisting) { if (changedExisting) {
await mediaRepository.save(existing); await mediaRepository.save(existing);
this.log( this.log(
@ -272,6 +313,12 @@ class JobPlexSync {
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN; : MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE; newMedia.mediaType = MediaType.MOVIE;
newMedia.ratingKey =
hasOtherResolution || (!this.enable4kMovie && has4k)
? plexitem.ratingKey
: undefined;
newMedia.ratingKey4k =
has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved ${tmdbMovie.title}`); this.log(`Saved ${tmdbMovie.title}`);
} }
@ -311,12 +358,14 @@ class JobPlexSync {
let tvShow: TmdbTvDetails | null = null; let tvShow: TmdbTvDetails | null = null;
try { try {
const metadata = await this.plexClient.getMetadata( const ratingKey =
plexitem.grandparentRatingKey ?? plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ?? plexitem.parentRatingKey ??
plexitem.ratingKey, plexitem.ratingKey;
{ includeChildren: true } const metadata = await this.plexClient.getMetadata(ratingKey, {
); includeChildren: true,
});
if (metadata.guid.match(tvdbRegex)) { if (metadata.guid.match(tvdbRegex)) {
const matchedtvdb = metadata.guid.match(tvdbRegex); const matchedtvdb = metadata.guid.match(tvdbRegex);
@ -454,6 +503,23 @@ class JobPlexSync {
episode.Media.some((media) => media.videoResolution === '4k') episode.Media.some((media) => media.videoResolution === '4k')
).length; ).length;
if (
media &&
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
media.ratingKey !== ratingKey
) {
media.ratingKey = ratingKey;
}
if (
media &&
total4k > 0 &&
this.enable4kShow &&
media.ratingKey4k !== ratingKey
) {
media.ratingKey4k = ratingKey;
}
if (existingSeason) { if (existingSeason) {
// These ternary statements look super confusing, but they are simply // 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, // setting the status to AVAILABLE if all of a type is there, partially if some,

@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRatingKeysToMedia1611801511397 implements MigrationInterface {
name = 'AddRatingKeysToMedia1611801511397';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
}
}

@ -72,6 +72,7 @@ export interface MovieDetails {
}; };
mediaInfo?: Media; mediaInfo?: Media;
externalIds: ExternalIds; externalIds: ExternalIds;
plexUrl?: string;
} }
export const mapMovieDetails = ( export const mapMovieDetails = (

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 320.03 103.61" style="&#10;"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#radial-gradient);}.cls-3{fill:#e5a00d;}</style><radialGradient id="radial-gradient" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset="0.17" stop-color="#f9be03"/><stop offset="0.51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><title>plex-logo</title><polygon id="X" class="cls-1" points="320.03 -0.09 289.96 -0.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79 320.03 -0.09"/><g id="chevron"><polygon class="cls-2" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/><polygon class="cls-3" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/></g><path id="E" class="cls-1" d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z"/><path id="L" class="cls-1" d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z"/><path id="P" class="cls-1" d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -22,7 +22,7 @@ const Badge: React.FC<BadgeProps> = ({
badgeStyle.push('bg-yellow-500 text-yellow-100'); badgeStyle.push('bg-yellow-500 text-yellow-100');
break; break;
case 'success': case 'success':
badgeStyle.push('bg-green-400 text-green-100'); badgeStyle.push('bg-green-500 text-green-100');
break; break;
default: default:
badgeStyle.push('bg-indigo-500 text-indigo-100'); badgeStyle.push('bg-indigo-500 text-indigo-100');

@ -9,22 +9,41 @@ import useClickOutside from '../../../hooks/useClickOutside';
import Transition from '../../Transition'; import Transition from '../../Transition';
import { withProperties } from '../../../utils/typeHelpers'; import { withProperties } from '../../../utils/typeHelpers';
const DropdownItem: React.FC<AnchorHTMLAttributes<HTMLAnchorElement>> = ({ interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
buttonType?: 'primary' | 'ghost';
}
const DropdownItem: React.FC<DropdownItemProps> = ({
children, children,
buttonType = 'primary',
...props ...props
}) => ( }) => {
<a let styleClass = '';
className="flex items-center px-4 py-2 text-sm leading-5 text-white bg-indigo-600 cursor-pointer hover:bg-indigo-500 hover:text-white focus:outline-none focus:border-indigo-700 focus:text-white"
{...props} switch (buttonType) {
> case 'ghost':
{children} styleClass =
</a> 'text-white bg-gray-700 hover:bg-gray-600 hover:text-white focus:border-gray-500 focus:text-white';
); break;
default:
styleClass =
'text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:border-indigo-700 focus:text-white';
}
return (
<a
className={`flex items-center px-4 py-2 text-sm leading-5 cursor-pointer focus:outline-none ${styleClass}`}
{...props}
>
{children}
</a>
);
};
interface ButtonWithDropdownProps interface ButtonWithDropdownProps
extends ButtonHTMLAttributes<HTMLButtonElement> { extends ButtonHTMLAttributes<HTMLButtonElement> {
text: ReactNode; text: ReactNode;
dropdownIcon?: ReactNode; dropdownIcon?: ReactNode;
buttonType?: 'primary' | 'ghost';
} }
const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
@ -32,29 +51,52 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
children, children,
dropdownIcon, dropdownIcon,
className, className,
buttonType = 'primary',
...props ...props
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
useClickOutside(buttonRef, () => setIsOpen(false)); useClickOutside(buttonRef, () => setIsOpen(false));
const styleClasses = {
mainButtonClasses: '',
dropdownSideButtonClasses: '',
dropdownClasses: '',
};
switch (buttonType) {
case 'ghost':
styleClasses.mainButtonClasses =
'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses =
'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownClasses = 'bg-gray-700';
break;
default:
styleClasses.mainButtonClasses =
'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses =
'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownClasses = 'bg-indigo-600';
}
return ( return (
<span className="relative z-0 inline-flex h-full rounded-md shadow-sm"> <span className="relative z-0 inline-flex h-full rounded-md shadow-sm">
<button <button
type="button" type="button"
className={`relative inline-flex h-full items-center px-4 py-2 text-white bg-indigo-600 hover:bg-indigo-500 text-sm leading-5 font-medium hover:text-white focus:ring-indigo active:bg-indigo-700 focus:z-10 focus:outline-none focus:ring-blue transition ease-in-out duration-150 ${ className={`relative inline-flex h-full items-center px-4 py-2 text-sm leading-5 font-medium z-10 hover:z-20 focus:z-20 focus:outline-none transition ease-in-out duration-150 ${
children ? 'rounded-l-md' : 'rounded-md' styleClasses.mainButtonClasses
} ${className}`} } ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
ref={buttonRef} ref={buttonRef}
{...props} {...props}
> >
{text} {text}
</button> </button>
<span className="relative block -ml-px"> <span className="relative z-10 block -ml-px">
{children && ( {children && (
<button <button
type="button" type="button"
className="relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-700 border border-indigo-600 rounded-r-md hover:bg-indigo-500 focus:z-10 focus:outline-none active:bg-indigo-700 focus:ring-blue" className={`relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out rounded-r-md focus:z-10 ${styleClasses.dropdownSideButtonClasses}`}
aria-label="Expand" aria-label="Expand"
onClick={() => setIsOpen((state) => !state)} onClick={() => setIsOpen((state) => !state)}
> >
@ -86,7 +128,9 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<div className="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg"> <div className="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div className="bg-indigo-600 rounded-md ring-1 ring-black ring-opacity-5"> <div
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
>
<div className="py-1">{children}</div> <div className="py-1">{children}</div>
</div> </div>
</div> </div>

@ -2,12 +2,14 @@ import React from 'react';
import TmdbLogo from '../../assets/services/tmdb.svg'; import TmdbLogo from '../../assets/services/tmdb.svg';
import ImdbLogo from '../../assets/services/imdb.svg'; import ImdbLogo from '../../assets/services/imdb.svg';
import RTLogo from '../../assets/services/rt.svg'; import RTLogo from '../../assets/services/rt.svg';
import PlexLogo from '../../assets/services/plex.svg';
interface ExternalLinkBlockProps { interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv';
imdbId?: string; imdbId?: string;
tmdbId?: number; tmdbId?: number;
rtUrl?: string; rtUrl?: string;
plexUrl?: string;
} }
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
@ -15,9 +17,20 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
tmdbId, tmdbId,
rtUrl, rtUrl,
mediaType, mediaType,
plexUrl,
}) => { }) => {
return ( return (
<div className="flex justify-end items-center"> <div className="flex justify-end items-center">
{plexUrl && (
<a
href={plexUrl}
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
target="_blank"
rel="noreferrer"
>
<PlexLogo />
</a>
)}
{tmdbId && ( {tmdbId && (
<a <a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`} href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}

@ -35,6 +35,7 @@ import RequestButton from '../RequestButton';
import MediaSlider from '../MediaSlider'; import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton'; import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock'; import DownloadBlock from '../DownloadBlock';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
const messages = defineMessages({ const messages = defineMessages({
releasedate: 'Release Date', releasedate: 'Release Date',
@ -69,6 +70,8 @@ const messages = defineMessages({
openradarr: 'Open Movie in Radarr', openradarr: 'Open Movie in Radarr',
openradarr4k: 'Open Movie in 4K Radarr', openradarr4k: 'Open Movie in 4K Radarr',
downloadstatus: 'Download Status', downloadstatus: 'Download Status',
playonplex: 'Play on Plex',
play4konplex: 'Play 4K on Plex',
}); });
interface MovieDetailsProps { interface MovieDetailsProps {
@ -252,6 +255,8 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<StatusBadge <StatusBadge
status={data.mediaInfo?.status} status={data.mediaInfo?.status}
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0} inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
plexUrl4k={data.mediaInfo?.plexUrl4k}
/> />
</span> </span>
)} )}
@ -260,6 +265,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
status={data.mediaInfo?.status4k} status={data.mediaInfo?.status4k}
is4k is4k
inProgress={(data.mediaInfo?.downloadStatus4k ?? []).length > 0} inProgress={(data.mediaInfo?.downloadStatus4k ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
plexUrl4k={
data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))
? data.mediaInfo.plexUrl4k
: undefined
}
/> />
</span> </span>
</div> </div>
@ -281,37 +294,86 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span> </span>
</div> </div>
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0"> <div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
{trailerUrl && ( {(trailerUrl ||
<a data.mediaInfo?.plexUrl ||
href={trailerUrl} data.mediaInfo?.plexUrl4k) && (
target={'_blank'} <ButtonWithDropdown
rel="noreferrer" buttonType="ghost"
className="mb-3 sm:mb-0" text={
<>
<svg
className="w-5 h-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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{data.mediaInfo?.plexUrl
? intl.formatMessage(messages.playonplex)
: data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))
? intl.formatMessage(messages.playonplex)
: intl.formatMessage(messages.watchtrailer)}
</span>
</>
}
onClick={() => {
if (data.mediaInfo?.plexUrl) {
window.open(data.mediaInfo?.plexUrl, '_blank');
} else if (data.mediaInfo?.plexUrl4k) {
window.open(data.mediaInfo?.plexUrl4k, '_blank');
} else if (trailerUrl) {
window.open(trailerUrl, '_blank');
}
}}
> >
<Button buttonType="ghost"> {data.mediaInfo?.plexUrl ||
<svg (data.mediaInfo?.plexUrl4k &&
className="w-5 h-5 mr-1" (hasPermission(Permission.REQUEST_4K) ||
fill="none" hasPermission(Permission.REQUEST_4K_MOVIE))) ? (
stroke="currentColor" <>
viewBox="0 0 24 24" {data.mediaInfo?.plexUrl &&
xmlns="http://www.w3.org/2000/svg" data.mediaInfo?.plexUrl4k &&
> (hasPermission(Permission.REQUEST_4K) ||
<path hasPermission(Permission.REQUEST_4K_MOVIE)) && (
strokeLinecap="round" <ButtonWithDropdown.Item
strokeLinejoin="round" onClick={() => {
strokeWidth={2} window.open(data.mediaInfo?.plexUrl4k, '_blank');
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" }}
/> buttonType="ghost"
<path >
strokeLinecap="round" {intl.formatMessage(messages.play4konplex)}
strokeLinejoin="round" </ButtonWithDropdown.Item>
strokeWidth={2} )}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" {(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) &&
/> trailerUrl && (
</svg> <ButtonWithDropdown.Item
<FormattedMessage {...messages.watchtrailer} /> onClick={() => {
</Button> window.open(trailerUrl, '_blank');
</a> }}
buttonType="ghost"
>
{intl.formatMessage(messages.watchtrailer)}
</ButtonWithDropdown.Item>
)}
</>
) : null}
</ButtonWithDropdown>
)} )}
<div className="mb-3 sm:mb-0"> <div className="mb-3 sm:mb-0">
<RequestButton <RequestButton
@ -550,6 +612,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
tmdbId={data.id} tmdbId={data.id}
imdbId={data.externalIds.imdbId} imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url} rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/> />
</div> </div>
</div> </div>

@ -13,18 +13,37 @@ interface StatusBadgeProps {
status?: MediaStatus; status?: MediaStatus;
is4k?: boolean; is4k?: boolean;
inProgress?: boolean; inProgress?: boolean;
plexUrl?: string;
plexUrl4k?: string;
} }
const StatusBadge: React.FC<StatusBadgeProps> = ({ const StatusBadge: React.FC<StatusBadgeProps> = ({
status, status,
is4k = false, is4k = false,
inProgress = false, inProgress = false,
plexUrl,
plexUrl4k,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
if (is4k) { if (is4k) {
switch (status) { switch (status) {
case MediaStatus.AVAILABLE: case MediaStatus.AVAILABLE:
if (plexUrl4k) {
return (
<a href={plexUrl4k} target="_blank" rel="noopener noreferrer">
<Badge
badgeType="success"
className="transition cursor-pointer hover:bg-green-400"
>
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.available),
})}
</Badge>
</a>
);
}
return ( return (
<Badge badgeType="success"> <Badge badgeType="success">
{intl.formatMessage(messages.status4k, { {intl.formatMessage(messages.status4k, {
@ -33,6 +52,21 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({
</Badge> </Badge>
); );
case MediaStatus.PARTIALLY_AVAILABLE: case MediaStatus.PARTIALLY_AVAILABLE:
if (plexUrl4k) {
return (
<a href={plexUrl4k} target="_blank" rel="noopener noreferrer">
<Badge
badgeType="success"
className="transition cursor-pointer hover:bg-green-400"
>
{intl.formatMessage(messages.status4k, {
status: intl.formatMessage(globalMessages.partiallyavailable),
})}
</Badge>
</a>
);
}
return ( return (
<Badge badgeType="success"> <Badge badgeType="success">
{intl.formatMessage(messages.status4k, { {intl.formatMessage(messages.status4k, {
@ -70,6 +104,22 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({
switch (status) { switch (status) {
case MediaStatus.AVAILABLE: case MediaStatus.AVAILABLE:
if (plexUrl) {
return (
<a href={plexUrl} target="_blank" rel="noopener noreferrer">
<Badge
badgeType="success"
className="transition cursor-pointer hover:bg-green-400"
>
<div className="flex items-center">
<span>{intl.formatMessage(globalMessages.available)}</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
</a>
);
}
return ( return (
<Badge badgeType="success"> <Badge badgeType="success">
<div className="flex items-center"> <div className="flex items-center">
@ -79,6 +129,24 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({
</Badge> </Badge>
); );
case MediaStatus.PARTIALLY_AVAILABLE: case MediaStatus.PARTIALLY_AVAILABLE:
if (plexUrl) {
return (
<a href={plexUrl} target="_blank" rel="noopener noreferrer">
<Badge
badgeType="success"
className="transition cursor-pointer hover:bg-green-400"
>
<div className="flex items-center">
<span>
{intl.formatMessage(globalMessages.partiallyavailable)}
</span>
{inProgress && <Spinner className="w-3 h-3 ml-1" />}
</div>
</Badge>
</a>
);
}
return ( return (
<Badge badgeType="success"> <Badge badgeType="success">
<div className="flex items-center"> <div className="flex items-center">

@ -37,6 +37,7 @@ import RequestButton from '../RequestButton';
import MediaSlider from '../MediaSlider'; import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton'; import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock'; import DownloadBlock from '../DownloadBlock';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
const messages = defineMessages({ const messages = defineMessages({
firstAirDate: 'First Air Date', firstAirDate: 'First Air Date',
@ -69,6 +70,8 @@ const messages = defineMessages({
opensonarr: 'Open Series in Sonarr', opensonarr: 'Open Series in Sonarr',
opensonarr4k: 'Open Series in 4K Sonarr', opensonarr4k: 'Open Series in 4K Sonarr',
downloadstatus: 'Download Status', downloadstatus: 'Download Status',
playonplex: 'Play on Plex',
play4konplex: 'Play 4K on Plex',
}); });
interface TvDetailsProps { interface TvDetailsProps {
@ -279,6 +282,8 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<StatusBadge <StatusBadge
status={data.mediaInfo?.status} status={data.mediaInfo?.status}
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0} inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
plexUrl4k={data.mediaInfo?.plexUrl4k}
/> />
</span> </span>
)} )}
@ -287,6 +292,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
status={data.mediaInfo?.status4k} status={data.mediaInfo?.status4k}
is4k is4k
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0} inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
plexUrl4k={
data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_TV))
? data.mediaInfo.plexUrl4k
: undefined
}
/> />
</span> </span>
</div> </div>
@ -303,37 +316,86 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</span> </span>
</div> </div>
<div className="flex flex-wrap justify-center flex-shrink-0 mt-4 sm:flex-nowrap sm:justify-end lg:mt-0"> <div className="flex flex-wrap justify-center flex-shrink-0 mt-4 sm:flex-nowrap sm:justify-end lg:mt-0">
{trailerUrl && ( {(trailerUrl ||
<a data.mediaInfo?.plexUrl ||
href={trailerUrl} data.mediaInfo?.plexUrl4k) && (
target="_blank" <ButtonWithDropdown
rel="noreferrer" buttonType="ghost"
className="mb-3 sm:mb-0" text={
<>
<svg
className="w-5 h-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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{data.mediaInfo?.plexUrl
? intl.formatMessage(messages.playonplex)
: data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_TV))
? intl.formatMessage(messages.play4konplex)
: intl.formatMessage(messages.watchtrailer)}
</span>
</>
}
onClick={() => {
if (data.mediaInfo?.plexUrl) {
window.open(data.mediaInfo?.plexUrl, '_blank');
} else if (data.mediaInfo?.plexUrl4k) {
window.open(data.mediaInfo?.plexUrl4k, '_blank');
} else if (trailerUrl) {
window.open(trailerUrl, '_blank');
}
}}
> >
<Button buttonType="ghost"> {data.mediaInfo?.plexUrl ||
<svg (data.mediaInfo?.plexUrl4k &&
className="w-5 h-5 mr-1" (hasPermission(Permission.REQUEST_4K) ||
fill="none" hasPermission(Permission.REQUEST_4K_TV))) ? (
stroke="currentColor" <>
viewBox="0 0 24 24" {data.mediaInfo?.plexUrl &&
xmlns="http://www.w3.org/2000/svg" data.mediaInfo?.plexUrl4k &&
> (hasPermission(Permission.REQUEST_4K) ||
<path hasPermission(Permission.REQUEST_4K_TV)) && (
strokeLinecap="round" <ButtonWithDropdown.Item
strokeLinejoin="round" onClick={() => {
strokeWidth={2} window.open(data.mediaInfo?.plexUrl4k, '_blank');
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" }}
/> buttonType="ghost"
<path >
strokeLinecap="round" {intl.formatMessage(messages.play4konplex)}
strokeLinejoin="round" </ButtonWithDropdown.Item>
strokeWidth={2} )}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" {(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) &&
/> trailerUrl && (
</svg> <ButtonWithDropdown.Item
<FormattedMessage {...messages.watchtrailer} /> onClick={() => {
</Button> window.open(trailerUrl, '_blank');
</a> }}
buttonType="ghost"
>
{intl.formatMessage(messages.watchtrailer)}
</ButtonWithDropdown.Item>
)}
</>
) : null}
</ButtonWithDropdown>
)} )}
<div className="mb-3 sm:mb-0"> <div className="mb-3 sm:mb-0">
<RequestButton <RequestButton
@ -553,6 +615,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
tmdbId={data.id} tmdbId={data.id}
imdbId={data.externalIds.imdbId} imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url} rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/> />
</div> </div>
</div> </div>

@ -59,6 +59,8 @@
"components.MovieDetails.overview": "Overview", "components.MovieDetails.overview": "Overview",
"components.MovieDetails.overviewunavailable": "Overview unavailable.", "components.MovieDetails.overviewunavailable": "Overview unavailable.",
"components.MovieDetails.pending": "Pending", "components.MovieDetails.pending": "Pending",
"components.MovieDetails.play4konplex": "Play 4K on Plex",
"components.MovieDetails.playonplex": "Play on Plex",
"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",
@ -513,6 +515,8 @@
"components.TvDetails.overview": "Overview", "components.TvDetails.overview": "Overview",
"components.TvDetails.overviewunavailable": "Overview unavailable.", "components.TvDetails.overviewunavailable": "Overview unavailable.",
"components.TvDetails.pending": "Pending", "components.TvDetails.pending": "Pending",
"components.TvDetails.play4konplex": "Play 4K on Plex",
"components.TvDetails.playonplex": "Play on Plex",
"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.showtype": "Show Type", "components.TvDetails.showtype": "Show Type",

@ -63,6 +63,7 @@ module.exports = {
margin: ['first', 'last', 'responsive'], margin: ['first', 'last', 'responsive'],
boxShadow: ['group-focus'], boxShadow: ['group-focus'],
opacity: ['disabled', 'hover', 'group-hover'], opacity: ['disabled', 'hover', 'group-hover'],
zIndex: ['hover', 'responsive'],
}, },
plugins: [ plugins: [
require('@tailwindcss/forms'), require('@tailwindcss/forms'),

Loading…
Cancel
Save