diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 5d0e3462..9ca195c6 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 bc315e35..4fec09c6 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 00000000..f9865c8f --- /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 a9367d73..c8639613 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 00000000..5debcdf3 --- /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 1e9a5d56..9118415f 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 422436c3..ecca1e15 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 6461e6f8..818e7654 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 81526c20..9e3f452a 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 2709681b..e43b6e00 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 fbb20f72..8f821bb5 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'),