Merge remote-tracking branch 'upstream/develop' into fix/jellyfin-translation

This commit is contained in:
Daniel Fendrich
2023-08-24 19:26:42 +02:00
26 changed files with 1416 additions and 932 deletions

View File

@@ -872,6 +872,33 @@
"contributions": [ "contributions": [
"code" "code"
] ]
},
{
"login": "scorp200",
"name": "Anton K. (ai Doge)",
"avatar_url": "https://avatars.githubusercontent.com/u/9427639?v=4",
"profile": "http://aidoge.xyz",
"contributions": [
"code"
]
},
{
"login": "marcofaggian",
"name": "Marco Faggian",
"avatar_url": "https://avatars.githubusercontent.com/u/19221001?v=4",
"profile": "https://marcofaggian.com",
"contributions": [
"code"
]
},
{
"login": "nemchik",
"name": "Eric Nemchik",
"avatar_url": "https://avatars.githubusercontent.com/u/725456?v=4",
"profile": "http://nemchik.com/",
"contributions": [
"code"
]
} }
], ],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>", "badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

View File

@@ -31,7 +31,7 @@ jobs:
build_and_push: build_and_push:
name: Build & Publish Docker Images name: Build & Publish Docker Images
if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]') if: github.ref == 'refs/heads/develop' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: self-hosted runs-on: ubuntu-20.04
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
@@ -39,13 +39,6 @@ jobs:
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -68,15 +61,6 @@ jobs:
COMMIT_TAG=${{ github.sha }} COMMIT_TAG=${{ github.sha }}
tags: | tags: |
fallenbagel/jellyseerr:develop fallenbagel/jellyseerr:develop
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temporary fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
discord: discord:
name: Send Discord Notification name: Send Discord Notification

View File

@@ -5,6 +5,9 @@
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a> <a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/badge/Discord-Chat-lightgrey" alt="Discord"></a>
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a> <a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a> <a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-98-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
**Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers! **Jellyseerr** is a free and open source software application for managing requests for your media library. It is a a fork of Overseerr built to bring support for Jellyfin & Emby media servers!

View File

@@ -5657,6 +5657,63 @@ paths:
audienceRating: audienceRating:
type: string type: string
enum: ['Spilled', 'Upright'] enum: ['Spilled', 'Upright']
/movie/{movieId}/ratingscombined:
get:
summary: Get RT and IMDB movie ratings combined
description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object.
tags:
- movies
parameters:
- in: path
name: movieId
required: true
schema:
type: number
example: 337401
responses:
'200':
description: Ratings returned
content:
application/json:
schema:
type: object
properties:
rt:
type: object
properties:
title:
type: string
example: Mulan
year:
type: number
example: 2020
url:
type: string
example: 'http://www.rottentomatoes.com/m/mulan_2020/'
criticsScore:
type: number
example: 85
criticsRating:
type: string
enum: ['Rotten', 'Fresh', 'Certified Fresh']
audienceScore:
type: number
example: 65
audienceRating:
type: string
enum: ['Spilled', 'Upright']
imdb:
type: object
properties:
title:
type: string
example: I am Legend
url:
type: string
example: 'https://www.imdb.com/title/tt0480249'
criticsScore:
type: number
example: 6.5
/tv/{tvId}: /tv/{tvId}:
get: get:
summary: Get TV details summary: Get TV details

View File

@@ -171,28 +171,25 @@ class JellyfinAPI {
public async getLibraries(): Promise<JellyfinLibrary[]> { public async getLibraries(): Promise<JellyfinLibrary[]> {
try { try {
const account = await this.axios.get<any>( const libraries = await this.axios.get<any>('/Library/VirtualFolders');
`/Users/${this.userId ?? 'Me'}/Views`
);
const response: JellyfinLibrary[] = account.data.Items.filter( const response: JellyfinLibrary[] = libraries.data
(Item: any) => { .filter((Item: any) => {
return ( return (
Item.Type === 'CollectionFolder' &&
Item.CollectionType !== 'music' && Item.CollectionType !== 'music' &&
Item.CollectionType !== 'books' && Item.CollectionType !== 'books' &&
Item.CollectionType !== 'musicvideos' && Item.CollectionType !== 'musicvideos' &&
Item.CollectionType !== 'homevideos' Item.CollectionType !== 'homevideos'
); );
} })
).map((Item: any) => { .map((Item: any) => {
return <JellyfinLibrary>{ return <JellyfinLibrary>{
key: Item.Id, key: Item.ItemId,
title: Item.Name, title: Item.Name,
type: Item.CollectionType === 'movies' ? 'movie' : 'show', type: Item.CollectionType === 'movies' ? 'movie' : 'show',
agent: 'jellyfin', agent: 'jellyfin',
}; };
}); });
return response; return response;
} catch (e) { } catch (e) {

View File

@@ -82,21 +82,6 @@ interface ServerResponse {
}; };
} }
interface FriendResponse {
MediaContainer: {
User: {
$: {
id: string;
title: string;
username: string;
email: string;
thumb: string;
};
Server?: ServerResponse[];
}[];
};
}
interface UsersResponse { interface UsersResponse {
MediaContainer: { MediaContainer: {
User: { User: {
@@ -234,19 +219,6 @@ class PlexTvAPI extends ExternalAPI {
} }
} }
public async getFriends(): Promise<FriendResponse> {
const response = await this.axios.get('/pms/friends/all', {
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise(
response.data
)) as FriendResponse;
return parsedXml;
}
public async checkUserAccess(userId: number): Promise<boolean> { public async checkUserAccess(userId: number): Promise<boolean> {
const settings = getSettings(); const settings = getSettings();
@@ -255,9 +227,9 @@ class PlexTvAPI extends ExternalAPI {
throw new Error('Plex is not configured!'); throw new Error('Plex is not configured!');
} }
const friends = await this.getFriends(); const usersResponse = await this.getUsers();
const users = friends.MediaContainer.User; const users = usersResponse.MediaContainer.User;
const user = users.find((u) => parseInt(u.$.id) === userId); const user = users.find((u) => parseInt(u.$.id) === userId);

View File

@@ -0,0 +1,195 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
type IMDBRadarrProxyResponse = IMDBMovie[];
interface IMDBMovie {
ImdbId: string;
Overview: string;
Title: string;
OriginalTitle: string;
TitleSlug: string;
Ratings: Rating[];
MovieRatings: MovieRatings;
Runtime: number;
Images: Image[];
Genres: string[];
Popularity: number;
Premier: string;
InCinema: string;
PhysicalRelease: any;
DigitalRelease: string;
Year: number;
AlternativeTitles: AlternativeTitle[];
Translations: Translation[];
Recommendations: Recommendation[];
Credits: Credits;
Studio: string;
YoutubeTrailerId: string;
Certifications: Certification[];
Status: any;
Collection: Collection;
OriginalLanguage: string;
Homepage: string;
TmdbId: number;
}
interface Rating {
Count: number;
Value: number;
Origin: string;
Type: string;
}
interface MovieRatings {
Tmdb: Tmdb;
Imdb: Imdb;
Metacritic: Metacritic;
RottenTomatoes: RottenTomatoes;
}
interface Tmdb {
Count: number;
Value: number;
Type: string;
}
interface Imdb {
Count: number;
Value: number;
Type: string;
}
interface Metacritic {
Count: number;
Value: number;
Type: string;
}
interface RottenTomatoes {
Count: number;
Value: number;
Type: string;
}
interface Image {
CoverType: string;
Url: string;
}
interface AlternativeTitle {
Title: string;
Type: string;
Language: string;
}
interface Translation {
Title: string;
Overview: string;
Language: string;
}
interface Recommendation {
TmdbId: number;
Title: string;
}
interface Credits {
Cast: Cast[];
Crew: Crew[];
}
interface Cast {
Name: string;
Order: number;
Character: string;
TmdbId: number;
CreditId: string;
Images: Image2[];
}
interface Image2 {
CoverType: string;
Url: string;
}
interface Crew {
Name: string;
Job: string;
Department: string;
TmdbId: number;
CreditId: string;
Images: Image3[];
}
interface Image3 {
CoverType: string;
Url: string;
}
interface Certification {
Country: string;
Certification: string;
}
interface Collection {
Name: string;
Images: any;
Overview: any;
Translations: any;
Parts: any;
TmdbId: number;
}
export interface IMDBRating {
title: string;
url: string;
criticsScore: number;
}
/**
* This is a best-effort API. The IMDB API is technically
* private and getting access costs money/requires approval.
*
* Radarr hosts a public proxy that's in use by all Radarr instances.
*/
class IMDBRadarrProxy extends ExternalAPI {
constructor() {
super('https://api.radarr.video/v1', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('imdb').data,
});
}
/**
* Ask the Radarr IMDB Proxy for the movie
*
* @param IMDBid Id of IMDB movie
*/
public async getMovieRatings(IMDBid: string): Promise<IMDBRating | null> {
try {
const data = await this.get<IMDBRadarrProxyResponse>(
`/movie/imdb/${IMDBid}`
);
if (!data?.length || data[0].ImdbId !== IMDBid) {
return null;
}
return {
title: data[0].Title,
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
criticsScore: data[0].MovieRatings.Imdb.Value,
};
} catch (e) {
throw new Error(
`[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}`
);
}
}
}
export default IMDBRadarrProxy;

View File

@@ -1,6 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache'; import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import ExternalAPI from './externalapi';
interface RTAlgoliaSearchResponse { interface RTAlgoliaSearchResponse {
results: { results: {
@@ -144,6 +144,9 @@ class RottenTomatoes extends ExternalAPI {
? 'Fresh' ? 'Fresh'
: 'Rotten', : 'Rotten',
criticsScore: movie.rottenTomatoes.criticsScore, criticsScore: movie.rottenTomatoes.criticsScore,
audienceRating:
movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
audienceScore: movie.rottenTomatoes.audienceScore,
year: Number(movie.releaseYear), year: Number(movie.releaseYear),
}; };
} catch (e) { } catch (e) {
@@ -192,6 +195,9 @@ class RottenTomatoes extends ExternalAPI {
criticsRating: criticsRating:
tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten', tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten',
criticsScore: tvshow.rottenTomatoes.criticsScore, criticsScore: tvshow.rottenTomatoes.criticsScore,
audienceRating:
tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled',
audienceScore: tvshow.rottenTomatoes.audienceScore,
year: Number(tvshow.releaseYear), year: Number(tvshow.releaseYear),
}; };
} catch (e) { } catch (e) {

7
server/api/ratings.ts Normal file
View File

@@ -0,0 +1,7 @@
import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy';
import { type RTRating } from '@server/api/rating/rottentomatoes';
export interface RatingResponse {
rt?: RTRating;
imdb?: IMDBRating;
}

View File

@@ -311,13 +311,15 @@ class JobJellyfinSync {
// 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,
// and then not modifying the status if there are 0 items // and then not modifying the status if there are 0 items
existingSeason.status = existingSeason.status =
totalStandard >= season.episode_count totalStandard >= season.episode_count ||
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: totalStandard > 0 : totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE ? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status; : existingSeason.status;
existingSeason.status4k = existingSeason.status4k =
this.enable4kShow && total4k >= season.episode_count (this.enable4kShow && total4k >= season.episode_count) ||
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0 : this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE ? MediaStatus.PARTIALLY_AVAILABLE

View File

@@ -8,6 +8,7 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync'; import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger'; import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule'; import schedule from 'node-schedule';
import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync'; import { jobJellyfinFullSync, jobJellyfinRecentSync } from './jellyfinsync';
@@ -107,21 +108,31 @@ export const startJobs = (): void => {
}); });
} }
// Run watchlist sync every 5 minutes // Watchlist Sync
scheduledJobs.push({ const watchlistSyncJob: ScheduledJob = {
id: 'plex-watchlist-sync', id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync', name: 'Plex Watchlist Sync',
type: 'process', type: 'process',
interval: 'minutes', interval: 'fixed',
cronSchedule: jobs['plex-watchlist-sync'].schedule, cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => { job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', { logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs', label: 'Jobs',
}); });
watchlistSync.syncWatchlist(); 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 // Run full radarr scan every 24 hours
scheduledJobs.push({ scheduledJobs.push({
id: 'radarr-scan', id: 'radarr-scan',

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ export type AvailableCacheIds =
| 'radarr' | 'radarr'
| 'sonarr' | 'sonarr'
| 'rt' | 'rt'
| 'imdb'
| 'github' | 'github'
| 'plexguid' | 'plexguid'
| 'plextv'; | 'plextv';
@@ -51,6 +52,10 @@ class CacheManager {
stdTtl: 43200, stdTtl: 43200,
checkPeriod: 60 * 30, checkPeriod: 60 * 30,
}), }),
imdb: new Cache('imdb', 'IMDB Radarr Proxy', {
stdTtl: 43200,
checkPeriod: 60 * 30,
}),
github: new Cache('github', 'GitHub API', { github: new Cache('github', 'GitHub API', {
stdTtl: 21600, stdTtl: 21600,
checkPeriod: 60 * 30, checkPeriod: 60 * 30,

View File

@@ -404,7 +404,7 @@ class Settings {
options: { options: {
webhookUrl: '', webhookUrl: '',
jsonPayload: jsonPayload:
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', 'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
}, },
}, },
webpush: { webpush: {

View File

@@ -1,4 +1,6 @@
import RottenTomatoes from '@server/api/rottentomatoes'; import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
@@ -118,6 +120,9 @@ movieRoutes.get('/:id/similar', async (req, res, next) => {
} }
}); });
/**
* Endpoint backed by RottenTomatoes
*/
movieRoutes.get('/:id/ratings', async (req, res, next) => { movieRoutes.get('/:id/ratings', async (req, res, next) => {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const rtapi = new RottenTomatoes(); const rtapi = new RottenTomatoes();
@@ -153,4 +158,53 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => {
} }
}); });
/**
* Endpoint combining RottenTomatoes and IMDB
*/
movieRoutes.get('/:id/ratingscombined', async (req, res, next) => {
const tmdb = new TheMovieDb();
const rtapi = new RottenTomatoes();
const imdbApi = new IMDBRadarrProxy();
try {
const movie = await tmdb.getMovie({
movieId: Number(req.params.id),
});
const rtratings = await rtapi.getMovieRatings(
movie.title,
Number(movie.release_date.slice(0, 4))
);
let imdbRatings;
if (movie.imdb_id) {
imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id);
}
if (!rtratings && !imdbRatings) {
return next({
status: 404,
message: 'No ratings found.',
});
}
const ratings: RatingResponse = {
...(rtratings ? { rt: rtratings } : {}),
...(imdbRatings ? { imdb: imdbRatings } : {}),
};
return res.status(200).json(ratings);
} catch (e) {
logger.debug('Something went wrong retrieving movie ratings', {
label: 'API',
errorMessage: e.message,
movieId: req.params.id,
});
return next({
status: 500,
message: 'Unable to retrieve movie ratings.',
});
}
});
export default movieRoutes; export default movieRoutes;

View File

@@ -367,25 +367,27 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
Object.assign(settings.tautulli, req.body); Object.assign(settings.tautulli, req.body);
try { if (settings.tautulli.hostname) {
const tautulliClient = new TautulliAPI(settings.tautulli); try {
const tautulliClient = new TautulliAPI(settings.tautulli);
const result = await tautulliClient.getInfo(); const result = await tautulliClient.getInfo();
if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) { if (!semver.gte(semver.coerce(result?.tautulli_version) ?? '', '2.9.0')) {
throw new Error('Tautulli version not supported'); throw new Error('Tautulli version not supported');
}
settings.save();
} catch (e) {
logger.error('Something went wrong testing Tautulli connection', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to connect to Tautulli.',
});
} }
settings.save();
} catch (e) {
logger.error('Something went wrong testing Tautulli connection', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to connect to Tautulli.',
});
} }
return res.status(200).json(settings.tautulli); return res.status(200).json(settings.tautulli);

View File

@@ -1,4 +1,4 @@
import RottenTomatoes from '@server/api/rottentomatoes'; import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';

View File

@@ -72,9 +72,7 @@ const SidebarLinks: SidebarLinkProps[] = [
{ {
href: '/issues', href: '/issues',
messagesKey: 'issues', messagesKey: 'issues',
svgIcon: ( svgIcon: <ExclamationTriangleIcon className="mr-3 h-6 w-6" />,
<ExclamationTriangleIcon className="mr-3 h-6 w-6 text-gray-300 transition duration-150 ease-in-out group-hover:text-gray-100 group-focus:text-gray-300" />
),
activeRegExp: /^\/issues/, activeRegExp: /^\/issues/,
requiredPermission: [ requiredPermission: [
Permission.MANAGE_ISSUES, Permission.MANAGE_ISSUES,

View File

@@ -2,6 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg';
import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
import RTFresh from '@app/assets/rt_fresh.svg'; import RTFresh from '@app/assets/rt_fresh.svg';
import RTRotten from '@app/assets/rt_rotten.svg'; import RTRotten from '@app/assets/rt_rotten.svg';
import ImdbLogo from '@app/assets/services/imdb.svg';
import TmdbLogo from '@app/assets/tmdb_logo.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg';
import Button from '@app/components/Common/Button'; import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage'; import CachedImage from '@app/components/Common/CachedImage';
@@ -40,7 +41,7 @@ import {
ChevronDoubleDownIcon, ChevronDoubleDownIcon,
ChevronDoubleUpIcon, ChevronDoubleUpIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import type { RTRating } from '@server/api/rottentomatoes'; import { type RatingResponse } from '@server/api/ratings';
import { IssueStatus } from '@server/constants/issue'; import { IssueStatus } from '@server/constants/issue';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
@@ -91,6 +92,7 @@ const messages = defineMessages({
rtcriticsscore: 'Rotten Tomatoes Tomatometer', rtcriticsscore: 'Rotten Tomatoes Tomatometer',
rtaudiencescore: 'Rotten Tomatoes Audience Score', rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB User Score', tmdbuserscore: 'TMDB User Score',
imdbuserscore: 'IMDB User Score',
}); });
interface MovieDetailsProps { interface MovieDetailsProps {
@@ -126,8 +128,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
), ),
}); });
const { data: ratingData } = useSWR<RTRating>( const { data: ratingData } = useSWR<RatingResponse>(
`/api/v1/movie/${router.query.movieId}/ratings` `/api/v1/movie/${router.query.movieId}/ratingscombined`
); );
const sortedCrew = useMemo( const sortedCrew = useMemo(
@@ -541,44 +543,62 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
)} )}
<div className="media-facts"> <div className="media-facts">
{(!!data.voteCount || {(!!data.voteCount ||
(ratingData?.criticsRating && !!ratingData?.criticsScore) || (ratingData?.rt?.criticsRating &&
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && ( !!ratingData?.rt?.criticsScore) ||
(ratingData?.rt?.audienceRating &&
!!ratingData?.rt?.audienceScore) ||
ratingData?.imdb?.criticsScore) && (
<div className="media-ratings"> <div className="media-ratings">
{ratingData?.criticsRating && !!ratingData?.criticsScore && ( {ratingData?.rt?.criticsRating &&
<Tooltip !!ratingData?.rt?.criticsScore && (
content={intl.formatMessage(messages.rtcriticsscore)} <Tooltip
> content={intl.formatMessage(messages.rtcriticsscore)}
>
<a
href={ratingData.rt.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.rt.criticsRating === 'Rotten' ? (
<RTRotten className="w-6" />
) : (
<RTFresh className="w-6" />
)}
<span>{ratingData.rt.criticsScore}%</span>
</a>
</Tooltip>
)}
{ratingData?.rt?.audienceRating &&
!!ratingData?.rt?.audienceScore && (
<Tooltip
content={intl.formatMessage(messages.rtaudiencescore)}
>
<a
href={ratingData.rt.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.rt.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6" />
) : (
<RTAudFresh className="w-6" />
)}
<span>{ratingData.rt.audienceScore}%</span>
</a>
</Tooltip>
)}
{ratingData?.imdb?.criticsScore && (
<Tooltip content={intl.formatMessage(messages.imdbuserscore)}>
<a <a
href={ratingData.url} href={ratingData.imdb.url}
className="media-rating" className="media-rating"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
{ratingData.criticsRating === 'Rotten' ? ( <ImdbLogo className="mr-1 w-6" />
<RTRotten className="w-6" /> <span>{ratingData.imdb.criticsScore}</span>
) : (
<RTFresh className="w-6" />
)}
<span>{ratingData.criticsScore}%</span>
</a>
</Tooltip>
)}
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
<Tooltip
content={intl.formatMessage(messages.rtaudiencescore)}
>
<a
href={ratingData.url}
className="media-rating"
target="_blank"
rel="noreferrer"
>
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6" />
) : (
<RTAudFresh className="w-6" />
)}
<span>{ratingData.audienceScore}%</span>
</a> </a>
</Tooltip> </Tooltip>
)} )}
@@ -827,7 +847,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
tmdbId={data.id} tmdbId={data.id}
tvdbId={data.externalIds.tvdbId} tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId} imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url} rtUrl={ratingData?.rt?.url}
mediaUrl={ mediaUrl={
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
} }

View File

@@ -437,6 +437,7 @@ export const WatchProviderSelector = ({
{otherProviders.length > 0 && ( {otherProviders.length > 0 && (
<button <button
className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200" className="relative top-4 flex items-center justify-center space-x-2 text-sm text-gray-400 transition hover:text-gray-200"
type="button"
onClick={() => setShowMore(!showMore)} onClick={() => setShowMore(!showMore)}
> >
<div className="h-0.5 flex-1 bg-gray-600" /> <div className="h-0.5 flex-1 bg-gray-600" />

View File

@@ -39,6 +39,9 @@ const defaultPayload = {
requestedBy_email: '{{requestedBy_email}}', requestedBy_email: '{{requestedBy_email}}',
requestedBy_username: '{{requestedBy_username}}', requestedBy_username: '{{requestedBy_username}}',
requestedBy_avatar: '{{requestedBy_avatar}}', requestedBy_avatar: '{{requestedBy_avatar}}',
requestedBy_settings_discordId: '{{requestedBy_settings_discordId}}',
requestedBy_settings_telegramChatId:
'{{requestedBy_settings_telegramChatId}}',
}, },
'{{issue}}': { '{{issue}}': {
issue_id: '{{issue_id}}', issue_id: '{{issue_id}}',
@@ -47,12 +50,18 @@ const defaultPayload = {
reportedBy_email: '{{reportedBy_email}}', reportedBy_email: '{{reportedBy_email}}',
reportedBy_username: '{{reportedBy_username}}', reportedBy_username: '{{reportedBy_username}}',
reportedBy_avatar: '{{reportedBy_avatar}}', reportedBy_avatar: '{{reportedBy_avatar}}',
reportedBy_settings_discordId: '{{reportedBy_settings_discordId}}',
reportedBy_settings_telegramChatId:
'{{reportedBy_settings_telegramChatId}}',
}, },
'{{comment}}': { '{{comment}}': {
comment_message: '{{comment_message}}', comment_message: '{{comment_message}}',
commentedBy_email: '{{commentedBy_email}}', commentedBy_email: '{{commentedBy_email}}',
commentedBy_username: '{{commentedBy_username}}', commentedBy_username: '{{commentedBy_username}}',
commentedBy_avatar: '{{commentedBy_avatar}}', commentedBy_avatar: '{{commentedBy_avatar}}',
commentedBy_settings_discordId: '{{commentedBy_settings_discordId}}',
commentedBy_settings_telegramChatId:
'{{commentedBy_settings_telegramChatId}}',
}, },
'{{extra}}': [], '{{extra}}': [],
}; };

View File

@@ -156,6 +156,7 @@ const Slider = ({
}`} }`}
onClick={() => slide(Direction.LEFT)} onClick={() => slide(Direction.LEFT)}
disabled={scrollPos.isStart} disabled={scrollPos.isStart}
type="button"
> >
<ChevronLeftIcon className="h-6 w-6" /> <ChevronLeftIcon className="h-6 w-6" />
</button> </button>
@@ -165,6 +166,7 @@ const Slider = ({
}`} }`}
onClick={() => slide(Direction.RIGHT)} onClick={() => slide(Direction.RIGHT)}
disabled={scrollPos.isEnd} disabled={scrollPos.isEnd}
type="button"
> >
<ChevronRightIcon className="h-6 w-6" /> <ChevronRightIcon className="h-6 w-6" />
</button> </button>

View File

@@ -176,11 +176,11 @@ const StatusBadge = ({
</span> </span>
{inProgress && ( {inProgress && (
<> <>
{mediaType === 'tv' && ( {mediaType === 'tv' && downloadItem[0].episode && (
<span className="ml-1"> <span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, { {intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber, seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber, episodeNumber: downloadItem[0].episode.episodeNumber,
})} })}
</span> </span>
)} )}
@@ -229,11 +229,11 @@ const StatusBadge = ({
</span> </span>
{inProgress && ( {inProgress && (
<> <>
{mediaType === 'tv' && ( {mediaType === 'tv' && downloadItem[0].episode && (
<span className="ml-1"> <span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, { {intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber, seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber, episodeNumber: downloadItem[0].episode.episodeNumber,
})} })}
</span> </span>
)} )}
@@ -282,11 +282,11 @@ const StatusBadge = ({
</span> </span>
{inProgress && ( {inProgress && (
<> <>
{mediaType === 'tv' && ( {mediaType === 'tv' && downloadItem[0].episode && (
<span className="ml-1"> <span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, { {intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode?.seasonNumber, seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode?.episodeNumber, episodeNumber: downloadItem[0].episode.episodeNumber,
})} })}
</span> </span>
)} )}

View File

@@ -40,7 +40,7 @@ import {
PlayIcon, PlayIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { ChevronDownIcon } from '@heroicons/react/24/solid'; import { ChevronDownIcon } from '@heroicons/react/24/solid';
import type { RTRating } from '@server/api/rottentomatoes'; import type { RTRating } from '@server/api/rating/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue'; import { IssueStatus } from '@server/constants/issue';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media';

View File

@@ -200,8 +200,6 @@
"components.LanguageSelector.originalLanguageDefault": "All Languages", "components.LanguageSelector.originalLanguageDefault": "All Languages",
"components.Layout.LanguagePicker.displaylanguage": "Display Language", "components.Layout.LanguagePicker.displaylanguage": "Display Language",
"components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV", "components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV",
"components.Layout.Sidebar.browsemovies": "Movies",
"components.Layout.Sidebar.browsetv": "Series",
"components.Layout.Sidebar.dashboard": "Discover", "components.Layout.Sidebar.dashboard": "Discover",
"components.Layout.Sidebar.browsemovies": "Movies", "components.Layout.Sidebar.browsemovies": "Movies",
"components.Layout.Sidebar.browsetv": "Series", "components.Layout.Sidebar.browsetv": "Series",
@@ -282,6 +280,7 @@
"components.MovieDetails.cast": "Cast", "components.MovieDetails.cast": "Cast",
"components.MovieDetails.digitalrelease": "Digital Release", "components.MovieDetails.digitalrelease": "Digital Release",
"components.MovieDetails.downloadstatus": "Download Status", "components.MovieDetails.downloadstatus": "Download Status",
"components.MovieDetails.imdbuserscore": "IMDB User Score",
"components.MovieDetails.managemovie": "Manage Movie", "components.MovieDetails.managemovie": "Manage Movie",
"components.MovieDetails.mark4kavailable": "Mark as Available in 4K", "components.MovieDetails.mark4kavailable": "Mark as Available in 4K",
"components.MovieDetails.markavailable": "Mark as Available", "components.MovieDetails.markavailable": "Mark as Available",

File diff suppressed because it is too large Load Diff