From 1d7150c24ec5ad347093889bfceab61b664900d5 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Thu, 28 Jan 2021 07:13:43 +0100 Subject: [PATCH] feat(media): add link to the item on plex (#735) Co-authored-by: sct --- server/entity/Media.ts | 20 +++ server/job/plexsync/index.ts | 76 ++++++++++- .../1611801511397-AddRatingKeysToMedia.ts | 51 ++++++++ server/models/Movie.ts | 1 + src/assets/services/plex.svg | 1 + src/components/Common/Badge/index.tsx | 2 +- .../Common/ButtonWithDropdown/index.tsx | 74 ++++++++--- src/components/ExternalLinkBlock/index.tsx | 13 ++ src/components/MovieDetails/index.tsx | 123 +++++++++++++----- src/components/StatusBadge/index.tsx | 68 ++++++++++ src/components/TvDetails/index.tsx | 123 +++++++++++++----- src/i18n/locale/en.json | 4 + tailwind.config.js | 1 + 13 files changed, 476 insertions(+), 81 deletions(-) create mode 100644 server/migration/1611801511397-AddRatingKeysToMedia.ts create mode 100644 src/assets/services/plex.svg diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 5d0e34621..9ca195c68 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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) { 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) { diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index bc315e351..4fec09c69 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -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, diff --git a/server/migration/1611801511397-AddRatingKeysToMedia.ts b/server/migration/1611801511397-AddRatingKeysToMedia.ts new file mode 100644 index 000000000..f9865c8f5 --- /dev/null +++ b/server/migration/1611801511397-AddRatingKeysToMedia.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRatingKeysToMedia1611801511397 implements MigrationInterface { + name = 'AddRatingKeysToMedia1611801511397'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + } +} diff --git a/server/models/Movie.ts b/server/models/Movie.ts index a9367d73e..c86396130 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -72,6 +72,7 @@ export interface MovieDetails { }; mediaInfo?: Media; externalIds: ExternalIds; + plexUrl?: string; } export const mapMovieDetails = ( diff --git a/src/assets/services/plex.svg b/src/assets/services/plex.svg new file mode 100644 index 000000000..5debcdf37 --- /dev/null +++ b/src/assets/services/plex.svg @@ -0,0 +1 @@ +plex-logo \ No newline at end of file diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 1e9a5d56f..9118415f2 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -22,7 +22,7 @@ const Badge: React.FC = ({ 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'); diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 422436c3b..ecca1e15e 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -9,22 +9,41 @@ import useClickOutside from '../../../hooks/useClickOutside'; import Transition from '../../Transition'; import { withProperties } from '../../../utils/typeHelpers'; -const DropdownItem: React.FC> = ({ +interface DropdownItemProps extends AnchorHTMLAttributes { + buttonType?: 'primary' | 'ghost'; +} + +const DropdownItem: React.FC = ({ children, + buttonType = 'primary', ...props -}) => ( - - {children} - -); +}) => { + 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 ( + + {children} + + ); +}; interface ButtonWithDropdownProps extends ButtonHTMLAttributes { text: ReactNode; dropdownIcon?: ReactNode; + buttonType?: 'primary' | 'ghost'; } const ButtonWithDropdown: React.FC = ({ @@ -32,29 +51,52 @@ const ButtonWithDropdown: React.FC = ({ children, dropdownIcon, className, + buttonType = 'primary', ...props }) => { const [isOpen, setIsOpen] = useState(false); const buttonRef = useRef(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 ( - + {children && ( - + {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)) && ( + { + window.open(data.mediaInfo?.plexUrl4k, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.play4konplex)} + + )} + {(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) && + trailerUrl && ( + { + window.open(trailerUrl, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.watchtrailer)} + + )} + + ) : null} + )}
= ({ movie }) => { tmdbId={data.id} imdbId={data.externalIds.imdbId} rtUrl={ratingData?.url} + plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k} />
diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index 6461e6f89..818e76549 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -13,18 +13,37 @@ interface StatusBadgeProps { status?: MediaStatus; is4k?: boolean; inProgress?: boolean; + plexUrl?: string; + plexUrl4k?: string; } const StatusBadge: React.FC = ({ status, is4k = false, inProgress = false, + plexUrl, + plexUrl4k, }) => { const intl = useIntl(); if (is4k) { switch (status) { case MediaStatus.AVAILABLE: + if (plexUrl4k) { + return ( + + + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage(globalMessages.available), + })} + + + ); + } + return ( {intl.formatMessage(messages.status4k, { @@ -33,6 +52,21 @@ const StatusBadge: React.FC = ({ ); case MediaStatus.PARTIALLY_AVAILABLE: + if (plexUrl4k) { + return ( + + + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage(globalMessages.partiallyavailable), + })} + + + ); + } + return ( {intl.formatMessage(messages.status4k, { @@ -70,6 +104,22 @@ const StatusBadge: React.FC = ({ switch (status) { case MediaStatus.AVAILABLE: + if (plexUrl) { + return ( + + +
+ {intl.formatMessage(globalMessages.available)} + {inProgress && } +
+
+
+ ); + } + return (
@@ -79,6 +129,24 @@ const StatusBadge: React.FC = ({ ); case MediaStatus.PARTIALLY_AVAILABLE: + if (plexUrl) { + return ( + + +
+ + {intl.formatMessage(globalMessages.partiallyavailable)} + + {inProgress && } +
+
+
+ ); + } + return (
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 81526c20c..9e3f452a2 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -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 = ({ tv }) => { 0} + plexUrl={data.mediaInfo?.plexUrl} + plexUrl4k={data.mediaInfo?.plexUrl4k} /> )} @@ -287,6 +292,14 @@ const TvDetails: React.FC = ({ 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 + } />
@@ -303,37 +316,86 @@ const TvDetails: React.FC = ({ tv }) => {
- {trailerUrl && ( - + + + + + + {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)} + + + } + 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'); + } + }} > - - + {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)) && ( + { + window.open(data.mediaInfo?.plexUrl4k, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.play4konplex)} + + )} + {(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) && + trailerUrl && ( + { + window.open(trailerUrl, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.watchtrailer)} + + )} + + ) : null} + )}
= ({ tv }) => { tmdbId={data.id} imdbId={data.externalIds.imdbId} rtUrl={ratingData?.url} + plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k} />
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 2709681b9..e43b6e008 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -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", diff --git a/tailwind.config.js b/tailwind.config.js index fbb20f727..8f821bb5a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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'),