diff --git a/.all-contributorsrc b/.all-contributorsrc index 3614dbd1b..f696ecb91 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -448,6 +448,69 @@ "contributions": [ "security" ] + }, + { + "login": "j0srisk", + "name": "Joseph Risk", + "avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4", + "profile": "http://josephrisk.com", + "contributions": [ + "code" + ] + }, + { + "login": "Loetwiek", + "name": "Loetwiek", + "avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4", + "profile": "https://github.com/Loetwiek", + "contributions": [ + "code" + ] + }, + { + "login": "Fuochi", + "name": "Fuochi", + "avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4", + "profile": "https://github.com/Fuochi", + "contributions": [ + "doc" + ] + }, + { + "login": "demrich", + "name": "David Emrich", + "avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4", + "profile": "https://github.com/demrich", + "contributions": [ + "code" + ] + }, + { + "login": "maxnatamo", + "name": "Max T. Kristiansen", + "avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4", + "profile": "https://maxtrier.dk", + "contributions": [ + "code" + ] + }, + { + "login": "DamsDev1", + "name": "Damien Fajole", + "avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4", + "profile": "https://damsdev.me", + "contributions": [ + "code" + ] + }, + { + "login": "AhmedNSidd", + "name": "Ahmed Siddiqui", + "avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4", + "profile": "https://github.com/AhmedNSidd", + "contributions": [ + "code" + ] } ] } diff --git a/README.md b/README.md index fb6c8790a..e33184a11 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,12 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Joseph Risk
Joseph Risk

💻 Loetwiek
Loetwiek

💻 Fuochi
Fuochi

📖 + David Emrich
David Emrich

💻 + Max T. Kristiansen
Max T. Kristiansen

💻 + Damien Fajole
Damien Fajole

💻 + + + Ahmed Siddiqui
Ahmed Siddiqui

💻 diff --git a/overseerr-api.yml b/overseerr-api.yml index dfbbfd084..3e7df27f4 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -5486,7 +5486,7 @@ paths: - type: array items: type: number - minimum: 1 + minimum: 0 - type: string enum: [all] is4k: @@ -5592,7 +5592,7 @@ paths: type: array items: type: number - minimum: 1 + minimum: 0 is4k: type: boolean example: false diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 27bed1962..92bffa808 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -3,6 +3,7 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { randomUUID } from 'node:crypto'; import xml2js from 'xml2js'; interface PlexAccountResponse { @@ -127,6 +128,11 @@ export interface PlexWatchlistItem { title: string; } +export interface PlexWatchlistCache { + etag: string; + response: WatchlistResponse; +} + class PlexTvAPI extends ExternalAPI { private authToken: string; @@ -261,6 +267,11 @@ class PlexTvAPI extends ExternalAPI { items: PlexWatchlistItem[]; }> { try { + const watchlistCache = cacheManager.getCache('plexwatchlist'); + let cachedWatchlist = watchlistCache.data.get( + this.authToken + ); + const params = new URLSearchParams({ 'X-Plex-Container-Start': offset.toString(), 'X-Plex-Container-Size': size.toString(), @@ -268,42 +279,62 @@ class PlexTvAPI extends ExternalAPI { const response = await this.fetch( `https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`, { - headers: this.defaultHeaders, + headers: { + ...this.defaultHeaders, + ...(cachedWatchlist?.etag + ? { 'If-None-Match': cachedWatchlist.etag } + : {}), + }, } ); const data = (await response.json()) as WatchlistResponse; + // If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache. + if (response.status >= 200 && response.status <= 299) { + cachedWatchlist = { + etag: response.headers.get('etag') ?? '', + response: data, + }; + + watchlistCache.data.set( + this.authToken, + cachedWatchlist + ); + } + const watchlistDetails = await Promise.all( - (data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => { - const detailedResponse = await this.getRolling( - `/library/metadata/${watchlistItem.ratingKey}`, - {}, - undefined, - {}, - 'https://metadata.provider.plex.tv' - ); + (cachedWatchlist?.response.MediaContainer.Metadata ?? []).map( + async (watchlistItem) => { + const detailedResponse = await this.getRolling( + `/library/metadata/${watchlistItem.ratingKey}`, + {}, + undefined, + {}, + 'https://metadata.provider.plex.tv' + ); - const metadata = detailedResponse.MediaContainer.Metadata[0]; + const metadata = detailedResponse.MediaContainer.Metadata[0]; - const tmdbString = metadata.Guid.find((guid) => - guid.id.startsWith('tmdb') - ); - const tvdbString = metadata.Guid.find((guid) => - guid.id.startsWith('tvdb') - ); + const tmdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tmdb') + ); + const tvdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tvdb') + ); - return { - ratingKey: metadata.ratingKey, - // This should always be set? But I guess it also cannot be? - // We will filter out the 0's afterwards - tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, - tvdbId: tvdbString - ? Number(tvdbString.id.split('//')[1]) - : undefined, - title: metadata.title, - type: metadata.type, - }; - }) + return { + ratingKey: metadata.ratingKey, + // This should always be set? But I guess it also cannot be? + // We will filter out the 0's afterwards + tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, + tvdbId: tvdbString + ? Number(tvdbString.id.split('//')[1]) + : undefined, + title: metadata.title, + type: metadata.type, + }; + } + ) ); const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); @@ -311,7 +342,7 @@ class PlexTvAPI extends ExternalAPI { return { offset, size, - totalSize: data.MediaContainer.totalSize, + totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0, items: filteredList, }; } catch (e) { @@ -327,6 +358,29 @@ class PlexTvAPI extends ExternalAPI { }; } } + + public async pingToken() { + try { + const data: { pong: unknown } = await this.get( + '/api/v2/ping', + {}, + undefined, + { + headers: { + 'X-Plex-Client-Identifier': randomUUID(), + }, + } + ); + if (!data?.pong) { + throw new Error('No pong response'); + } + } catch (e) { + logger.error('Failed to ping token', { + label: 'Plex Refresh Token', + errorMessage: e.message, + }); + } + } } export default PlexTvAPI; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 6b2c7b56e..f56a1b5ed 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -257,9 +257,7 @@ export class MediaRequest { >; const requestedSeasons = requestBody.seasons === 'all' - ? tmdbMediaShow.seasons - .map((season) => season.season_number) - .filter((sn) => sn > 0) + ? tmdbMediaShow.seasons.map((season) => season.season_number) : (requestBody.seasons as number[]); let existingSeasons: number[] = []; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index a210988e4..ffc19daa7 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -2,6 +2,7 @@ import { MediaServerType } from '@server/constants/server'; import availabilitySync from '@server/lib/availabilitySync'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; +import refreshToken from '@server/lib/refreshToken'; import { jellyfinFullScanner, jellyfinRecentScanner, @@ -13,7 +14,6 @@ import type { JobId } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import watchlistSync from '@server/lib/watchlistsync'; import logger from '@server/logger'; -import random from 'lodash/random'; import schedule from 'node-schedule'; interface ScheduledJob { @@ -113,30 +113,20 @@ export const startJobs = (): void => { } // Watchlist Sync - const watchlistSyncJob: ScheduledJob = { + scheduledJobs.push({ id: 'plex-watchlist-sync', name: 'Plex Watchlist Sync', type: 'process', - interval: 'fixed', + interval: 'seconds', cronSchedule: jobs['plex-watchlist-sync'].schedule, - job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => { + job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => { logger.info('Starting scheduled job: Plex Watchlist Sync', { label: 'Jobs', }); watchlistSync.syncWatchlist(); }), - }; - - // To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule - // after each run - watchlistSyncJob.job.on('run', () => { - watchlistSyncJob.job.schedule( - new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true))) - ); }); - scheduledJobs.push(watchlistSyncJob); - // Run full radarr scan every 24 hours scheduledJobs.push({ id: 'radarr-scan', @@ -233,5 +223,19 @@ export const startJobs = (): void => { }), }); + scheduledJobs.push({ + id: 'plex-refresh-token', + name: 'Plex Refresh Token', + type: 'process', + interval: 'fixed', + cronSchedule: jobs['plex-refresh-token'].schedule, + job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => { + logger.info('Starting scheduled job: Plex Refresh Token', { + label: 'Jobs', + }); + refreshToken.run(); + }), + }); + logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 011205e7f..51d0e08f2 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -8,7 +8,8 @@ export type AvailableCacheIds = | 'imdb' | 'github' | 'plexguid' - | 'plextv'; + | 'plextv' + | 'plexwatchlist'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -68,6 +69,7 @@ class CacheManager { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60, }), + plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/refreshToken.ts b/server/lib/refreshToken.ts new file mode 100644 index 000000000..ac7bd3463 --- /dev/null +++ b/server/lib/refreshToken.ts @@ -0,0 +1,37 @@ +import PlexTvAPI from '@server/api/plextv'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import logger from '@server/logger'; + +class RefreshToken { + public async run() { + const userRepository = getRepository(User); + + const users = await userRepository + .createQueryBuilder('user') + .addSelect('user.plexToken') + .where("user.plexToken != ''") + .getMany(); + + for (const user of users) { + await this.refreshUserToken(user); + } + } + + private async refreshUserToken(user: User) { + if (!user.plexToken) { + logger.warn('Skipping user refresh token for user without plex token', { + label: 'Plex Refresh Token', + user: user.displayName, + }); + return; + } + + const plexTvApi = new PlexTvAPI(user.plexToken); + plexTvApi.pingToken(); + } +} + +const refreshToken = new RefreshToken(); + +export default refreshToken; diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index f6049630c..e4af7a1f7 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -278,9 +278,7 @@ class PlexScanner const seasons = tvShow.seasons; const processableSeasons: ProcessableSeason[] = []; - const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0); - - for (const season of filteredSeasons) { + for (const season of seasons) { const matchedPlexSeason = metadata.Children?.Metadata.find( (md) => Number(md.index) === season.season_number ); diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 3256c9482..5d28e0144 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -103,10 +103,8 @@ class SonarrScanner const tmdbId = tvShow.id; - const filteredSeasons = sonarrSeries.seasons.filter( - (sn) => - sn.seasonNumber !== 0 && - tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) + const filteredSeasons = sonarrSeries.seasons.filter((sn) => + tvShow.seasons.find((s) => s.season_number === sn.seasonNumber) ); for (const season of filteredSeasons) { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 425fc1389..509330348 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -282,6 +282,7 @@ export type JobId = | 'plex-recently-added-scan' | 'plex-full-scan' | 'plex-watchlist-sync' + | 'plex-refresh-token' | 'radarr-scan' | 'sonarr-scan' | 'download-sync' @@ -469,7 +470,10 @@ class Settings { schedule: '0 0 3 * * *', }, 'plex-watchlist-sync': { - schedule: '0 */10 * * * *', + schedule: '0 */3 * * * *', + }, + 'plex-refresh-token': { + schedule: '0 0 5 * * *', }, 'radarr-scan': { schedule: '0 0 4 * * *', diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 2d1984517..4919bf70c 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -62,7 +62,7 @@ class WatchlistSync { const plexTvApi = new PlexTvAPI(user.plexToken); - const response = await plexTvApi.getWatchlist({ size: 200 }); + const response = await plexTvApi.getWatchlist({ size: 20 }); const mediaItems = await Media.getRelatedMedia( user, diff --git a/src/components/Discover/StudioSlider/index.tsx b/src/components/Discover/StudioSlider/index.tsx index d1e88d451..6b23f4bdd 100644 --- a/src/components/Discover/StudioSlider/index.tsx +++ b/src/components/Discover/StudioSlider/index.tsx @@ -74,6 +74,12 @@ const studios: Studio[] = [ 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/2Tc1P3Ac8M479naPp1kYT3izLS5.png', url: '/discover/movies/studio/9993', }, + { + name: 'A24', + image: + 'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1ZXsGaFPgrgS6ZZGS37AqD5uU12.png', + url: '/discover/movies/studio/41077', + }, ]; const StudioSlider = () => { diff --git a/src/components/Layout/PullToRefresh/index.tsx b/src/components/Layout/PullToRefresh/index.tsx index cdedcf43c..f2a1c7bae 100644 --- a/src/components/Layout/PullToRefresh/index.tsx +++ b/src/components/Layout/PullToRefresh/index.tsx @@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'react'; const PullToRefresh = () => { const router = useRouter(); - const [pullStartPoint, setPullStartPoint] = useState(0); const [pullChange, setPullChange] = useState(0); const refreshDiv = useRef(null); @@ -19,6 +18,7 @@ const PullToRefresh = () => { // Reload function that is called when reload threshold has been hit // Add loading class to determine when to add spin animation const forceReload = () => { + setPullStartPoint(0); refreshDiv.current?.classList.add('loading'); setTimeout(() => { router.reload(); @@ -32,6 +32,8 @@ const PullToRefresh = () => { const pullStart = (e: TouchEvent) => { setPullStartPoint(e.targetTouches[0].screenY); + const html = document.querySelector('html'); + if (window.scrollY === 0 && window.scrollX === 0) { refreshDiv.current?.classList.add('block'); refreshDiv.current?.classList.remove('hidden'); @@ -41,6 +43,7 @@ const PullToRefresh = () => { html.style.overscrollBehaviorY = 'none'; } } else { + setPullStartPoint(0); refreshDiv.current?.classList.remove('block'); refreshDiv.current?.classList.add('hidden'); } @@ -49,7 +52,6 @@ const PullToRefresh = () => { // Tracks how far we have pulled down the refresh icon const pullDown = async (e: TouchEvent) => { const screenY = e.targetTouches[0].screenY; - const pullLength = pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0; @@ -59,12 +61,11 @@ const PullToRefresh = () => { // Will reload the page if we are past the threshold // Otherwise, we reset the pull const pullFinish = () => { - setPullStartPoint(0); - - if (pullDownReloadThreshold) { + if (pullDownReloadThreshold && pullStartPoint !== 0) { forceReload(); } else { setPullChange(0); + setTimeout(() => setPullStartPoint(0), 200); } document.body.style.touchAction = 'auto'; @@ -83,7 +84,21 @@ const PullToRefresh = () => { window.removeEventListener('touchmove', pullDown); window.removeEventListener('touchend', pullFinish); }; - }, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]); + }, [ + pullDownInitThreshold, + pullDownReloadThreshold, + pullStartPoint, + refreshDiv, + router, + setPullStartPoint, + ]); + + if ( + pullStartPoint === 0 && + !refreshDiv.current?.classList.contains('loading') + ) { + return null; + } return (
{
{ key={`season-${season.id}`} className="mb-1 mr-2 inline-block" > - {season.seasonNumber} + + {season.seasonNumber === 0 + ? intl.formatMessage(globalMessages.specials) + : season.seasonNumber} + ))}
diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 1432148db..e737c7330 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -411,8 +411,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { {intl.formatMessage(messages.seasons, { seasonCount: - title.seasons.filter((season) => season.seasonNumber !== 0) - .length === request.seasons.length + title.seasons.length === request.seasons.length ? 0 : request.seasons.length, })} @@ -420,7 +419,11 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
{request.seasons.map((season) => ( - {season.seasonNumber} + + {season.seasonNumber === 0 + ? intl.formatMessage(globalMessages.specials) + : season.seasonNumber} + ))}
diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 80ba2ab7a..7f64039cb 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -481,9 +481,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { {intl.formatMessage(messages.seasons, { seasonCount: - title.seasons.filter( - (season) => season.seasonNumber !== 0 - ).length === request.seasons.length + title.seasons.length === request.seasons.length ? 0 : request.seasons.length, })} @@ -491,7 +489,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
{request.seasons.map((season) => ( - {season.seasonNumber} + + {season.seasonNumber === 0 + ? intl.formatMessage(globalMessages.specials) + : season.seasonNumber} + ))}
diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 71750678c..10c9c7db8 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -42,7 +42,6 @@ const messages = defineMessages('components.RequestModal', { season: 'Season', numberofepisodes: '# of Episodes', seasonnumber: 'Season {number}', - extras: 'Extras', errorediting: 'Something went wrong while editing the request.', requestedited: 'Request for {title} edited successfully!', requestApproved: 'Request for {title} approved!', @@ -255,9 +254,7 @@ const TvRequestModal = ({ const getAllSeasons = (): number[] => { return (data?.seasons ?? []) - .filter( - (season) => season.seasonNumber !== 0 && season.episodeCount !== 0 - ) + .filter((season) => season.episodeCount !== 0) .map((season) => season.seasonNumber); }; @@ -580,10 +577,7 @@ const TvRequestModal = ({ {data?.seasons - .filter( - (season) => - season.seasonNumber !== 0 && season.episodeCount !== 0 - ) + .filter((season) => season.episodeCount !== 0) .map((season) => { const seasonRequest = getSeasonRequest( season.seasonNumber @@ -660,7 +654,7 @@ const TvRequestModal = ({ {season.seasonNumber === 0 - ? intl.formatMessage(messages.extras) + ? intl.formatMessage(globalMessages.specials) : intl.formatMessage(messages.seasonnumber, { number: season.seasonNumber, })} diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 975de36c7..aeba15316 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -58,6 +58,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages( 'plex-recently-added-scan': 'Plex Recently Added Scan', 'plex-full-scan': 'Plex Full Library Scan', 'plex-watchlist-sync': 'Plex Watchlist Sync', + 'plex-refresh-token': 'Plex Refresh Token', 'jellyfin-full-scan': 'Jellyfin Full Library Scan', 'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan', 'availability-sync': 'Media Availability Sync', diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index f4d058a83..bc4dfd7af 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -238,6 +238,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => { ); } + // Does NOT include "Specials" const seasonCount = data.seasons.filter( (season) => season.seasonNumber !== 0 && season.episodeCount !== 0 ).length; @@ -299,9 +300,17 @@ const TvDetails = ({ tv }: TvDetailsProps) => { return [...requestedSeasons, ...availableSeasons]; }; - const isComplete = seasonCount <= getAllRequestedSeasons(false).length; + const showHasSpecials = data.seasons.some( + (season) => season.seasonNumber === 0 + ); - const is4kComplete = seasonCount <= getAllRequestedSeasons(true).length; + const isComplete = + (showHasSpecials ? seasonCount + 1 : seasonCount) <= + getAllRequestedSeasons(false).length; + + const is4kComplete = + (showHasSpecials ? seasonCount + 1 : seasonCount) <= + getAllRequestedSeasons(true).length; const streamingProviders = data?.watchProviders?.find((provider) => provider.iso_3166_1 === region) @@ -784,7 +793,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => { {data.seasons .slice() .reverse() - .filter((season) => season.seasonNumber !== 0) .map((season) => { const show4k = settings.currentSettings.series4kEnabled && @@ -838,9 +846,11 @@ const TvDetails = ({ tv }: TvDetailsProps) => { >
- {intl.formatMessage(messages.seasonnumber, { - seasonNumber: season.seasonNumber, - })} + {season.seasonNumber === 0 + ? intl.formatMessage(globalMessages.specials) + : intl.formatMessage(messages.seasonnumber, { + seasonNumber: season.seasonNumber, + })} {intl.formatMessage(messages.episodeCount, { diff --git a/src/hooks/useDeepLinks.ts b/src/hooks/useDeepLinks.ts index 983086591..bc367229e 100644 --- a/src/hooks/useDeepLinks.ts +++ b/src/hooks/useDeepLinks.ts @@ -23,7 +23,7 @@ const useDeepLinks = ({ if ( settings.currentSettings.mediaServerType === MediaServerType.PLEX && (/iPad|iPhone|iPod/.test(navigator.userAgent) || - (navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1)) + (navigator.userAgent.includes('Mac') && navigator.maxTouchPoints > 1)) ) { setReturnedMediaUrl(iOSPlexUrl); setReturnedMediaUrl4k(iOSPlexUrl4k); diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 6aa5ed1da..1765f1931 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -65,6 +65,7 @@ const globalMessages = defineMessages('i18n', { '{title} was successfully removed from the Blacklist.', addToBlacklist: 'Add to Blacklist', removefromBlacklist: 'Remove from Blacklist', + specials: 'Specials', }); export default globalMessages; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 7086acfc3..73ff71a6f 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -536,7 +536,6 @@ "components.RequestModal.cancel": "Cancel Request", "components.RequestModal.edit": "Edit Request", "components.RequestModal.errorediting": "Something went wrong while editing the request.", - "components.RequestModal.extras": "Extras", "components.RequestModal.numberofepisodes": "# of Episodes", "components.RequestModal.pending4krequest": "Pending 4K Request", "components.RequestModal.pendingapproval": "Your request is pending approval.", @@ -847,6 +846,7 @@ "components.Settings.SettingsJobsCache.nextexecution": "Next Execution", "components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan", "components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan", + "components.Settings.SettingsJobsCache.plex-refresh-token": "Plex Refresh Token", "components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync", "components.Settings.SettingsJobsCache.process": "Process", "components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan", @@ -1381,6 +1381,7 @@ "i18n.saving": "Saving…", "i18n.settings": "Settings", "i18n.showingresults": "Showing {from} to {to} of {total} results", + "i18n.specials": "Specials", "i18n.status": "Status", "i18n.test": "Test", "i18n.testing": "Testing…",