mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: throw 404 when movie/tv show doesnt exist
also adds site webmanifest for mobile icons and title changes for tv/movie pages
This commit is contained in:
@@ -33,6 +33,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
example: '2020-09-02T05:02:23.000Z'
|
example: '2020-09-02T05:02:23.000Z'
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
requestCount:
|
||||||
|
type: number
|
||||||
|
example: 5
|
||||||
|
readOnly: true
|
||||||
requests:
|
requests:
|
||||||
type: array
|
type: array
|
||||||
readOnly: true
|
readOnly: true
|
||||||
@@ -1656,9 +1660,14 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: array
|
type: object
|
||||||
items:
|
properties:
|
||||||
$ref: '#/components/schemas/MediaRequest'
|
pageInfo:
|
||||||
|
$ref: '#/components/schemas/PageInfo'
|
||||||
|
results:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/MediaRequest'
|
||||||
post:
|
post:
|
||||||
summary: Create a new request
|
summary: Create a new request
|
||||||
description: |
|
description: |
|
||||||
|
1
public/site.webmanifest
Normal file
1
public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
@@ -5,6 +5,7 @@ import {
|
|||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
|
RelationCount,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Permission, hasPermission } from '../lib/permissions';
|
import { Permission, hasPermission } from '../lib/permissions';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
@@ -38,6 +39,9 @@ export class User {
|
|||||||
@Column()
|
@Column()
|
||||||
public avatar: string;
|
public avatar: string;
|
||||||
|
|
||||||
|
@RelationCount((user: User) => user.requests)
|
||||||
|
public requestCount: number;
|
||||||
|
|
||||||
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
|
@OneToMany(() => MediaRequest, (request) => request.requestedBy)
|
||||||
public requests: MediaRequest[];
|
public requests: MediaRequest[];
|
||||||
|
|
||||||
|
10
server/interfaces/api/common.ts
Normal file
10
server/interfaces/api/common.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
interface PageInfo {
|
||||||
|
pages: number;
|
||||||
|
page: number;
|
||||||
|
results: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse {
|
||||||
|
pageInfo: PageInfo;
|
||||||
|
}
|
@@ -1,11 +1,6 @@
|
|||||||
import type Media from '../../entity/Media';
|
import type Media from '../../entity/Media';
|
||||||
|
import { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface MediaResultsResponse {
|
export interface MediaResultsResponse extends PaginatedResponse {
|
||||||
pageInfo: {
|
|
||||||
pages: number;
|
|
||||||
page: number;
|
|
||||||
results: number;
|
|
||||||
pageSize: number;
|
|
||||||
};
|
|
||||||
results: Media[];
|
results: Media[];
|
||||||
}
|
}
|
||||||
|
6
server/interfaces/api/requestInterfaces.ts
Normal file
6
server/interfaces/api/requestInterfaces.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { PaginatedResponse } from './common';
|
||||||
|
import type { MediaRequest } from '../../entity/MediaRequest';
|
||||||
|
|
||||||
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
|
results: MediaRequest[];
|
||||||
|
}
|
@@ -8,17 +8,21 @@ import RottenTomatoes from '../api/rottentomatoes';
|
|||||||
|
|
||||||
const movieRoutes = Router();
|
const movieRoutes = Router();
|
||||||
|
|
||||||
movieRoutes.get('/:id', async (req, res) => {
|
movieRoutes.get('/:id', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
const movie = await tmdb.getMovie({
|
try {
|
||||||
movieId: Number(req.params.id),
|
const movie = await tmdb.getMovie({
|
||||||
language: req.query.language as string,
|
movieId: Number(req.params.id),
|
||||||
});
|
language: req.query.language as string,
|
||||||
|
});
|
||||||
|
|
||||||
const media = await Media.getMedia(movie.id);
|
const media = await Media.getMedia(movie.id);
|
||||||
|
|
||||||
return res.status(200).json(mapMovieDetails(movie, media));
|
return res.status(200).json(mapMovieDetails(movie, media));
|
||||||
|
} catch (e) {
|
||||||
|
return next({ status: 404, message: 'Movie does not exist' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
movieRoutes.get('/:id/recommendations', async (req, res) => {
|
movieRoutes.get('/:id/recommendations', async (req, res) => {
|
||||||
@@ -74,27 +78,27 @@ movieRoutes.get('/:id/similar', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
movieRoutes.get('/:id/ratings', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
try {
|
||||||
const rtapi = new RottenTomatoes();
|
const tmdb = new TheMovieDb();
|
||||||
|
const rtapi = new RottenTomatoes();
|
||||||
|
|
||||||
const movie = await tmdb.getMovie({
|
const movie = await tmdb.getMovie({
|
||||||
movieId: Number(req.params.id),
|
movieId: Number(req.params.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!movie) {
|
const rtratings = await rtapi.getMovieRatings(
|
||||||
|
movie.title,
|
||||||
|
Number(movie.release_date.slice(0, 4))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rtratings) {
|
||||||
|
return next({ status: 404, message: 'Unable to retrieve ratings' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json(rtratings);
|
||||||
|
} catch (e) {
|
||||||
return next({ status: 404, message: 'Movie does not exist' });
|
return next({ status: 404, message: 'Movie does not exist' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rtratings = await rtapi.getMovieRatings(
|
|
||||||
movie.title,
|
|
||||||
Number(movie.release_date.slice(0, 4))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!rtratings) {
|
|
||||||
return next({ status: 404, message: 'Unable to retrieve ratings' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json(rtratings);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default movieRoutes;
|
export default movieRoutes;
|
||||||
|
@@ -8,12 +8,16 @@ import Media from '../entity/Media';
|
|||||||
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
|
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
|
||||||
import SeasonRequest from '../entity/SeasonRequest';
|
import SeasonRequest from '../entity/SeasonRequest';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
|
import { RequestResultsResponse } from '../interfaces/api/requestInterfaces';
|
||||||
|
|
||||||
const requestRoutes = Router();
|
const requestRoutes = Router();
|
||||||
|
|
||||||
requestRoutes.get('/', async (req, res, next) => {
|
requestRoutes.get('/', async (req, res, next) => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
try {
|
try {
|
||||||
|
const pageSize = Number(req.query.take) ?? 20;
|
||||||
|
const skip = Number(req.query.skip) ?? 0;
|
||||||
|
|
||||||
let statusFilter:
|
let statusFilter:
|
||||||
| MediaRequestStatus
|
| MediaRequestStatus
|
||||||
| FindOperator<MediaRequestStatus>
|
| FindOperator<MediaRequestStatus>
|
||||||
@@ -51,23 +55,33 @@ requestRoutes.get('/', async (req, res, next) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = req.user?.hasPermission(Permission.MANAGE_REQUESTS)
|
const [requests, requestCount] = req.user?.hasPermission(
|
||||||
? await requestRepository.find({
|
Permission.MANAGE_REQUESTS
|
||||||
|
)
|
||||||
|
? await requestRepository.findAndCount({
|
||||||
order: sortFilter,
|
order: sortFilter,
|
||||||
relations: ['media', 'modifiedBy'],
|
relations: ['media', 'modifiedBy'],
|
||||||
where: { status: statusFilter },
|
where: { status: statusFilter },
|
||||||
take: Number(req.query.take) ?? 20,
|
take: Number(req.query.take) ?? 20,
|
||||||
skip: Number(req.query.skip) ?? 0,
|
skip,
|
||||||
})
|
})
|
||||||
: await requestRepository.find({
|
: await requestRepository.findAndCount({
|
||||||
where: { requestedBy: { id: req.user?.id }, status: statusFilter },
|
where: { requestedBy: { id: req.user?.id }, status: statusFilter },
|
||||||
relations: ['media', 'modifiedBy'],
|
relations: ['media', 'modifiedBy'],
|
||||||
order: sortFilter,
|
order: sortFilter,
|
||||||
take: Number(req.query.limit) ?? 20,
|
take: Number(req.query.limit) ?? 20,
|
||||||
skip: Number(req.query.skip) ?? 0,
|
skip,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).json(requests);
|
return res.status(200).json({
|
||||||
|
pageInfo: {
|
||||||
|
pages: Math.ceil(requestCount / pageSize),
|
||||||
|
pageSize,
|
||||||
|
results: requestCount,
|
||||||
|
page: Math.ceil(skip / pageSize) + 1,
|
||||||
|
},
|
||||||
|
results: requests,
|
||||||
|
} as RequestResultsResponse);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
}
|
}
|
||||||
|
@@ -8,17 +8,20 @@ import RottenTomatoes from '../api/rottentomatoes';
|
|||||||
|
|
||||||
const tvRoutes = Router();
|
const tvRoutes = Router();
|
||||||
|
|
||||||
tvRoutes.get('/:id', async (req, res) => {
|
tvRoutes.get('/:id', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
try {
|
||||||
|
const tv = await tmdb.getTvShow({
|
||||||
|
tvId: Number(req.params.id),
|
||||||
|
language: req.query.language as string,
|
||||||
|
});
|
||||||
|
|
||||||
const tv = await tmdb.getTvShow({
|
const media = await Media.getMedia(tv.id);
|
||||||
tvId: Number(req.params.id),
|
|
||||||
language: req.query.language as string,
|
|
||||||
});
|
|
||||||
|
|
||||||
const media = await Media.getMedia(tv.id);
|
return res.status(200).json(mapTvDetails(tv, media));
|
||||||
|
} catch (e) {
|
||||||
return res.status(200).json(mapTvDetails(tv, media));
|
return next({ status: 404, message: 'TV Show does not exist' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => {
|
tvRoutes.get('/:id/season/:seasonNumber', async (req, res) => {
|
||||||
|
@@ -8,7 +8,7 @@ const router = Router();
|
|||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
const users = await userRepository.find({ relations: ['requests'] });
|
const users = await userRepository.find();
|
||||||
|
|
||||||
return res.status(200).json(User.filterMany(users));
|
return res.status(200).json(User.filterMany(users));
|
||||||
});
|
});
|
||||||
|
@@ -15,6 +15,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||||||
import { LanguageContext } from '../../context/LanguageContext';
|
import { LanguageContext } from '../../context/LanguageContext';
|
||||||
import type Media from '../../../server/entity/Media';
|
import type Media from '../../../server/entity/Media';
|
||||||
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
|
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
|
||||||
|
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
|
||||||
import RequestCard from '../RequestCard';
|
import RequestCard from '../RequestCard';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@@ -70,9 +71,9 @@ const Discover: React.FC = () => {
|
|||||||
'/api/v1/media?filter=available&take=20&sort=modified'
|
'/api/v1/media?filter=available&take=20&sort=modified'
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: requests, error: requestError } = useSWR<MediaRequest[]>(
|
const { data: requests, error: requestError } = useSWR<
|
||||||
'/api/v1/request?filter=unavailable&take=20&sort=modified&skip=0'
|
RequestResultsResponse
|
||||||
);
|
>('/api/v1/request?filter=unavailable&take=20&sort=modified&skip=0');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -125,8 +126,8 @@ const Discover: React.FC = () => {
|
|||||||
<Slider
|
<Slider
|
||||||
sliderKey="requests"
|
sliderKey="requests"
|
||||||
isLoading={!requests && !requestError}
|
isLoading={!requests && !requestError}
|
||||||
isEmpty={!!requests && !requestError && requests.length === 0}
|
isEmpty={!!requests && !requestError && requests.results.length === 0}
|
||||||
items={requests?.map((request) => (
|
items={(requests?.results ?? []).map((request) => (
|
||||||
<RequestCard
|
<RequestCard
|
||||||
key={`request-slider-item-${request.id}`}
|
key={`request-slider-item-${request.id}`}
|
||||||
request={request}
|
request={request}
|
||||||
|
@@ -35,6 +35,7 @@ import RTAudFresh from '../../assets/rt_aud_fresh.svg';
|
|||||||
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
||||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||||
import Error from '../../pages/_error';
|
import Error from '../../pages/_error';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
releasedate: 'Release Date',
|
releasedate: 'Release Date',
|
||||||
@@ -128,6 +129,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Head>
|
||||||
|
<title>{data.title} - Overseerr</title>
|
||||||
|
</Head>
|
||||||
<RequestModal
|
<RequestModal
|
||||||
tmdbId={data.id}
|
tmdbId={data.id}
|
||||||
show={showRequestModal}
|
show={showRequestModal}
|
||||||
|
@@ -26,6 +26,7 @@ import RTRotten from '../../assets/rt_rotten.svg';
|
|||||||
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
|
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
|
||||||
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
|
||||||
import type { RTRating } from '../../../server/api/rottentomatoes';
|
import type { RTRating } from '../../../server/api/rottentomatoes';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
userrating: 'User Rating',
|
userrating: 'User Rating',
|
||||||
@@ -131,6 +132,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
|||||||
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Head>
|
||||||
|
<title>{data.name} - Overseerr</title>
|
||||||
|
</Head>
|
||||||
<RequestModal
|
<RequestModal
|
||||||
tmdbId={data.id}
|
tmdbId={data.id}
|
||||||
show={showRequestModal}
|
show={showRequestModal}
|
||||||
|
@@ -73,7 +73,7 @@ const UserList: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm leading-5 text-white">
|
<div className="text-sm leading-5 text-white">
|
||||||
{user.requests.length}
|
{user.requestCount}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
@@ -22,6 +22,24 @@ class MyDocument extends Document {
|
|||||||
<Html>
|
<Html>
|
||||||
<Head>
|
<Head>
|
||||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-icon"
|
||||||
|
sizes="180x180"
|
||||||
|
href="/apple-touch-icon.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="32x32"
|
||||||
|
href="/favicon-32x32.png"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
sizes="16x16"
|
||||||
|
href="/favicon-16x16.png"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/site.webmanifest"></link>
|
||||||
</Head>
|
</Head>
|
||||||
<body>
|
<body>
|
||||||
<Main />
|
<Main />
|
||||||
|
Reference in New Issue
Block a user