Merge branch 'develop'

pull/570/head
sct 4 years ago
commit 46326f9d16

@ -97,6 +97,33 @@
"contributions": [
"infra"
]
},
{
"login": "Shutruk",
"name": "Shutruk",
"avatar_url": "https://avatars2.githubusercontent.com/u/9198633?v=4",
"profile": "https://github.com/Shutruk",
"contributions": [
"translation"
]
},
{
"login": "krystiancharubin",
"name": "Krystian Charubin",
"avatar_url": "https://avatars2.githubusercontent.com/u/17775600?v=4",
"profile": "https://github.com/krystiancharubin",
"contributions": [
"design"
]
},
{
"login": "kieron",
"name": "Kieron Boswell",
"avatar_url": "https://avatars2.githubusercontent.com/u/8655212?v=4",
"profile": "https://github.com/kieron",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

@ -9,8 +9,10 @@ assignees: ''
**Describe the bug**
A clear and concise description of what the bug is.
**Are you on latest or develop branch?**
Please fill in which docker image you are currently using.
**What version of Overseerr are you running?**
Please fill in the version you are currently running.
You can find it under: Settings -> About -> Version
**To Reproduce**
Steps to reproduce the behavior:

@ -0,0 +1,19 @@
name: 'Invalid Template'
on:
issues:
types: [labeled, unlabeled, reopened]
jobs:
support:
runs-on: ubuntu-latest
steps:
- uses: dessant/support-requests@v2
with:
github-token: ${{ github.token }}
support-label: 'invalid:template-incomplete'
issue-comment: >
:wave: @{issue-author}, please edit your issue and follow the template provided.
close-issue: false
lock-issue: true
issue-lock-reason: 'resolved'

@ -16,7 +16,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- 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-10-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-13-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -86,7 +86,13 @@ If you would like to chat with community members you can join the [Overseerr Dis
Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md) applies to all Overseerr community channels.
## Contributors
## Contributing
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
@ -105,13 +111,13 @@ Our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_COND
<td align="center"><a href="https://github.com/jvennik"><img src="https://avatars3.githubusercontent.com/u/6672637?v=4" width="100px;" alt=""/><br /><sub><b>jvennik</b></sub></a><br /><a href="#translation-jvennik" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/darknessgp"><img src="https://avatars0.githubusercontent.com/u/1521243?v=4" width="100px;" alt=""/><br /><sub><b>darknessgp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=darknessgp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/saltydk"><img src="https://avatars1.githubusercontent.com/u/6587950?v=4" width="100px;" alt=""/><br /><sub><b>salty</b></sub></a><br /><a href="#infra-saltydk" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
## Contributing
You can help build Overseerr too! Check out our [Contribution Guide](https://github.com/sct/overseerr/blob/develop/CONTRIBUTING.md) to get started.
<!-- ALL-CONTRIBUTORS-LIST:END -->

@ -643,7 +643,6 @@ components:
readOnly: true
requestedBy:
$ref: '#/components/schemas/User'
readOnly: true
modifiedBy:
anyOf:
- $ref: '#/components/schemas/User'
@ -967,6 +966,10 @@ components:
type: apiKey
name: connect.sid
in: cookie
apiKey:
type: apiKey
in: header
name: X-Api-Key
paths:
/settings/main:
@ -2486,3 +2489,4 @@ paths:
security:
- cookieAuth: []
- apiKey: []

@ -1 +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"}
{"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":"#1e2937","display":"standalone"}

@ -4,10 +4,12 @@ import { getSettings } from '../lib/settings';
export interface PlexLibraryItem {
ratingKey: string;
parentRatingKey?: string;
grandparentRatingKey?: string;
title: string;
guid: string;
parentGuid?: string;
type: 'movie' | 'show' | 'season';
grandparentGuid?: string;
type: 'movie' | 'show' | 'season' | 'episode';
}
interface PlexLibraryResponse {
@ -20,6 +22,7 @@ export interface PlexLibrary {
type: 'show' | 'movie';
key: string;
title: string;
agent: string;
}
interface PlexLibrariesResponse {
@ -120,9 +123,9 @@ class PlexAPI {
return response.MediaContainer.Metadata[0];
}
public async getRecentlyAdded(): Promise<PlexLibraryItem[]> {
public async getRecentlyAdded(id: string): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>(
'/library/recentlyAdded'
`/library/sections/${id}/recentlyAdded`
);
return response.MediaContainer.Metadata;

@ -17,9 +17,11 @@ import { startJobs } from './job/schedule';
import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
import { getAppVersion } from './utils/appVersion';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
logger.info(`Starting Overseerr version ${getAppVersion()}`);
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
@ -101,7 +103,7 @@ app
const port = Number(process.env.PORT) || 3000;
server.listen(port, () => {
logger.info(`Server ready on port ${port}`, {
label: 'SERVER',
label: 'Server',
});
});
})

@ -8,7 +8,8 @@ import logger from '../../logger';
import { getSettings, Library } from '../../lib/settings';
import Season from '../../entity/Season';
const BUNDLE_SIZE = 10;
const BUNDLE_SIZE = 20;
const UPDATE_RATE = 4 * 1000;
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
@ -136,7 +137,9 @@ class JobPlexSync {
try {
const metadata = await this.plexClient.getMetadata(
plexitem.parentRatingKey ?? plexitem.ratingKey,
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey,
{ includeChildren: true }
);
if (metadata.guid.match(tvdbRegex)) {
@ -239,9 +242,15 @@ class JobPlexSync {
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${
plexitem.parentRatingKey ?? plexitem.ratingKey
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey
}`,
'error'
'error',
{
errorMessage: e.message,
plexitem,
}
);
}
}
@ -251,7 +260,11 @@ class JobPlexSync {
slicedItems.map(async (plexitem) => {
if (plexitem.type === 'movie') {
await this.processMovie(plexitem);
} else if (plexitem.type === 'show') {
} else if (
plexitem.type === 'show' ||
plexitem.type === 'episode' ||
plexitem.type === 'season'
) {
await this.processShow(plexitem);
}
})
@ -277,16 +290,17 @@ class JobPlexSync {
end: end + BUNDLE_SIZE,
});
resolve();
}, 5000)
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' = 'debug'
level: 'info' | 'error' | 'debug' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Plex Sync' });
logger[level](message, { label: 'Plex Sync', ...optional });
}
public async run(): Promise<void> {
@ -300,20 +314,22 @@ class JobPlexSync {
});
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
if (this.isRecentOnly) {
this.currentLibrary = {
id: '0',
name: 'Recently Added',
enabled: true,
};
this.log(`Beginning to process recently added`, 'info');
this.items = await this.plexClient.getRecentlyAdded();
await this.loop();
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(
`Beginning to process recently added for library: ${library.name}`,
'info'
);
this.items = await this.plexClient.getRecentlyAdded(library.id);
await this.loop();
}
} else {
this.libraries = settings.plex.libraries.filter(
(library) => library.enabled
);
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');

@ -13,7 +13,7 @@ export const startJobs = (): void => {
// Run recently added plex sync every 5 minutes
scheduledJobs.push({
name: 'Plex Recently Added Sync',
job: schedule.scheduleJob('0 */10 * * * *', () => {
job: schedule.scheduleJob('0 */5 * * * *', () => {
logger.info('Starting scheduled job: Plex Recently Added Sync', {
label: 'Jobs',
});
@ -23,7 +23,7 @@ export const startJobs = (): void => {
// Run full plex sync every 6 hours
scheduledJobs.push({
name: 'Plex Full Library Sync',
job: schedule.scheduleJob('0 0 */6 * * *', () => {
job: schedule.scheduleJob('0 0 3 * * *', () => {
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexFullSync.run();
}),

@ -135,7 +135,7 @@ class EmailAgent implements NotificationAgent {
to: payload.notifyUser.email,
},
locals: {
body: 'Your requsested media is now available!',
body: 'Your requested media is now available!',
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),

@ -1,9 +1,25 @@
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import { Permission } from '../lib/permissions';
import { getSettings } from '../lib/settings';
export const checkUser: Middleware = async (req, _res, next) => {
if (req.session?.userId) {
const settings = getSettings();
if (req.header('X-API-Key') === settings.main.apiKey) {
const userRepository = getRepository(User);
let userId = 1; // Work on original administrator account
// If a User ID is provided, we will act on that users behalf
if (req.header('X-API-User')) {
userId = Number(req.header('X-API-User'));
}
const user = await userRepository.findOne({ where: { id: userId } });
if (user) {
req.user = user;
}
} else if (req.session?.userId) {
const userRepository = getRepository(User);
const user = await userRepository.findOne({

@ -19,6 +19,7 @@ import { isAuthenticated } from '../middleware/auth';
import { merge } from 'lodash';
import Media from '../entity/Media';
import { MediaRequest } from '../entity/MediaRequest';
import { getAppVersion } from '../utils/appVersion';
const settingsRoutes = Router();
@ -93,17 +94,22 @@ settingsRoutes.get('/plex/library', async (req, res) => {
const libraries = await plexapi.getLibraries();
const newLibraries: Library[] = libraries.map((library) => {
const existing = settings.plex.libraries.find(
(l) => l.id === library.key
);
return {
id: library.key,
name: library.title,
enabled: existing?.enabled ?? false,
};
});
const newLibraries: Library[] = libraries
// Remove libraries that are not movie or show
.filter((library) => library.type === 'movie' || library.type === 'show')
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
.filter((library) => library.agent !== 'com.plexapp.agents.none')
.map((library) => {
const existing = settings.plex.libraries.find(
(l) => l.id === library.key && l.name === library.title
);
return {
id: library.key,
name: library.title,
enabled: existing?.enabled ?? false,
};
});
settings.plex.libraries = newLibraries;
}
@ -440,16 +446,8 @@ settingsRoutes.get('/about', async (req, res) => {
const totalMediaItems = await mediaRepository.count();
const totalRequests = await mediaRequestRepository.count();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { version } = require('../../package.json');
let finalVersion = version;
if (version === '0.1.0') {
finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`;
}
return res.status(200).json({
version: finalVersion,
version: getAppVersion(),
totalMediaItems,
totalRequests,
});

@ -0,0 +1,12 @@
export const getAppVersion = (): string => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { version } = require('../../package.json');
let finalVersion = version;
if (version === '0.1.0') {
finalVersion = `develop-${process.env.COMMIT_TAG ?? 'local'}`;
}
return finalVersion;
};

@ -45,6 +45,10 @@ const availableLanguages: AvailableLanguageObject = {
code: 'nl',
display: 'Nederlands',
},
es: {
code: 'es',
display: 'Spanish',
},
};
const LanguagePicker: React.FC = () => {

@ -1,9 +1,10 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useRef } from 'react';
import Transition from '../../Transition';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { defineMessages, FormattedMessage } from 'react-intl';
import { useUser, Permission } from '../../../hooks/useUser';
import useClickOutside from '../../../hooks/useClickOutside';
const messages = defineMessages({
dashboard: 'Discover',
@ -116,8 +117,10 @@ const SidebarLinks: SidebarLinkProps[] = [
];
const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
const navRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { hasPermission } = useUser();
useClickOutside(navRef, () => setClosed());
return (
<>
<div className="md:hidden">
@ -166,7 +169,10 @@ const Sidebar: React.FC<SidebarProps> = ({ open, setClosed }) => {
</svg>
</button>
</div>
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
<div
ref={navRef}
className="flex-1 h-0 pt-5 pb-4 overflow-y-auto"
>
<div className="flex-shrink-0 flex items-center px-4">
<span className="text-xl text-gray-50">
<Link href="/">

@ -193,14 +193,14 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
</SlideOver>
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
<div className="mr-4 flex-shrink-0">
<div className="md:mr-4 flex-shrink-0">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
/>
</div>
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
@ -352,7 +352,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="ml-2"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<svg

@ -46,7 +46,7 @@ const SettingsJobs: React.FC = () => {
</div>
</Table.TD>
<Table.TD alignText="right">
<Button buttonType="primary">
<Button buttonType="primary" disabled>
{intl.formatMessage(messages.runnow)}
</Button>
</Table.TD>

@ -0,0 +1,17 @@
import React from 'react';
// We will localize this file when the complete version is released.
const SettingsLogs: React.FC = () => {
return (
<>
<div className="leading-loose text-gray-300 text-sm">
Logs page is still being built. For now, you can access your logs
directly in <code>stdout</code> (container logs) or looking in{' '}
<code>/app/config/logs/overseerr.logs</code>
</div>
</>
);
};
export default SettingsLogs;

@ -8,6 +8,7 @@ import LoginWithPlex from './LoginWithPlex';
import SetupSteps from './SetupSteps';
import axios from 'axios';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Badge from '../Common/Badge';
const messages = defineMessages({
finish: 'Finish Setup',
@ -16,6 +17,9 @@ const messages = defineMessages({
loginwithplex: 'Login with Plex',
configureplex: 'Configure Plex',
configureservices: 'Configure Services',
tip: 'Tip',
syncingbackground:
'Syncing will run in the background. You can continue the setup process in the meantime.',
});
const Setup: React.FC = () => {
@ -85,6 +89,12 @@ const Setup: React.FC = () => {
{currentStep === 2 && (
<div>
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
<div className="mt-4 text-gray-500 text-sm">
<span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge>
</span>
{intl.formatMessage(messages.syncingbackground)}
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">

@ -10,10 +10,11 @@ import Link from 'next/link';
import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import { defineMessages, useIntl } from 'react-intl';
import { useIsTouch } from '../../hooks/useIsTouch';
const messages = defineMessages({
movie: 'MOVIE',
tvshow: 'SERIES',
movie: 'Movie',
tvshow: 'Series',
});
interface TitleCardProps {
@ -38,6 +39,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
mediaType,
canExpand = false,
}) => {
const isTouch = useIsTouch();
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const [currentStatus, setCurrentStatus] = useState(status);
@ -74,9 +76,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
<div
className="titleCard outline-none cursor-default"
style={{
backgroundImage: `url(//image.tmdb.org/t/p/w600_and_h900_bestv2${image})`,
backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`,
}}
onMouseEnter={() => {
if (!isTouch) {
setShowDetail(true);
}
}}
onMouseEnter={() => setShowDetail(true)}
onMouseLeave={() => setShowDetail(false)}
onClick={() => setShowDetail(true)}
onKeyDown={(e) => {
@ -146,158 +152,162 @@ const TitleCard: React.FC<TitleCardProps> = ({
<Transition
show={!image || showDetail || showRequestModal}
enter="transition ease-in-out duration-300 transform opacity-0"
enter="transition transform opacity-0"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition ease-in-out duration-300 transform opacity-100"
leave="transition transform opacity-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden"
style={{
background:
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
}}
>
<div className="absolute bottom-0 w-full left-0 right-0">
<div className="px-2 text-white">
<div className="text-sm">{year}</div>
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
<a
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden cursor-pointer"
style={{
background:
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
}}
>
<div className="absolute bottom-0 w-full left-0 right-0">
<div className="px-2 text-white">
<div className="text-sm">{year}</div>
<h1 className="text-xl leading-tight whitespace-normal">
{title}
</h1>
<div
className="text-xs whitespace-normal"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
>
{summary}
<h1 className="text-xl leading-tight whitespace-normal">
{title}
</h1>
<div
className="text-xs whitespace-normal"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
>
{summary}
</div>
</div>
</div>
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
<Link
href={
mediaType === 'movie' ? '/movie/[movieId]' : '/tv/[tvId]'
}
as={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
>
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</a>
</Link>
{(!currentStatus ||
currentStatus === MediaStatus.UNKNOWN) && (
<button
onClick={() => setShowRequestModal(true)}
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
<Link
href={
mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`
}
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</a>
</Link>
{(!currentStatus ||
currentStatus === MediaStatus.UNKNOWN) && (
<button
onClick={(e) => {
e.preventDefault();
setShowRequestModal(true);
}}
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PENDING && (
<button
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PENDING && (
<button
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
disabled
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PROCESSING && (
<button
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PROCESSING && (
<button
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
disabled
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<button
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<button
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
disabled
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
)}
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
)}
</div>
</div>
</div>
</div>
</a>
</Link>
</Transition>
</div>
</div>

@ -196,14 +196,14 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)}
</SlideOver>
<div className="flex flex-col items-center md:flex-row md:items-end pt-4">
<div className="mr-4 flex-shrink-0">
<div className="md:mr-4 flex-shrink-0">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="rounded md:rounded-lg shadow md:shadow-2xl w-32 md:w-52"
/>
</div>
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="text-white flex flex-col md:mr-4 mt-4 md:mt-0 text-center md:text-left">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
@ -344,7 +344,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="ml-2"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<svg

@ -0,0 +1,20 @@
import React from 'react';
import useInteraction from '../hooks/useInteraction';
interface InteractionContextProps {
isTouch: boolean;
}
export const InteractionContext = React.createContext<InteractionContextProps>({
isTouch: false,
});
export const InteractionProvider: React.FC = ({ children }) => {
const isTouch = useInteraction();
return (
<InteractionContext.Provider value={{ isTouch }}>
{children}
</InteractionContext.Provider>
);
};

@ -7,7 +7,8 @@ export type AvailableLocales =
| 'nb-NO'
| 'de'
| 'ru'
| 'nl';
| 'nl'
| 'es';
interface LanguageContextProps {
locale: AvailableLocales;

@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
export const INTERACTION_TYPE = {
MOUSE: 'mouse',
PEN: 'pen',
TOUCH: 'touch',
};
const UPDATE_INTERVAL = 1000; // Throttle updates to the type to prevent flip flopping
const useInteraction = (): boolean => {
const [isTouch, setIsTouch] = useState(false);
useEffect(() => {
const hasTapEvent = 'ontouchstart' in window;
setIsTouch(hasTapEvent);
let localTouch = hasTapEvent;
let lastTouchUpdate = Date.now();
const shouldUpdate = (): boolean =>
lastTouchUpdate + UPDATE_INTERVAL < Date.now();
const onMouseMove = (): void => {
if (localTouch && shouldUpdate()) {
setTimeout(() => {
if (shouldUpdate()) {
setIsTouch(false);
localTouch = false;
}
}, UPDATE_INTERVAL);
}
};
const onTouchStart = (): void => {
lastTouchUpdate = Date.now();
if (!localTouch) {
setIsTouch(true);
localTouch = true;
}
};
const onPointerMove = (e: PointerEvent): void => {
const { pointerType } = e;
switch (pointerType) {
case INTERACTION_TYPE.TOUCH:
case INTERACTION_TYPE.PEN:
return onTouchStart();
default:
return onMouseMove();
}
};
if (hasTapEvent) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchstart', onTouchStart);
} else {
window.addEventListener('pointerdown', onPointerMove);
window.addEventListener('pointermove', onPointerMove);
}
return () => {
if (hasTapEvent) {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('touchstart', onTouchStart);
} else {
window.removeEventListener('pointerdown', onPointerMove);
window.removeEventListener('pointermove', onPointerMove);
}
};
}, []);
return isTouch;
};
export default useInteraction;

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { InteractionContext } from '../context/InteractionContext';
export const useIsTouch = (): boolean => {
const { isTouch } = useContext(InteractionContext);
return isTouch;
};

@ -1,9 +1,9 @@
{
"components.Discover.discovermovies": "Beliebte Filme",
"components.Discover.discovertv": "Beliebte Serie",
"components.Discover.discovertv": "Beliebte Serien",
"components.Discover.nopending": "Keine ausstehenden Anträge",
"components.Discover.popularmovies": "Beliebte Filme",
"components.Discover.populartv": "Beliebte Serie",
"components.Discover.populartv": "Beliebte Serien",
"components.Discover.recentlyAdded": "Kürzlich hinzugefügt",
"components.Discover.recentrequests": "Letzte Anträge",
"components.Discover.trending": "Trends",
@ -304,5 +304,9 @@
"pages.serviceUnavailable": "{statusCode} Dienst nicht verfügbar",
"pages.somethingWentWrong": "{statusCode} Es ist ein Fehler aufgetreten",
"components.TvDetails.TvCast.fullseriescast": "Vollserienbesetzung",
"components.MovieDetails.MovieCast.fullcast": "Vollständige Besetzung"
"components.MovieDetails.MovieCast.fullcast": "Vollständige Besetzung",
"components.Settings.Notifications.emailsettingssaved": "E-Mail-Benachrichtigungseinstellungen gespeichert!",
"components.Settings.Notifications.emailsettingsfailed": "Die Einstellungen für die E-Mail-Benachrichtigungen konnten nicht gespeichert werden.",
"components.Settings.Notifications.discordsettingssaved": "Discord-Benachrichtigungseinstellungen gespeichert!",
"components.Settings.Notifications.discordsettingsfailed": "Die Einstellungen für die Discord-Benachrichtigungen konnten nicht gespeichert werden."
}

@ -236,6 +236,8 @@
"components.Setup.finishing": "Finishing...",
"components.Setup.loginwithplex": "Login with Plex",
"components.Setup.signinMessage": "Get started by logging in with your Plex account",
"components.Setup.syncingbackground": "Syncing will run in the background. You can continue the setup process in the meantime.",
"components.Setup.tip": "Tip",
"components.Setup.welcome": "Welcome to Overseerr",
"components.Slider.noresults": "No Results",
"components.TitleCard.movie": "Movie",

@ -0,0 +1,323 @@
{
"components.Settings.SonarrModal.toastRadarrTestFailure": "Error al connectar al Servidor Sonarr",
"components.Settings.SonarrModal.testing": "Comprobando...",
"components.Settings.SonarrModal.test": "Comprobar",
"components.Settings.SonarrModal.ssl": "SSL",
"components.Settings.SonarrModal.servernamePlaceholder": "Un servidor Sonarr",
"components.Settings.SonarrModal.servername": "Nombre del Servidor",
"components.Settings.SonarrModal.server4k": "Servidor 4K",
"components.Settings.SonarrModal.selectRootFolder": "Selecciona la Carpeta Raíz",
"components.Settings.SonarrModal.selectQualityProfile": "Selecciona un Perfil de Calidad",
"components.Settings.SonarrModal.seasonfolders": "Carpetas por Temporada",
"components.Settings.SonarrModal.saving": "Guardando...",
"components.Settings.SonarrModal.save": "Guardar Cambios",
"components.Settings.SonarrModal.rootfolder": "Carpeta Raíz",
"components.Settings.SonarrModal.qualityprofile": "Perfil de Calidad",
"components.Settings.SonarrModal.port": "Puerto",
"components.Settings.SonarrModal.hostname": "Nombre de Host",
"components.Settings.SonarrModal.editsonarr": "Editar Servidor Sonarr",
"components.Settings.SonarrModal.defaultserver": "Servidor por Defecto",
"components.Settings.SonarrModal.createsonarr": "Crear Nuevo Servidor Sonarr",
"components.Settings.SonarrModal.baseUrlPlaceholder": "Ejemplo: /sonarr",
"components.Settings.SonarrModal.baseUrl": "URL Base",
"components.Settings.SonarrModal.apiKeyPlaceholder": "Tu Clave API de Sonarr",
"components.Settings.SonarrModal.apiKey": "Clave API",
"components.Settings.SonarrModal.add": "Agregar Servidor",
"components.Settings.SettingsAbout.version": "Versión",
"components.Settings.SettingsAbout.totalrequests": "Peticiones Totales",
"components.Settings.SettingsAbout.totalmedia": "Contenido Total",
"components.Settings.SettingsAbout.overseerrinformation": "Información de Overseerr",
"components.Settings.SettingsAbout.githubdiscussions": "Discursiones en GitHub",
"components.Settings.SettingsAbout.gettingsupport": "Soporte",
"components.Settings.SettingsAbout.clickheretojoindiscord": "Click aquín para unirte a nuestro servidor de Discord.",
"components.Settings.RadarrModal.validationRootFolderRequired": "Debes seleccionar una carpeta raíz",
"components.Settings.RadarrModal.validationProfileRequired": "Debes seleccionar un perfil",
"components.Settings.RadarrModal.validationPortRequired": "Debes proporcionar un puerto",
"components.Settings.RadarrModal.validationNameRequired": "Debes proporcionar un nombre de servidor",
"components.Settings.RadarrModal.validationHostnameRequired": "Debes proporcionar un hostname/IP",
"components.Settings.RadarrModal.validationApiKeyRequired": "Debes proporcionar la clave API",
"components.Settings.RadarrModal.toastRadarrTestSuccess": "¡Conexión con Radarr establecida!",
"components.Settings.RadarrModal.toastRadarrTestFailure": "Error al connectar al Servidor Radarr",
"components.Settings.RadarrModal.testing": "Comprobando...",
"components.Settings.RadarrModal.test": "Comprobar",
"components.Settings.RadarrModal.ssl": "SSL",
"components.Settings.RadarrModal.servernamePlaceholder": "Un Servidor Radarr",
"components.Settings.RadarrModal.servername": "Nombre del Servidor",
"components.Settings.RadarrModal.server4k": "Servidor 4K",
"components.Settings.RadarrModal.selectRootFolder": "Selecciona la Carpeta Raíz",
"components.Settings.RadarrModal.selectQualityProfile": "Selecciona un Perfil de Calidad",
"components.Settings.RadarrModal.selectMinimumAvailability": "Selecciona Disponibilidad Mínima",
"components.Settings.RadarrModal.saving": "Guardando...",
"components.Settings.RadarrModal.save": "Guardar Cambios",
"components.Settings.RadarrModal.rootfolder": "Carpeta Raíz",
"components.Settings.RadarrModal.qualityprofile": "Perfil de Calidad",
"components.Settings.RadarrModal.port": "Puerto",
"components.Settings.RadarrModal.minimumAvailability": "Disponibilidad Mínima",
"components.Settings.RadarrModal.hostname": "Nombre de Host",
"components.Settings.RadarrModal.editradarr": "Editar Servidor Radarr",
"components.Settings.RadarrModal.defaultserver": "Servidor por Defecto",
"components.Settings.RadarrModal.createradarr": "Crear Nuevo Servidor Radarr",
"components.Settings.RadarrModal.baseUrlPlaceholder": "Ejemplo: /radarr",
"components.Settings.RadarrModal.baseUrl": "URL Base",
"components.Settings.RadarrModal.apiKeyPlaceholder": "Tu clave API de Radarr",
"components.Settings.RadarrModal.apiKey": "Clave API",
"components.Settings.RadarrModal.add": "Agregar Servidor",
"components.Settings.Notifications.webhookUrlPlaceholder": "Ajustes del servidor -> Integraciones -> Webhooks",
"components.Settings.Notifications.webhookUrl": "URL de Webhook",
"components.Settings.Notifications.validationWebhookUrlRequired": "Debes proporcionar una URL del webhook",
"components.Settings.Notifications.validationSmtpPortRequired": "Debes proporcionar un puerto SMTP",
"components.Settings.Notifications.validationSmtpHostRequired": "Debes proporcionar un host SMTP",
"components.Settings.Notifications.validationFromRequired": "Debes proporcionar una dirección de envío de email",
"components.Settings.Notifications.smtpPort": "Puerto SMTP",
"components.Settings.Notifications.smtpHost": "Host SMTP",
"components.Settings.Notifications.saving": "Guardando...",
"components.Settings.Notifications.save": "Guardar Cambios",
"components.Settings.Notifications.enableSsl": "Activar SSL",
"components.Settings.Notifications.emailsettingssaved": "¡Ajustes de notificación de Email guardados!",
"components.Settings.Notifications.emailsettingsfailed": "Fallo al guardar ajustes de notificación de Email.",
"components.Settings.Notifications.emailsender": "Dirección del Remitente de Email",
"components.Settings.Notifications.discordsettingssaved": "¡Ajustes de notificación de Discord guardados!",
"components.Settings.Notifications.discordsettingsfailed": "Fallo al guardar ajustes de notificación de Discord.",
"components.Settings.Notifications.authUser": "Usuario",
"components.Settings.Notifications.authPass": "Contraseña",
"components.Settings.Notifications.agentenabled": "Agente Activado",
"components.Search.searchresults": "Resultado de la búsqueda",
"components.RequestModal.status": "Estado",
"components.RequestModal.selectseason": "Seleccionar temporada(s)",
"components.RequestModal.seasonnumber": "Temporada {number}",
"components.RequestModal.season": "Temporada",
"components.RequestModal.requesttitle": "Solicitar {title}",
"components.RequestModal.requestseasons": "Solicitar {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}",
"components.RequestModal.requesting": "Solicitando…",
"components.RequestModal.requestfrom": "Hay una petición pendiente de {username}",
"components.RequestModal.requestadmin": "Tu petición será aprovada inmediatamente.",
"components.RequestModal.requestSuccess": "<strong>{title}</strong> solicitado.",
"components.RequestList.showingresults": "Mostrando <strong>{from}</strong> a <strong>{to}</strong> de <strong>{total}</strong> resultados",
"components.RequestModal.requestCancel": "Solicitud para <strong>{title}</strong> cancelada",
"components.RequestModal.request": "Solicitar",
"components.RequestModal.pendingrequest": "Solicitud pendiente para {title}",
"components.RequestModal.numberofepisodes": "# de Episodios",
"components.RequestModal.notrequested": "No Solicitado",
"components.RequestModal.extras": "Extras",
"components.RequestModal.close": "Cerrar",
"components.RequestModal.cancelrequest": "Esto borrará tu petición. ¿Quieres continuar?",
"components.RequestModal.cancelling": "Cancelando...",
"components.RequestModal.cancel": "Cancelar Petición",
"components.RequestList.status": "Estado",
"components.RequestList.requests": "Peticiones",
"components.RequestList.requestedAt": "Solicitado",
"components.RequestList.previous": "Anterior",
"components.RequestList.next": "Siguiente",
"components.RequestList.modifiedBy": "Última Edición Por",
"components.RequestList.mediaInfo": "Información",
"components.RequestList.RequestItem.seasons": "Temporadas",
"components.RequestList.RequestItem.requestedby": "Solicitado por {username}",
"components.RequestList.RequestItem.notavailable": "N/D",
"components.RequestCard.seasons": "Temporadas",
"components.RequestCard.requestedby": "Solicitado por {username}",
"components.RequestCard.all": "Todas",
"components.RequestBlock.seasons": "Temporadas",
"components.PlexLoginButton.loginwithplex": "Iniciar Sesión con Plex",
"components.PlexLoginButton.loggingin": "Iniciando Sesión...",
"components.PlexLoginButton.loading": "Cargando...",
"components.PersonDetails.nobiography": "No hay biografía disponible.",
"components.PersonDetails.ascharacter": "como {character}",
"components.PersonDetails.appearsin": "Aparece en",
"components.MovieDetails.viewrequest": "Ver Petición",
"components.MovieDetails.userrating": "Puntuación de los Usuarios",
"components.MovieDetails.unavailable": "No Disponible",
"components.MovieDetails.status": "Estado",
"components.MovieDetails.similarsubtext": "Otras películas parecidas a {title}",
"components.MovieDetails.similar": "Títulos Similares",
"components.MovieDetails.runtime": "{minutes} minutos",
"components.MovieDetails.revenue": "Recaudado",
"components.MovieDetails.request": "Solicitar",
"components.MovieDetails.releasedate": "Fecha de Lanzamiento",
"components.MovieDetails.recommendationssubtext": "Si te gustó {title}, también te puede gustar...",
"components.MovieDetails.cast": "Reparto",
"components.MovieDetails.MovieCast.fullcast": "Reparto Completo",
"components.MovieDetails.recommendations": "Recomendaciones",
"components.MovieDetails.pending": "Pendiente",
"components.MovieDetails.overviewunavailable": "Resumen no disponible",
"components.MovieDetails.overview": "Resumen",
"components.MovieDetails.originallanguage": "Idioma Original",
"components.MovieDetails.manageModalTitle": "Gestionar Película",
"components.MovieDetails.manageModalRequests": "Peticiones",
"components.MovieDetails.manageModalNoRequests": "Sin Peticiones",
"components.MovieDetails.manageModalClearMediaWarning": "Esto borrará todos los datos de los medios, incluyendo las peticiones. Si el elemento existe en tu librería de Plex, la información del elemento se recreará en la siguiente sincronización.",
"components.MovieDetails.manageModalClearMedia": "Borrar todos los datos de medios",
"components.MovieDetails.decline": "Rechazar",
"components.MovieDetails.cancelrequest": "Cancelar Petición",
"components.MovieDetails.budget": "Presupuesto",
"components.MovieDetails.available": "Disponible",
"components.MovieDetails.approve": "Aprobar",
"components.Login.signinplex": "Inicia Sesión para continuar",
"components.Layout.UserDropdown.signout": "Cerrar Sesión",
"components.Layout.Sidebar.users": "Usuarios",
"components.Layout.Sidebar.settings": "Ajustes",
"components.Layout.Sidebar.requests": "Peticiones",
"components.Layout.Sidebar.dashboard": "Descubrir",
"components.Layout.SearchInput.searchPlaceholder": "Buscar Películas y Series",
"components.Layout.LanguagePicker.changelanguage": "Cambiar Idioma",
"components.Discover.upcomingmovies": "Próximas Películas",
"components.Discover.upcoming": "Próximas Películas",
"components.Discover.trending": "Tendencias",
"components.Discover.recentrequests": "Peticiones Recientes",
"components.Discover.recentlyAdded": "Agregado Recientemente",
"components.Discover.populartv": "Series Populares",
"components.Discover.popularmovies": "Películas Populares",
"components.Discover.nopending": "Sin Peticiones Pendientes",
"components.Discover.discovertv": "Series Populares",
"components.Discover.discovermovies": "Películas Populares",
"components.Layout.alphawarning": "Este software está en fase ALFA. Muchas cosas pueden ser inestables o fallar. ¡Por favor reporta estos problemas en el GitHub de Overseerr!",
"components.Settings.addsonarr": "Agregar servidor Sonarr",
"components.Settings.address": "Dirección",
"components.Settings.addradarr": "Agregar servidor Radarr",
"components.Settings.activeProfile": "Perfil activo",
"components.Settings.SonarrModal.validationRootFolderRequired": "Debes seleccionar una carpeta raíz",
"components.Settings.SonarrModal.validationProfileRequired": "Debes seleccionar un perfil",
"components.Settings.SonarrModal.validationPortRequired": "Debes proporcionar un puerto",
"components.Settings.SonarrModal.validationNameRequired": "Debes proporcionar un nombre de servidor",
"components.Settings.SonarrModal.validationHostnameRequired": "Debes proporcionar un hostname/IP",
"components.Settings.SonarrModal.validationApiKeyRequired": "Debes proporcionar la clave API",
"components.Settings.SonarrModal.toastRadarrTestSuccess": "¡Conexión con Sonarr establecida!",
"components.Settings.menuLogs": "Registro",
"pages.somethingWentWrong": "{statusCode} - Algo salió mal",
"pages.serviceUnavailable": "{statusCode}: Servicio no Disponible",
"pages.returnHome": "Volver al Inicio",
"pages.pageNotFound": "404 - Página no encontrada",
"pages.oops": "Ups",
"pages.internalServerError": "{statusCode}: Error interno del servidor",
"i18n.unavailable": "No Disponible",
"i18n.tvshows": "Series",
"i18n.processing": "Procesando…",
"i18n.pending": "Pendiente",
"i18n.partiallyavailable": "Parcialmente Disponible",
"i18n.movies": "Películas",
"i18n.delete": "Eliminar",
"i18n.declined": "Rechazado",
"i18n.decline": "Rechazar",
"i18n.cancel": "Cancelar",
"i18n.available": "Disponible",
"i18n.approved": "Aprobado",
"i18n.approve": "Aprobar",
"components.UserList.usertype": "Tipo de usuario",
"components.UserList.username": "Nombre de usuario",
"components.UserList.userlist": "Lista de usuarios",
"components.UserList.user": "Usuario",
"components.UserList.totalrequests": "Solicitudes totales",
"components.UserList.role": "Rol",
"components.UserList.plexuser": "Usuario de Plex",
"components.UserList.lastupdated": "Última actualización",
"components.UserList.edit": "Editar",
"components.UserList.delete": "Eliminar",
"components.UserList.created": "Creado",
"components.UserList.admin": "Administrador",
"components.UserEdit.voteDescription": "Otorga permiso para votar en las solicitudes (votación aún no implementada)",
"components.UserEdit.vote": "Votar",
"components.UserEdit.usersaved": "Usuario guardado",
"components.UserEdit.usersDescription": "Otorga permiso para administrar usuarios de Overseerr. Los usuarios con este permiso no pueden modificar a los usuarios con privilegios de administrador, o concederlos.",
"components.UserEdit.users": "Administrar usuarios",
"components.UserEdit.username": "Nombre de usuario",
"components.UserEdit.userfail": "Algo salió mal al guardar al usuario.",
"components.UserEdit.settingsDescription": "Otorga permiso para modificar todas las configuraciones de Overseerr. El usuario debe tener este permiso para otorgarlo a otros.",
"components.UserEdit.settings": "Administrar configuración",
"components.UserEdit.saving": "Guardando...",
"components.UserEdit.save": "Guardar",
"components.UserEdit.requestDescription": "Otorga permiso para solicitar películas y series.",
"components.UserEdit.request": "Solicitar",
"components.UserEdit.permissions": "Permisos",
"components.UserEdit.managerequestsDescription": "Otorga permiso para administrar las solicitudes de Overseerr. Esto incluye aprobar y rechazar solicitudes.",
"components.UserEdit.managerequests": "Gestionar solicitudes",
"components.UserEdit.email": "Correo electrónico",
"components.UserEdit.edituser": "Editar Usuario",
"components.UserEdit.avatar": "Avatar",
"components.UserEdit.autoapproveDescription": "Otorga aprobación automática para cualquier solicitud de este usuario.",
"components.UserEdit.autoapprove": "Aprobación automática",
"components.UserEdit.adminDescription": "Acceso total de administrador . Ignoran todas las comprobaciones de permisos.",
"components.UserEdit.admin": "Administrador",
"components.TvDetails.userrating": "Puntuación de los Usuarios",
"components.TvDetails.unavailable": "No Disponible",
"components.TvDetails.status": "Estado",
"components.TvDetails.similarsubtext": "Otras series similares a {title}",
"components.TvDetails.similar": "Series similares",
"components.TvDetails.requestmore": "Solicitar más",
"components.TvDetails.request": "Solicitar",
"components.TvDetails.recommendationssubtext": "Si te gustó {title}, también te puede gustar...",
"components.TvDetails.recommendations": "Recomendaciones",
"components.TvDetails.pending": "Pendiente",
"components.TvDetails.overviewunavailable": "Resumen no disponible",
"components.TvDetails.overview": "Resumen",
"components.TvDetails.originallanguage": "Idioma original",
"components.TvDetails.manageModalTitle": "Gestionar Series",
"components.TvDetails.manageModalRequests": "Peticiones",
"components.TvDetails.manageModalNoRequests": "Sin Peticiones",
"components.TvDetails.manageModalClearMediaWarning": "Esto borrará todos los datos de los medios, incluyendo las peticiones. Si el elemento existe en tu librería de Plex, la información del elemento se recreará en la siguiente sincronización.",
"components.TvDetails.manageModalClearMedia": "Borrar todos los datos de medios",
"components.TvDetails.declinerequests": "Rechazar {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.decline": "Rechazar",
"components.TvDetails.cast": "Reparto",
"components.TvDetails.cancelrequest": "Cancelar Petición",
"components.TvDetails.available": "Disponible",
"components.TvDetails.approverequests": "Aprobar {requestCount} {requestCount, plural, one {Request} other {Requests}}",
"components.TvDetails.approve": "Aprobar",
"components.TvDetails.TvCast.fullseriescast": "Reparto completo de la serie",
"components.TitleCard.tvshow": "Series",
"components.Settings.jobname": "Nombre de Tarea",
"components.TitleCard.movie": "Película",
"components.Slider.noresults": "Sin resultados",
"components.Setup.welcome": "Bienvenido a Overseerr",
"components.Setup.signinMessage": "Comience iniciando sesión con su cuenta de Plex",
"components.Setup.loginwithplex": "Iniciar sesión con Plex",
"components.Setup.finishing": "Finalizando...",
"components.Setup.finish": "Finalizar configuración",
"components.Setup.continue": "Continuar",
"components.Setup.configureplex": "Configurar Plex",
"components.Setup.configureservices": "Configurar servicios",
"components.Settings.validationPortRequired": "Debes proporcionar un puerto",
"components.Settings.validationHostnameRequired": "Debe proporcionar un nombre de host / IP",
"components.Settings.syncing": "Sincronizando…",
"components.Settings.sync": "Sincronizar bibliotecas de Plex",
"components.Settings.startscan": "Iniciar escaneo",
"components.Settings.ssl": "SSL",
"components.Settings.sonarrsettings": "Ajustes de Sonarr",
"components.Settings.sonarrSettingsDescription": "Configure su conexión Sonarr a continuación. Puede tener varias instancias, pero sólo dos activas como predeterminadas en cualquier momento (una para HD estándar y otro para 4K). Los administradores pueden elegir qué servidor se utiliza para nuevas solicitudes.",
"components.Settings.servernamePlaceholder": "Nombre del servidor Plex",
"components.Settings.servername": "Nombre del servidor (Establecido automáticamente después de guardar)",
"components.Settings.saving": "Guardando...",
"components.Settings.save": "Guardar cambios",
"components.Settings.runnow": "Ejecutar ahora",
"components.Settings.radarrsettings": "Ajustes de Radarr",
"components.Settings.radarrSettingsDescription": "Configure su conexión a Radarr a continuación. Puede tener varias instancias, pero sólo dos activas como predeterminadas en cualquier momento (una para HD estándar y otro para 4K). Los administradores pueden elegir qué servidor se utiliza para nuevas solicitudes.",
"components.Settings.port": "Puerto",
"components.Settings.plexsettingsDescription": "Configure los ajustes de su servidor Plex. Overseerr usa su servidor Plex para escanear su biblioteca en un intervalo y ver qué contenido está disponible.",
"components.Settings.plexsettings": "Ajustes de Plex",
"components.Settings.plexlibrariesDescription": "Las bibliotecas en las que Overseerr busca títulos. Configure y guarde la configuración de conexión Plex y haga clic en el botón de abajo si no aparece ninguna.",
"components.Settings.plexlibraries": "Bibliotecas Plex",
"components.Settings.notrunning": "Sin ejecutarse",
"components.Settings.notificationsettingsDescription": "Aquí puedes elegir qué tipos de notificaciones enviar y a través de qué tipos de servicios.",
"components.Settings.notificationsettings": "Configuración de notificaciones",
"components.Settings.nextexecution": "Siguiente ejecución",
"components.Settings.menuServices": "Servicios",
"components.Settings.menuPlexSettings": "Plex",
"components.Settings.menuNotifications": "Notificaciones",
"components.Settings.menuJobs": "Tareas",
"components.Settings.menuGeneralSettings": "Ajustes Generales",
"components.Settings.menuAbout": "Acerca de",
"components.Settings.manualscanDescription": "Normalmente, esto sólo se ejecutará una vez cada 6 horas. Overseerr comprobará de forma más agresiva los añadidos recientemente de su servidor Plex. ¡Si es la primera vez que configura Plex se recomienda un escaneo manual completo de la biblioteca!",
"components.Settings.manualscan": "Escaneo manual de biblioteca",
"components.Settings.librariesRemaining": "Bibliotecas restantes: {count}",
"components.Settings.hostname": "Nombre de host / IP",
"components.Settings.generalsettingsDescription": "Estos son ajustes relacionados con la configuración general de Overseerr.",
"components.Settings.generalsettings": "Configuración general",
"components.Settings.edit": "Editar",
"components.Settings.deleteserverconfirm": "¿Está seguro de que desea eliminar este servidor?",
"components.Settings.delete": "Eliminar",
"components.Settings.default4k": "4K predeterminado",
"components.Settings.default": "Predeterminado",
"components.Settings.currentlibrary": "Biblioteca actual: {name}",
"components.Settings.copied": "Clave API copiada en el portapapeles",
"components.Settings.cancelscan": "Cancelar escaneo",
"components.Settings.applicationurl": "URL de la aplicación",
"components.Settings.apikey": "Clave API"
}

@ -304,5 +304,9 @@
"pages.serviceUnavailable": "{statusCode} Service indisponible",
"pages.somethingWentWrong": "{statusCode} Une erreur est survenue",
"components.TvDetails.TvCast.fullseriescast": "Casting complet de la série",
"components.MovieDetails.MovieCast.fullcast": "Casting complet"
"components.MovieDetails.MovieCast.fullcast": "Casting complet",
"components.Settings.Notifications.emailsettingssaved": "Paramètres de notification par courriel enregistrés !",
"components.Settings.Notifications.emailsettingsfailed": "Les paramètres de notification par courriel n'ont pas pu être enregistrés.",
"components.Settings.Notifications.discordsettingssaved": "Paramètres de notification Discord enregistrés !",
"components.Settings.Notifications.discordsettingsfailed": "Les paramètres de notification Discord n'ont pas pu être enregistrés."
}

@ -205,7 +205,7 @@
"components.Settings.runnow": "今すぐ実行",
"components.Settings.save": "変更を保存",
"components.Settings.saving": "保存中...",
"components.Settings.servername": "サーバー名 (自動設定)",
"components.Settings.servername": "サーバー名 (保存時に自動設定されます)",
"components.Settings.servernamePlaceholder": "Plexサーバー名",
"components.Settings.sonarrSettingsDescription": "Sonarr接続設定。複数のサーバーを繋ぐことができますが、デフォルトで常にアクティブなのは2つのみ1つは標準HD用、もう1つは4K用。管理者は、新しいリクエストに使用するサーバーを選択することができます。",
"components.Settings.sonarrsettings": "Sonarr設定",
@ -302,5 +302,22 @@
"pages.pageNotFound": "404 - ページが見つかりません",
"pages.returnHome": "ホームへ戻る",
"pages.serviceUnavailable": "{statusCode} - サービスが利用できません",
"pages.somethingWentWrong": "{statusCode} - 問題が発生しました"
"pages.somethingWentWrong": "{statusCode} - 問題が発生しました",
"components.TvDetails.TvCast.fullseriescast": "フルシリーズキャスト",
"components.Settings.validationPortRequired": "ポートの入力が必要です",
"components.Settings.validationHostnameRequired": "ホスト名/IPの入力が必要です",
"components.Settings.SonarrModal.validationNameRequired": "サーバー名を指定してください",
"components.Settings.SettingsAbout.version": "バージョン",
"components.Settings.SettingsAbout.totalrequests": "総リクエスト数",
"components.Settings.SettingsAbout.totalmedia": "総メディア数",
"components.Settings.SettingsAbout.overseerrinformation": "Overseerr情報",
"components.Settings.SettingsAbout.githubdiscussions": "GitHubディスカッション",
"components.Settings.SettingsAbout.gettingsupport": "サポート",
"components.Settings.SettingsAbout.clickheretojoindiscord": "Discordサーバーの参加はこちら。",
"components.Settings.RadarrModal.validationNameRequired": "サーバー名を指定してください",
"components.Settings.Notifications.emailsettingssaved": "メール通知設定が保存されました!",
"components.Settings.Notifications.emailsettingsfailed": "メール通知設定の保存に失敗しました。",
"components.Settings.Notifications.discordsettingsfailed": "ディスコード通知設定の保存に失敗しました。",
"components.Settings.Notifications.discordsettingssaved": "ディスコードの通知設定が保存されました!",
"components.MovieDetails.MovieCast.fullcast": "フルキャスト"
}

@ -90,13 +90,13 @@
"components.Settings.Notifications.agentenabled": "Agent Ingeschakeld",
"components.Settings.Notifications.authPass": "Wachtwoord",
"components.Settings.Notifications.authUser": "Gebruikersnaam",
"components.Settings.Notifications.emailsender": "E-mail Adres Van Afzender",
"components.Settings.Notifications.emailsender": "E-mailadres van afzender",
"components.Settings.Notifications.enableSsl": "Schakel SSL in",
"components.Settings.Notifications.save": "Wijzigingen Opslaan",
"components.Settings.Notifications.saving": "Bezig met opslaan...",
"components.Settings.Notifications.smtpHost": "SMTP Host",
"components.Settings.Notifications.smtpPort": "SMTP Poort",
"components.Settings.Notifications.validationFromRequired": "Je moet een afzender adres opgeven",
"components.Settings.Notifications.validationFromRequired": "Je moet een afzenderadres opgeven",
"components.Settings.Notifications.validationSmtpHostRequired": "Je moet een SMTP host opgeven",
"components.Settings.Notifications.validationSmtpPortRequired": "Je moet een SMTP poort opgeven",
"components.Settings.Notifications.validationWebhookUrlRequired": "Je moet een webhook URL opgeven",
@ -205,7 +205,7 @@
"components.Settings.runnow": "Nu Starten",
"components.Settings.save": "Wijzigingen Opslaan",
"components.Settings.saving": "Bezig met opslaan...",
"components.Settings.servername": "Server Naam (Automatisch Ingesteld)",
"components.Settings.servername": "Server Naam (Automatisch Ingesteld na opslaan)",
"components.Settings.servernamePlaceholder": "Plex Server Naam",
"components.Settings.sonarrSettingsDescription": "Stel hier onder je Sonarr connectie in. Je kan er meerdere hebben, maar slechts twee actief hebben als standaard (één voor standaard HD, en één voor 4K). Beheerders kunnen bepalen welke server gebruikt wordt voor nieuwe verzoeken.",
"components.Settings.sonarrsettings": "Sonarr Instellingen",
@ -305,8 +305,12 @@
"pages.somethingWentWrong": "{statusCode} - Er is iets verkeerd gegaan",
"components.MovieDetails.MovieCast.fullcast": "Volledige Cast",
"components.TvDetails.TvCast.fullseriescast": "Volledige Cast van de Series",
"components.Settings.Notifications.emailsettingssaved": "E-mail notificatie instellingen zijn opgeslagen!",
"components.Settings.Notifications.emailsettingsfailed": "E-mail notificatie instellingen konden niet opgeslagen worden.",
"components.Settings.Notifications.emailsettingssaved": "Instellingen voor e-mailmeldingen opgeslagen!",
"components.Settings.Notifications.emailsettingsfailed": "Instellingen voor e-mailmeldingen zijn niet opgeslagen.",
"components.Settings.Notifications.discordsettingssaved": "Discord notificatie instellingen zijn opgeslagen!",
"components.Settings.Notifications.discordsettingsfailed": "Discord notificatie instellingen konden niet opgeslagen worden."
"components.Settings.Notifications.discordsettingsfailed": "Discord notificatie instellingen konden niet opgeslagen worden.",
"components.Settings.validationPortRequired": "Je moet een poort opgeven",
"components.Settings.validationHostnameRequired": "Je moet een hostnaam/IP opgeven",
"components.Settings.SonarrModal.validationNameRequired": "Je moet een servernaam opgeven",
"components.Settings.RadarrModal.validationNameRequired": "Je moet een servernaam opgeven"
}

@ -12,6 +12,7 @@ import { IntlProvider } from 'react-intl';
import { LanguageContext, AvailableLocales } from '../context/LanguageContext';
import Head from 'next/head';
import Toast from '../components/Toast';
import { InteractionProvider } from '../context/InteractionContext';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadLocaleData = (locale: string): Promise<any> => {
@ -28,6 +29,8 @@ const loadLocaleData = (locale: string): Promise<any> => {
return import('../i18n/locale/ru.json');
case 'nl':
return import('../i18n/locale/nl.json');
case 'es':
return import('../i18n/locale/es.json');
default:
return import('../i18n/locale/en.json');
}
@ -88,12 +91,14 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
defaultLocale="en"
messages={loadedMessages}
>
<ToastProvider components={{ Toast }}>
<Head>
<title>Overseerr</title>
</Head>
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
<InteractionProvider>
<ToastProvider components={{ Toast }}>
<Head>
<title>Overseerr</title>
</Head>
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</InteractionProvider>
</IntlProvider>
</LanguageContext.Provider>
</SWRConfig>

@ -0,0 +1,14 @@
import { NextPage } from 'next';
import React from 'react';
import SettingsLayout from '../../components/Settings/SettingsLayout';
import SettingsLogs from '../../components/Settings/SettingsLogs';
const SettingsLogsPage: NextPage = () => {
return (
<SettingsLayout>
<SettingsLogs />
</SettingsLayout>
);
};
export default SettingsLogsPage;

@ -47,3 +47,7 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
code {
@apply bg-gray-800 py-1 px-2 rounded-md;
}

Loading…
Cancel
Save