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 })
public externalServiceSlug4k?: string;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true })
public ratingKey4k?: string;
public serviceUrl?: string;
public serviceUrl4k?: string;
public downloadStatus?: DownloadingItem[] = [];
public downloadStatus4k?: DownloadingItem[] = [];
public plexUrl?: string;
public plexUrl4k?: string;
constructor(init?: Partial<Media>) {
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()
public setServiceUrl(): void {
if (this.mediaType === MediaType.MOVIE) {

@ -68,6 +68,7 @@ class JobPlexSync {
private async processMovie(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
try {
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
@ -138,6 +139,23 @@ class JobPlexSync {
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) {
await mediaRepository.save(existing);
this.log(
@ -160,6 +178,12 @@ class JobPlexSync {
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
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);
this.log(`Saved ${plexitem.title}`);
}
@ -242,6 +266,23 @@ class JobPlexSync {
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) {
await mediaRepository.save(existing);
this.log(
@ -272,6 +313,12 @@ class JobPlexSync {
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
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);
this.log(`Saved ${tmdbMovie.title}`);
}
@ -311,12 +358,14 @@ class JobPlexSync {
let tvShow: TmdbTvDetails | null = null;
try {
const metadata = await this.plexClient.getMetadata(
const ratingKey =
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey,
{ includeChildren: true }
);
plexitem.parentRatingKey ??
plexitem.ratingKey;
const metadata = await this.plexClient.getMetadata(ratingKey, {
includeChildren: true,
});
if (metadata.guid.match(tvdbRegex)) {
const matchedtvdb = metadata.guid.match(tvdbRegex);
@ -454,6 +503,23 @@ class JobPlexSync {
episode.Media.some((media) => media.videoResolution === '4k')
).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) {
// 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,

@ -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;
externalIds: ExternalIds;
plexUrl?: string;
}
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');
break;
case 'success':
badgeStyle.push('bg-green-400 text-green-100');
badgeStyle.push('bg-green-500 text-green-100');
break;
default:
badgeStyle.push('bg-indigo-500 text-indigo-100');

@ -9,22 +9,41 @@ import useClickOutside from '../../../hooks/useClickOutside';
import Transition from '../../Transition';
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,
buttonType = 'primary',
...props
}) => (
<a
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}
>
{children}
</a>
);
}) => {
let styleClass = '';
switch (buttonType) {
case 'ghost':
styleClass =
'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
extends ButtonHTMLAttributes<HTMLButtonElement> {
text: ReactNode;
dropdownIcon?: ReactNode;
buttonType?: 'primary' | 'ghost';
}
const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
@ -32,29 +51,52 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
children,
dropdownIcon,
className,
buttonType = 'primary',
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
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 (
<span className="relative z-0 inline-flex h-full rounded-md shadow-sm">
<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 ${
children ? 'rounded-l-md' : 'rounded-md'
} ${className}`}
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 ${
styleClasses.mainButtonClasses
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
ref={buttonRef}
{...props}
>
{text}
</button>
<span className="relative block -ml-px">
<span className="relative z-10 block -ml-px">
{children && (
<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"
onClick={() => setIsOpen((state) => !state)}
>
@ -86,7 +128,9 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
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="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>
</div>

@ -2,12 +2,14 @@ import React from 'react';
import TmdbLogo from '../../assets/services/tmdb.svg';
import ImdbLogo from '../../assets/services/imdb.svg';
import RTLogo from '../../assets/services/rt.svg';
import PlexLogo from '../../assets/services/plex.svg';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
imdbId?: string;
tmdbId?: number;
rtUrl?: string;
plexUrl?: string;
}
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
@ -15,9 +17,20 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
tmdbId,
rtUrl,
mediaType,
plexUrl,
}) => {
return (
<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 && (
<a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}

@ -35,6 +35,7 @@ import RequestButton from '../RequestButton';
import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
const messages = defineMessages({
releasedate: 'Release Date',
@ -69,6 +70,8 @@ const messages = defineMessages({
openradarr: 'Open Movie in Radarr',
openradarr4k: 'Open Movie in 4K Radarr',
downloadstatus: 'Download Status',
playonplex: 'Play on Plex',
play4konplex: 'Play 4K on Plex',
});
interface MovieDetailsProps {
@ -252,6 +255,8 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
plexUrl4k={data.mediaInfo?.plexUrl4k}
/>
</span>
)}
@ -260,6 +265,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
status={data.mediaInfo?.status4k}
is4k
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>
</div>
@ -281,37 +294,86 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span>
</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">
{trailerUrl && (
<a
href={trailerUrl}
target={'_blank'}
rel="noreferrer"
className="mb-3 sm:mb-0"
{(trailerUrl ||
data.mediaInfo?.plexUrl ||
data.mediaInfo?.plexUrl4k) && (
<ButtonWithDropdown
buttonType="ghost"
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">
<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>
<FormattedMessage {...messages.watchtrailer} />
</Button>
</a>
{data.mediaInfo?.plexUrl ||
(data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE))) ? (
<>
{data.mediaInfo?.plexUrl &&
data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_MOVIE)) && (
<ButtonWithDropdown.Item
onClick={() => {
window.open(data.mediaInfo?.plexUrl4k, '_blank');
}}
buttonType="ghost"
>
{intl.formatMessage(messages.play4konplex)}
</ButtonWithDropdown.Item>
)}
{(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) &&
trailerUrl && (
<ButtonWithDropdown.Item
onClick={() => {
window.open(trailerUrl, '_blank');
}}
buttonType="ghost"
>
{intl.formatMessage(messages.watchtrailer)}
</ButtonWithDropdown.Item>
)}
</>
) : null}
</ButtonWithDropdown>
)}
<div className="mb-3 sm:mb-0">
<RequestButton
@ -550,6 +612,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
tmdbId={data.id}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/>
</div>
</div>

@ -13,18 +13,37 @@ interface StatusBadgeProps {
status?: MediaStatus;
is4k?: boolean;
inProgress?: boolean;
plexUrl?: string;
plexUrl4k?: string;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({
status,
is4k = false,
inProgress = false,
plexUrl,
plexUrl4k,
}) => {
const intl = useIntl();
if (is4k) {
switch (status) {
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 (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
@ -33,6 +52,21 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({
</Badge>
);
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 (
<Badge badgeType="success">
{intl.formatMessage(messages.status4k, {
@ -70,6 +104,22 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({
switch (status) {
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 (
<Badge badgeType="success">
<div className="flex items-center">
@ -79,6 +129,24 @@ const StatusBadge: React.FC<StatusBadgeProps> = ({
</Badge>
);
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 (
<Badge badgeType="success">
<div className="flex items-center">

@ -37,6 +37,7 @@ import RequestButton from '../RequestButton';
import MediaSlider from '../MediaSlider';
import ConfirmButton from '../Common/ConfirmButton';
import DownloadBlock from '../DownloadBlock';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
const messages = defineMessages({
firstAirDate: 'First Air Date',
@ -69,6 +70,8 @@ const messages = defineMessages({
opensonarr: 'Open Series in Sonarr',
opensonarr4k: 'Open Series in 4K Sonarr',
downloadstatus: 'Download Status',
playonplex: 'Play on Plex',
play4konplex: 'Play 4K on Plex',
});
interface TvDetailsProps {
@ -279,6 +282,8 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
plexUrl4k={data.mediaInfo?.plexUrl4k}
/>
</span>
)}
@ -287,6 +292,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
status={data.mediaInfo?.status4k}
is4k
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>
</div>
@ -303,37 +316,86 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</span>
</div>
<div className="flex flex-wrap justify-center flex-shrink-0 mt-4 sm:flex-nowrap sm:justify-end lg:mt-0">
{trailerUrl && (
<a
href={trailerUrl}
target="_blank"
rel="noreferrer"
className="mb-3 sm:mb-0"
{(trailerUrl ||
data.mediaInfo?.plexUrl ||
data.mediaInfo?.plexUrl4k) && (
<ButtonWithDropdown
buttonType="ghost"
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">
<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>
<FormattedMessage {...messages.watchtrailer} />
</Button>
</a>
{data.mediaInfo?.plexUrl ||
(data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_TV))) ? (
<>
{data.mediaInfo?.plexUrl &&
data.mediaInfo?.plexUrl4k &&
(hasPermission(Permission.REQUEST_4K) ||
hasPermission(Permission.REQUEST_4K_TV)) && (
<ButtonWithDropdown.Item
onClick={() => {
window.open(data.mediaInfo?.plexUrl4k, '_blank');
}}
buttonType="ghost"
>
{intl.formatMessage(messages.play4konplex)}
</ButtonWithDropdown.Item>
)}
{(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) &&
trailerUrl && (
<ButtonWithDropdown.Item
onClick={() => {
window.open(trailerUrl, '_blank');
}}
buttonType="ghost"
>
{intl.formatMessage(messages.watchtrailer)}
</ButtonWithDropdown.Item>
)}
</>
) : null}
</ButtonWithDropdown>
)}
<div className="mb-3 sm:mb-0">
<RequestButton
@ -553,6 +615,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
tmdbId={data.id}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/>
</div>
</div>

@ -59,6 +59,8 @@
"components.MovieDetails.overview": "Overview",
"components.MovieDetails.overviewunavailable": "Overview unavailable.",
"components.MovieDetails.pending": "Pending",
"components.MovieDetails.play4konplex": "Play 4K on Plex",
"components.MovieDetails.playonplex": "Play on Plex",
"components.MovieDetails.recommendations": "Recommendations",
"components.MovieDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.MovieDetails.releasedate": "Release Date",
@ -513,6 +515,8 @@
"components.TvDetails.overview": "Overview",
"components.TvDetails.overviewunavailable": "Overview unavailable.",
"components.TvDetails.pending": "Pending",
"components.TvDetails.play4konplex": "Play 4K on Plex",
"components.TvDetails.playonplex": "Play on Plex",
"components.TvDetails.recommendations": "Recommendations",
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.TvDetails.showtype": "Show Type",

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

Loading…
Cancel
Save