Merge development into master

This commit is contained in:
github-actions[bot] 2021-09-11 12:47:42 +00:00 committed by GitHub
commit ac2052f43d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1313 additions and 873 deletions

View File

@ -65,7 +65,7 @@ jobs:
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Merge development -> master - name: Merge development -> master
uses: devmasx/merge-branch@v1.3.1 uses: devmasx/merge-branch@1.4.0
with: with:
type: now type: now
from_branch: development from_branch: development

View File

@ -50,6 +50,7 @@ If you need something that is not already part of Bazarr, feel free to create a
* Hosszupuska * Hosszupuska
* LegendasDivx * LegendasDivx
* LegendasTV * LegendasTV
* Ktuvit (Get `hashed_password` using method described [here](https://github.com/XBMCil/service.subtitles.ktuvit))
* Napiprojekt * Napiprojekt
* Napisy24 * Napisy24
* Nekur * Nekur

View File

@ -139,6 +139,10 @@ defaults = {
'password': '', 'password': '',
'skip_wrong_fps': 'False' 'skip_wrong_fps': 'False'
}, },
'ktuvit': {
'email': '',
'hashed_password': ''
},
'legendastv': { 'legendastv': {
'username': '', 'username': '',
'password': '', 'password': '',

View File

@ -188,6 +188,10 @@ def get_providers_auth():
'username': settings.titlovi.username, 'username': settings.titlovi.username,
'password': settings.titlovi.password, 'password': settings.titlovi.password,
}, },
'ktuvit' : {
'email': settings.ktuvit.email,
'hashed_password': settings.ktuvit.hashed_password,
},
} }

View File

@ -45,8 +45,10 @@ import logging
def is_virtualenv(): def is_virtualenv():
# return True if Bazarr have been start from within a virtualenv or venv # return True if Bazarr have been start from within a virtualenv or venv
base_prefix = getattr(sys, "base_prefix", None) or getattr(sys, "real_prefix", None) or sys.prefix base_prefix = getattr(sys, "base_prefix", None)
return base_prefix != sys.prefix # real_prefix will return None if not in a virtualenv enviroment or the default python path
real_prefix = getattr(sys, "real_prefix", None) or sys.prefix
return base_prefix != real_prefix
# deploy requirements.txt # deploy requirements.txt

View File

@ -25,7 +25,7 @@
"react-dom": "^17", "react-dom": "^17",
"react-helmet": "^6.1", "react-helmet": "^6.1",
"react-redux": "^7.2", "react-redux": "^7.2",
"react-router-dom": "^5.2", "react-router-dom": "^5.3",
"react-scripts": "^4", "react-scripts": "^4",
"react-select": "^4", "react-select": "^4",
"react-table": "^7", "react-table": "^7",
@ -17095,11 +17095,11 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "5.2.0", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz",
"integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==", "integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.1.2", "@babel/runtime": "^7.12.13",
"history": "^4.9.0", "history": "^4.9.0",
"hoist-non-react-statics": "^3.1.0", "hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1", "loose-envify": "^1.3.1",
@ -17115,15 +17115,15 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "5.2.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz",
"integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==", "integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.1.2", "@babel/runtime": "^7.12.13",
"history": "^4.9.0", "history": "^4.9.0",
"loose-envify": "^1.3.1", "loose-envify": "^1.3.1",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"react-router": "5.2.0", "react-router": "5.2.1",
"tiny-invariant": "^1.0.2", "tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0" "tiny-warning": "^1.0.0"
}, },
@ -18316,9 +18316,9 @@
"integrity": "sha512-vTxrZz4dX5W86M6oVWVdOVe72ZiPs41Oi7Z6Km4W5Turyz28mrXSJhhEBZoRtzJWIv3833WKVwLSDWWkEfupMg==" "integrity": "sha512-vTxrZz4dX5W86M6oVWVdOVe72ZiPs41Oi7Z6Km4W5Turyz28mrXSJhhEBZoRtzJWIv3833WKVwLSDWWkEfupMg=="
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.37.5", "version": "1.38.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.37.5.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.38.2.tgz",
"integrity": "sha512-Cx3ewxz9QB/ErnVIiWg2cH0kiYZ0FPvheDTVC6BsiEGBTZKKZJ1Gq5Kq6jy3PKtL6+EJ8NIoaBW/RSd2R6cZOA==", "integrity": "sha512-Bz1fG6qiyF0FX6m/I+VxtdVKz1Dfmg/e9kfDy2PhWOkq3T384q2KxwIfP0fXpeI+EyyETdOauH+cRHQDFASllA==",
"dependencies": { "dependencies": {
"chokidar": ">=3.0.0 <4.0.0" "chokidar": ">=3.0.0 <4.0.0"
}, },
@ -35324,11 +35324,11 @@
} }
}, },
"react-router": { "react-router": {
"version": "5.2.0", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz",
"integrity": "sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw==", "integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==",
"requires": { "requires": {
"@babel/runtime": "^7.1.2", "@babel/runtime": "^7.12.13",
"history": "^4.9.0", "history": "^4.9.0",
"hoist-non-react-statics": "^3.1.0", "hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1", "loose-envify": "^1.3.1",
@ -35341,15 +35341,15 @@
} }
}, },
"react-router-dom": { "react-router-dom": {
"version": "5.2.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz",
"integrity": "sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA==", "integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==",
"requires": { "requires": {
"@babel/runtime": "^7.1.2", "@babel/runtime": "^7.12.13",
"history": "^4.9.0", "history": "^4.9.0",
"loose-envify": "^1.3.1", "loose-envify": "^1.3.1",
"prop-types": "^15.6.2", "prop-types": "^15.6.2",
"react-router": "5.2.0", "react-router": "5.2.1",
"tiny-invariant": "^1.0.2", "tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0" "tiny-warning": "^1.0.0"
} }
@ -36292,9 +36292,9 @@
"integrity": "sha512-vTxrZz4dX5W86M6oVWVdOVe72ZiPs41Oi7Z6Km4W5Turyz28mrXSJhhEBZoRtzJWIv3833WKVwLSDWWkEfupMg==" "integrity": "sha512-vTxrZz4dX5W86M6oVWVdOVe72ZiPs41Oi7Z6Km4W5Turyz28mrXSJhhEBZoRtzJWIv3833WKVwLSDWWkEfupMg=="
}, },
"sass": { "sass": {
"version": "1.37.5", "version": "1.38.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.37.5.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.38.2.tgz",
"integrity": "sha512-Cx3ewxz9QB/ErnVIiWg2cH0kiYZ0FPvheDTVC6BsiEGBTZKKZJ1Gq5Kq6jy3PKtL6+EJ8NIoaBW/RSd2R6cZOA==", "integrity": "sha512-Bz1fG6qiyF0FX6m/I+VxtdVKz1Dfmg/e9kfDy2PhWOkq3T384q2KxwIfP0fXpeI+EyyETdOauH+cRHQDFASllA==",
"requires": { "requires": {
"chokidar": ">=3.0.0 <4.0.0" "chokidar": ">=3.0.0 <4.0.0"
} }

View File

@ -30,7 +30,7 @@
"react-dom": "^17", "react-dom": "^17",
"react-helmet": "^6.1", "react-helmet": "^6.1",
"react-redux": "^7.2", "react-redux": "^7.2",
"react-router-dom": "^5.2", "react-router-dom": "^5.3",
"react-scripts": "^4", "react-scripts": "^4",
"react-select": "^4", "react-select": "^4",
"react-table": "^7", "react-table": "^7",

View File

@ -47,7 +47,9 @@ export const siteUpdateNotifier = createAction<string>(
"site/progress/update_notifier" "site/progress/update_notifier"
); );
export const siteChangeSidebar = createAction<string>("site/sidebar/update"); export const siteChangeSidebarVisibility = createAction<boolean>(
"site/sidebar/visibility"
);
export const siteUpdateOffline = createAction<boolean>("site/offline/update"); export const siteUpdateOffline = createAction<boolean>("site/offline/update");

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react"; import { useCallback } from "react";
import { useSystemSettings } from "."; import { useSystemSettings } from ".";
import { siteAddNotifications, siteChangeSidebar } from "../actions"; import { siteAddNotifications } from "../actions";
import { useReduxAction, useReduxStore } from "./base"; import { useReduxAction, useReduxStore } from "./base";
export function useNotification(id: string, timeout: number = 5000) { export function useNotification(id: string, timeout: number = 5000) {
@ -37,10 +37,3 @@ export function useShowOnlyDesired() {
const settings = useSystemSettings(); const settings = useSystemSettings();
return settings.content?.general.embedded_subs_show_desired ?? false; return settings.content?.general.embedded_subs_show_desired ?? false;
} }
export function useSetSidebar(key: string) {
const update = useReduxAction(siteChangeSidebar);
useEffect(() => {
update(key);
}, [update, key]);
}

View File

@ -6,7 +6,7 @@ import {
siteAddNotifications, siteAddNotifications,
siteAddProgress, siteAddProgress,
siteBootstrap, siteBootstrap,
siteChangeSidebar, siteChangeSidebarVisibility,
siteRedirectToAuth, siteRedirectToAuth,
siteRemoveNotifications, siteRemoveNotifications,
siteRemoveProgress, siteRemoveProgress,
@ -28,7 +28,7 @@ interface Site {
timestamp: string; timestamp: string;
}; };
notifications: Server.Notification[]; notifications: Server.Notification[];
sidebar: string; showSidebar: boolean;
badges: Badge; badges: Badge;
} }
@ -41,7 +41,7 @@ const defaultSite: Site = {
timestamp: String(Date.now()), timestamp: String(Date.now()),
}, },
notifications: [], notifications: [],
sidebar: "", showSidebar: false,
badges: { badges: {
movies: 0, movies: 0,
episodes: 0, episodes: 0,
@ -116,8 +116,8 @@ const reducer = createReducer(defaultSite, (builder) => {
}); });
builder builder
.addCase(siteChangeSidebar, (state, action) => { .addCase(siteChangeSidebarVisibility, (state, action) => {
state.sidebar = action.payload; state.showSidebar = action.payload;
}) })
.addCase(siteUpdateOffline, (state, action) => { .addCase(siteUpdateOffline, (state, action) => {
state.offline = action.payload; state.offline = action.payload;

View File

@ -21,6 +21,7 @@ interface Settings {
subscene: Settings.Subscene; subscene: Settings.Subscene;
betaseries: Settings.Betaseries; betaseries: Settings.Betaseries;
titlovi: Settings.Titlovi; titlovi: Settings.Titlovi;
ktuvit: Settings.Ktuvit;
notifications: Settings.Notifications; notifications: Settings.Notifications;
} }
@ -193,6 +194,11 @@ declare namespace Settings {
interface Titlovi extends BaseProvider {} interface Titlovi extends BaseProvider {}
interface Ktuvit {
email?: string;
hashed_password?: string;
}
interface Betaseries { interface Betaseries {
token?: string; token?: string;
} }

View File

@ -5,7 +5,7 @@ import {
faUser, faUser,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useContext, useMemo } from "react"; import React, { FunctionComponent, useMemo } from "react";
import { import {
Button, Button,
Col, Col,
@ -16,8 +16,10 @@ import {
Row, Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { SidebarToggleContext } from "."; import {
import { siteRedirectToAuth } from "../@redux/actions"; siteChangeSidebarVisibility,
siteRedirectToAuth,
} from "../@redux/actions";
import { useSystemSettings } from "../@redux/hooks"; import { useSystemSettings } from "../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base"; import { useReduxAction } from "../@redux/hooks/base";
import { useIsOffline } from "../@redux/hooks/site"; import { useIsOffline } from "../@redux/hooks/site";
@ -56,7 +58,7 @@ const Header: FunctionComponent<Props> = () => {
const canLogout = (settings.content?.auth.type ?? "none") === "form"; const canLogout = (settings.content?.auth.type ?? "none") === "form";
const toggleSidebar = useContext(SidebarToggleContext); const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
const offline = useIsOffline(); const offline = useIsOffline();
@ -115,7 +117,10 @@ const Header: FunctionComponent<Props> = () => {
className="cursor-pointer" className="cursor-pointer"
></Image> ></Image>
</div> </div>
<Button className="mx-2 m-0 d-md-none" onClick={toggleSidebar}> <Button
className="mx-2 m-0 d-md-none"
onClick={() => changeSidebar(true)}
>
<FontAwesomeIcon icon={faBars}></FontAwesomeIcon> <FontAwesomeIcon icon={faBars}></FontAwesomeIcon>
</Button> </Button>
<Container fluid> <Container fluid>

View File

@ -1,71 +0,0 @@
import React, { FunctionComponent, useMemo } from "react";
import { Redirect, Route, Switch, useHistory } from "react-router-dom";
import { useDidMount } from "rooks";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import BlacklistRouter from "../Blacklist/Router";
import DisplayItemRouter from "../DisplayItem/Router";
import HistoryRouter from "../History/Router";
import SettingRouter from "../Settings/Router";
import EmptyPage, { RouterEmptyPath } from "../special-pages/404";
import SystemRouter from "../System/Router";
import { ScrollToTop } from "../utilities";
import WantedRouter from "../Wanted/Router";
const Router: FunctionComponent<{ className?: string }> = ({ className }) => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
const redirectPath = useMemo(() => {
if (sonarr) {
return "/series";
} else if (radarr) {
return "/movies";
} else {
return "/settings";
}
}, [sonarr, radarr]);
const history = useHistory();
useDidMount(() => {
history.listen(() => {
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
setTimeout(ScrollToTop);
});
});
return (
<div className={className}>
<Switch>
<Route exact path="/">
<Redirect exact to={redirectPath}></Redirect>
</Route>
<Route path={["/series", "/movies"]}>
<DisplayItemRouter></DisplayItemRouter>
</Route>
<Route path="/wanted">
<WantedRouter></WantedRouter>
</Route>
<Route path="/history">
<HistoryRouter></HistoryRouter>
</Route>
<Route path="/blacklist">
<BlacklistRouter></BlacklistRouter>
</Route>
<Route path="/settings">
<SettingRouter></SettingRouter>
</Route>
<Route path="/system">
<SystemRouter></SystemRouter>
</Route>
<Route exact path={RouterEmptyPath}>
<EmptyPage></EmptyPage>
</Route>
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
</div>
);
};
export default Router;

View File

@ -1,9 +1,4 @@
import React, { import React, { FunctionComponent, useEffect } from "react";
FunctionComponent,
useCallback,
useEffect,
useState,
} from "react";
import { Row } from "react-bootstrap"; import { Row } from "react-bootstrap";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { Route, Switch } from "react-router"; import { Route, Switch } from "react-router";
@ -14,16 +9,15 @@ import { useReduxStore } from "../@redux/hooks/base";
import { useNotification } from "../@redux/hooks/site"; import { useNotification } from "../@redux/hooks/site";
import store from "../@redux/store"; import store from "../@redux/store";
import { LoadingIndicator, ModalProvider } from "../components"; import { LoadingIndicator, ModalProvider } from "../components";
import Router from "../Router";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
import Auth from "../special-pages/AuthPage"; import Auth from "../special-pages/AuthPage";
import ErrorBoundary from "../special-pages/ErrorBoundary"; import ErrorBoundary from "../special-pages/ErrorBoundary";
import LaunchError from "../special-pages/LaunchError"; import LaunchError from "../special-pages/LaunchError";
import { Environment } from "../utilities"; import { Environment } from "../utilities";
import Header from "./Header"; import Header from "./Header";
import Router from "./Router";
// Sidebar Toggle // Sidebar Toggle
export const SidebarToggleContext = React.createContext<() => void>(() => {});
interface Props {} interface Props {}
@ -43,9 +37,6 @@ const App: FunctionComponent<Props> = () => {
} }
}, initialized === true); }, initialized === true);
const [sidebar, setSidebar] = useState(false);
const toggleSidebar = useCallback(() => setSidebar((s) => !s), []);
if (!auth) { if (!auth) {
return <Redirect to="/login"></Redirect>; return <Redirect to="/login"></Redirect>;
} }
@ -61,17 +52,15 @@ const App: FunctionComponent<Props> = () => {
} }
return ( return (
<ErrorBoundary> <ErrorBoundary>
<SidebarToggleContext.Provider value={toggleSidebar}> <Row noGutters className="header-container">
<Row noGutters className="header-container"> <Header></Header>
<Header></Header> </Row>
</Row> <Row noGutters className="flex-nowrap">
<Row noGutters className="flex-nowrap"> <Sidebar></Sidebar>
<Sidebar open={sidebar}></Sidebar> <ModalProvider>
<ModalProvider> <Router></Router>
<Router className="d-flex flex-row flex-grow-1 main-router"></Router> </ModalProvider>
</ModalProvider> </Row>
</Row>
</SidebarToggleContext.Provider>
</ErrorBoundary> </ErrorBoundary>
); );
}; };

View File

@ -1,36 +0,0 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import {
useIsRadarrEnabled,
useIsSonarrEnabled,
useSetSidebar,
} from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import BlacklistMovies from "./Movies";
import BlacklistSeries from "./Series";
const Router: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
useSetSidebar("Blacklist");
return (
<Switch>
{sonarr && (
<Route exact path="/blacklist/series">
<BlacklistSeries></BlacklistSeries>
</Route>
)}
{radarr && (
<Route path="/blacklist/movies">
<BlacklistMovies></BlacklistMovies>
</Route>
)}
<Route path="/blacklist/*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

View File

@ -102,7 +102,7 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
seriesid: id, seriesid: id,
} }
); );
dispatchTask("Scaning disk...", [task], "Scaning..."); dispatchTask("Scanning disk...", [task], "Scanning...");
}} }}
> >
Scan Disk Scan Disk

View File

@ -1,45 +0,0 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
import { RouterEmptyPath } from "../special-pages/404";
import Episodes from "./Episodes";
import MovieDetail from "./MovieDetail";
import Movies from "./Movies";
import Series from "./Series";
interface Props {}
const Router: FunctionComponent<Props> = () => {
const radarr = useIsRadarrEnabled();
const sonarr = useIsSonarrEnabled();
return (
<Switch>
{radarr && (
<Route exact path="/movies">
<Movies></Movies>
</Route>
)}
{radarr && (
<Route path="/movies/:id">
<MovieDetail></MovieDetail>
</Route>
)}
{sonarr && (
<Route exact path="/series">
<Series></Series>
</Route>
)}
{sonarr && (
<Route path="/series/:id">
<Episodes></Episodes>
</Route>
)}
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

View File

@ -1,40 +0,0 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import {
useIsRadarrEnabled,
useIsSonarrEnabled,
useSetSidebar,
} from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import MoviesHistory from "./Movies";
import SeriesHistory from "./Series";
import HistoryStats from "./Statistics";
const Router: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
useSetSidebar("History");
return (
<Switch>
{sonarr && (
<Route exact path="/history/series">
<SeriesHistory></SeriesHistory>
</Route>
)}
{radarr && (
<Route exact path="/history/movies">
<MoviesHistory></MoviesHistory>
</Route>
)}
<Route exact path="/history/stats">
<HistoryStats></HistoryStats>
</Route>
<Route path="/history/*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

View File

@ -0,0 +1,19 @@
import { FunctionComponent } from "react";
import { Redirect } from "react-router-dom";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
const RootRedirect: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
let path = "/settings";
if (sonarr) {
path = "/series";
} else if (radarr) {
path = "movies";
}
return <Redirect to={path}></Redirect>;
};
export default RootRedirect;

View File

@ -0,0 +1,245 @@
import {
faClock,
faCogs,
faExclamationTriangle,
faFileExcel,
faFilm,
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
import { useReduxStore } from "../@redux/hooks/base";
import BlacklistMoviesView from "../Blacklist/Movies";
import BlacklistSeriesView from "../Blacklist/Series";
import Episodes from "../DisplayItem/Episodes";
import MovieDetail from "../DisplayItem/MovieDetail";
import MovieView from "../DisplayItem/Movies";
import SeriesView from "../DisplayItem/Series";
import MoviesHistoryView from "../History/Movies";
import SeriesHistoryView from "../History/Series";
import HistoryStats from "../History/Statistics";
import SettingsGeneralView from "../Settings/General";
import SettingsLanguagesView from "../Settings/Languages";
import SettingsNotificationsView from "../Settings/Notifications";
import SettingsProvidersView from "../Settings/Providers";
import SettingsRadarrView from "../Settings/Radarr";
import SettingsSchedulerView from "../Settings/Scheduler";
import SettingsSonarrView from "../Settings/Sonarr";
import SettingsSubtitlesView from "../Settings/Subtitles";
import SettingsUIView from "../Settings/UI";
import EmptyPage, { RouterEmptyPath } from "../special-pages/404";
import SystemLogsView from "../System/Logs";
import SystemProvidersView from "../System/Providers";
import SystemReleasesView from "../System/Releases";
import SystemStatusView from "../System/Status";
import SystemTasksView from "../System/Tasks";
import WantedMoviesView from "../Wanted/Movies";
import WantedSeriesView from "../Wanted/Series";
import { Navigation } from "./nav";
import RootRedirect from "./RootRedirect";
export function useNavigationItems() {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
const { movies, episodes, providers } = useReduxStore((s) => s.site.badges);
const items = useMemo<Navigation.RouteItem[]>(
() => [
{
name: "404",
path: RouterEmptyPath,
component: EmptyPage,
routeOnly: true,
},
{
name: "Redirect",
path: "/",
component: RootRedirect,
routeOnly: true,
},
{
icon: faPlay,
name: "Series",
path: "/series",
component: SeriesView,
enabled: sonarr,
routes: [
{
name: "Episode",
path: "/:id",
component: Episodes,
routeOnly: true,
},
],
},
{
icon: faFilm,
name: "Movies",
path: "/movies",
component: MovieView,
enabled: radarr,
routes: [
{
name: "Movie Details",
path: "/:id",
component: MovieDetail,
routeOnly: true,
},
],
},
{
icon: faClock,
name: "History",
path: "/history",
routes: [
{
name: "Series",
path: "/series",
enabled: sonarr,
component: SeriesHistoryView,
},
{
name: "Movies",
path: "/movies",
enabled: radarr,
component: MoviesHistoryView,
},
{
name: "Statistics",
path: "/stats",
component: HistoryStats,
},
],
},
{
icon: faFileExcel,
name: "Blacklist",
path: "/blacklist",
routes: [
{
name: "Series",
path: "/series",
enabled: sonarr,
component: BlacklistSeriesView,
},
{
name: "Movies",
path: "/movies",
enabled: radarr,
component: BlacklistMoviesView,
},
],
},
{
icon: faExclamationTriangle,
name: "Wanted",
path: "/wanted",
routes: [
{
name: "Series",
path: "/series",
badge: episodes,
enabled: sonarr,
component: WantedSeriesView,
},
{
name: "Movies",
path: "/movies",
badge: movies,
enabled: radarr,
component: WantedMoviesView,
},
],
},
{
icon: faCogs,
name: "Settings",
path: "/settings",
routes: [
{
name: "General",
path: "/general",
component: SettingsGeneralView,
},
{
name: "Languages",
path: "/languages",
component: SettingsLanguagesView,
},
{
name: "Providers",
path: "/providers",
component: SettingsProvidersView,
},
{
name: "Subtitles",
path: "/subtitles",
component: SettingsSubtitlesView,
},
{
name: "Sonarr",
path: "/sonarr",
component: SettingsSonarrView,
},
{
name: "Radarr",
path: "/radarr",
component: SettingsRadarrView,
},
{
name: "Notifications",
path: "/notifications",
component: SettingsNotificationsView,
},
{
name: "Scheduler",
path: "/scheduler",
component: SettingsSchedulerView,
},
{
name: "UI",
path: "/ui",
component: SettingsUIView,
},
],
},
{
icon: faLaptop,
name: "System",
path: "/system",
routes: [
{
name: "Tasks",
path: "/tasks",
component: SystemTasksView,
},
{
name: "Logs",
path: "/logs",
component: SystemLogsView,
},
{
name: "Providers",
path: "/providers",
badge: providers,
component: SystemProvidersView,
},
{
name: "Status",
path: "/status",
component: SystemStatusView,
},
{
name: "Releases",
path: "/releases",
component: SystemReleasesView,
},
],
},
],
[episodes, movies, providers, radarr, sonarr]
);
return items;
}

26
frontend/src/Navigation/nav.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FunctionComponent } from "react";
export declare namespace Navigation {
type RouteWithoutChild = {
icon?: IconDefinition;
name: string;
path: string;
component: FunctionComponent;
badge?: number;
enabled?: boolean;
routeOnly?: boolean;
};
type RouteWithChild = {
icon: IconDefinition;
name: string;
path: string;
component?: FunctionComponent;
badge?: number;
enabled?: boolean;
routes: RouteWithoutChild[];
};
type RouteItem = RouteWithChild | RouteWithoutChild;
}

View File

@ -0,0 +1,83 @@
import { FunctionComponent } from "react";
import { Redirect, Route, Switch, useHistory } from "react-router";
import { useDidMount } from "rooks";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
import { RouterEmptyPath } from "../special-pages/404";
import { BuildKey, ScrollToTop } from "../utilities";
const Router: FunctionComponent = () => {
const navItems = useNavigationItems();
const history = useHistory();
useDidMount(() => {
history.listen(() => {
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
setTimeout(ScrollToTop);
});
});
return (
<div className="d-flex flex-row flex-grow-1 main-router">
<Switch>
{navItems.map((v, idx) => {
if ("routes" in v) {
return (
<Route path={v.path} key={BuildKey(idx, v.name, "router")}>
<ParentRouter {...v}></ParentRouter>
</Route>
);
} else if (v.enabled !== false) {
return (
<Route
key={BuildKey(idx, v.name, "root")}
exact
path={v.path}
component={v.component}
></Route>
);
} else {
return null;
}
})}
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
</div>
);
};
export default Router;
const ParentRouter: FunctionComponent<Navigation.RouteWithChild> = ({
path,
enabled,
component,
routes,
}) => {
if (enabled === false || (component === undefined && routes.length === 0)) {
return null;
}
const ParentComponent =
component ?? (() => <Redirect to={path + routes[0].path}></Redirect>);
return (
<Switch>
<Route exact path={path} component={ParentComponent}></Route>
{routes
.filter((v) => v.enabled !== false)
.map((v, idx) => (
<Route
key={BuildKey(idx, v.name, "route")}
exact
path={path + v.path}
component={v.component}
></Route>
))}
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};

View File

@ -70,6 +70,18 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
skip_wrong_fps: "Skip Wrong FPS", skip_wrong_fps: "Skip Wrong FPS",
}, },
}, },
{
key: "ktuvit",
name: "Ktuvit",
description: "Hebrew Subtitles Provider",
defaultKey: {
email: "",
hashed_password: ""
},
keyNameOverride: {
hashed_password: "Hashed Password",
},
},
{ {
key: "legendastv", key: "legendastv",
name: "LegendasTV", name: "LegendasTV",

View File

@ -1,58 +0,0 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { useSetSidebar } from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import General from "./General";
import Languages from "./Languages";
import Notifications from "./Notifications";
import Providers from "./Providers";
import Radarr from "./Radarr";
import Scheduler from "./Scheduler";
import Sonarr from "./Sonarr";
import Subtitles from "./Subtitles";
import UI from "./UI";
interface Props {}
const Router: FunctionComponent<Props> = () => {
useSetSidebar("Settings");
return (
<Switch>
<Route exact path="/settings">
<Redirect exact to="/settings/general"></Redirect>
</Route>
<Route exact path="/settings/general">
<General></General>
</Route>
<Route exact path="/settings/ui">
<UI></UI>
</Route>
<Route exact path="/settings/sonarr">
<Sonarr></Sonarr>
</Route>
<Route exact path="/settings/radarr">
<Radarr></Radarr>
</Route>
<Route exact path="/settings/languages">
<Languages></Languages>
</Route>
<Route exact path="/settings/subtitles">
<Subtitles></Subtitles>
</Route>
<Route exact path="/settings/scheduler">
<Scheduler></Scheduler>
</Route>
<Route exact path="/settings/providers">
<Providers></Providers>
</Route>
<Route exact path="/settings/notifications">
<Notifications></Notifications>
</Route>
<Route path="/settings/*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

View File

@ -1,86 +1,56 @@
import React, { FunctionComponent, useContext, useMemo } from "react"; import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { Container, Image, ListGroup } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useReduxStore } from "../@redux/hooks/base"; import React, {
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site"; createContext,
import logo from "../@static/logo64.png"; FunctionComponent,
import { SidebarToggleContext } from "../App"; useContext,
import { useGotoHomepage } from "../utilities/hooks"; useMemo,
useState,
} from "react";
import { import {
BadgesContext, Badge,
CollapseItem, Collapse,
HiddenKeysContext, Container,
LinkItem, Image,
} from "./items"; ListGroup,
import { RadarrDisabledKey, SidebarList, SonarrDisabledKey } from "./list"; ListGroupItem,
} from "react-bootstrap";
import { NavLink, useHistory, useRouteMatch } from "react-router-dom";
import { siteChangeSidebarVisibility } from "../@redux/actions";
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
import logo from "../@static/logo64.png";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
import { BuildKey } from "../utilities";
import { useGotoHomepage } from "../utilities/hooks";
import "./style.scss"; import "./style.scss";
import { BadgeProvider } from "./types";
interface Props { const SelectionContext = createContext<{
open?: boolean; selection: string | null;
} select: (selection: string | null) => void;
}>({ selection: null, select: () => {} });
const Sidebar: FunctionComponent<Props> = ({ open }) => { const Sidebar: FunctionComponent = () => {
const toggle = useContext(SidebarToggleContext); const open = useReduxStore((s) => s.site.showSidebar);
const { movies, episodes, providers, status } = useReduxStore( const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
(s) => s.site.badges
);
const sonarrEnabled = useIsSonarrEnabled();
const radarrEnabled = useIsRadarrEnabled();
const badges = useMemo<BadgeProvider>(
() => ({
Wanted: {
Series: sonarrEnabled ? episodes : 0,
Movies: radarrEnabled ? movies : 0,
},
System: {
Providers: providers,
Status: status,
},
}),
[movies, episodes, providers, sonarrEnabled, radarrEnabled, status]
);
const hiddenKeys = useMemo<string[]>(() => {
const list = [];
if (!sonarrEnabled) {
list.push(SonarrDisabledKey);
}
if (!radarrEnabled) {
list.push(RadarrDisabledKey);
}
return list;
}, [sonarrEnabled, radarrEnabled]);
const cls = ["sidebar-container"]; const cls = ["sidebar-container"];
const overlay = ["sidebar-overlay"]; const overlay = ["sidebar-overlay"];
if (open === true) { if (open) {
cls.push("open"); cls.push("open");
overlay.push("open"); overlay.push("open");
} }
const sidebarItems = useMemo(
() =>
SidebarList.map((v) => {
if (hiddenKeys.includes(v.hiddenKey ?? "")) {
return null;
}
if ("children" in v) {
return <CollapseItem key={v.name} {...v}></CollapseItem>;
} else {
return <LinkItem key={v.link} {...v}></LinkItem>;
}
}),
[hiddenKeys]
);
const goHome = useGotoHomepage(); const goHome = useGotoHomepage();
const [selection, setSelection] = useState<string | null>(null);
return ( return (
<React.Fragment> <SelectionContext.Provider
value={{ selection: selection, select: setSelection }}
>
<aside className={cls.join(" ")}> <aside className={cls.join(" ")}>
<Container className="sidebar-title d-flex align-items-center d-md-none"> <Container className="sidebar-title d-flex align-items-center d-md-none">
<Image <Image
@ -92,13 +62,184 @@ const Sidebar: FunctionComponent<Props> = ({ open }) => {
className="cursor-pointer" className="cursor-pointer"
></Image> ></Image>
</Container> </Container>
<HiddenKeysContext.Provider value={hiddenKeys}> <SidebarNavigation></SidebarNavigation>
<BadgesContext.Provider value={badges}>
<ListGroup variant="flush">{sidebarItems}</ListGroup>
</BadgesContext.Provider>
</HiddenKeysContext.Provider>
</aside> </aside>
<div className={overlay.join(" ")} onClick={toggle}></div> <div
className={overlay.join(" ")}
onClick={() => changeSidebar(false)}
></div>
</SelectionContext.Provider>
);
};
const SidebarNavigation: FunctionComponent = () => {
const navItems = useNavigationItems();
return (
<ListGroup variant="flush">
{navItems.map((v, idx) => {
if ("routes" in v) {
return (
<SidebarParent key={BuildKey(idx, v.name)} {...v}></SidebarParent>
);
} else {
return (
<SidebarChild
parent=""
key={BuildKey(idx, v.name)}
{...v}
></SidebarChild>
);
}
})}
</ListGroup>
);
};
const SidebarParent: FunctionComponent<Navigation.RouteWithChild> = ({
icon,
badge,
name,
path,
routes,
enabled,
component,
}) => {
const computedBadge = useMemo(() => {
let computed = badge ?? 0;
computed += routes.reduce((prev, curr) => {
return prev + (curr.badge ?? 0);
}, 0);
return computed !== 0 ? computed : undefined;
}, [badge, routes]);
const enabledRoutes = useMemo(
() => routes.filter((v) => v.enabled !== false && v.routeOnly !== true),
[routes]
);
const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
const { selection, select } = useContext(SelectionContext);
const match = useRouteMatch({ path });
const open = match !== null || selection === path;
const collapseBoxClass = useMemo(
() => `sidebar-collapse-box ${open ? "active" : ""}`,
[open]
);
const history = useHistory();
if (enabled === false) {
return null;
} else if (enabledRoutes.length === 0) {
if (component) {
return (
<NavLink
activeClassName="sb-active"
className="list-group-item list-group-item-action sidebar-button"
to={path}
onClick={() => changeSidebar(false)}
>
<SidebarContent
icon={icon}
name={name}
badge={computedBadge}
></SidebarContent>
</NavLink>
);
} else {
return null;
}
}
return (
<div className={collapseBoxClass}>
<ListGroupItem
action
className="sidebar-button"
onClick={() => {
if (open) {
select(null);
} else {
select(path);
}
if (component !== undefined) {
history.push(path);
}
}}
>
<SidebarContent
icon={icon}
name={name}
badge={computedBadge}
></SidebarContent>
</ListGroupItem>
<Collapse in={open}>
<div className="sidebar-collapse">
{enabledRoutes.map((v, idx) => (
<SidebarChild
key={BuildKey(idx, v.name, "child")}
parent={path}
{...v}
></SidebarChild>
))}
</div>
</Collapse>
</div>
);
};
interface SidebarChildProps {
parent: string;
}
const SidebarChild: FunctionComponent<
SidebarChildProps & Navigation.RouteWithoutChild
> = ({ icon, name, path, badge, enabled, routeOnly, parent }) => {
const changeSidebar = useReduxAction(siteChangeSidebarVisibility);
const { select } = useContext(SelectionContext);
if (enabled === false || routeOnly === true) {
return null;
}
return (
<NavLink
activeClassName="sb-active"
className="list-group-item list-group-item-action sidebar-button sb-collapse"
to={parent + path}
onClick={() => {
select(null);
changeSidebar(false);
}}
>
<SidebarContent icon={icon} name={name} badge={badge}></SidebarContent>
</NavLink>
);
};
const SidebarContent: FunctionComponent<{
icon?: IconDefinition;
name: string;
badge?: number;
}> = ({ icon, name, badge }) => {
return (
<React.Fragment>
{icon && (
<FontAwesomeIcon
size="1x"
className="icon"
icon={icon}
></FontAwesomeIcon>
)}
<span className="d-flex flex-grow-1 justify-content-between">
{name} <Badge variant="secondary">{badge !== 0 ? badge : null}</Badge>
</span>
</React.Fragment> </React.Fragment>
); );
}; };

View File

@ -1,179 +0,0 @@
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useContext, useMemo } from "react";
import { Badge, Collapse, ListGroupItem } from "react-bootstrap";
import { NavLink } from "react-router-dom";
import { siteChangeSidebar } from "../@redux/actions";
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
import { SidebarToggleContext } from "../App";
import {
BadgeProvider,
ChildBadgeProvider,
CollapseItemType,
LinkItemType,
} from "./types";
export const HiddenKeysContext = React.createContext<string[]>([]);
export const BadgesContext = React.createContext<BadgeProvider>({});
function useToggleSidebar() {
return useReduxAction(siteChangeSidebar);
}
function useSidebarKey() {
return useReduxStore((s) => s.site.sidebar);
}
export const LinkItem: FunctionComponent<LinkItemType> = ({
link,
name,
icon,
}) => {
const badges = useContext(BadgesContext);
const toggle = useContext(SidebarToggleContext);
const badgeValue = useMemo(() => {
let badge: Nullable<number> = null;
if (name in badges) {
let item = badges[name];
if (typeof item === "number") {
badge = item;
}
}
return badge;
}, [badges, name]);
return (
<NavLink
activeClassName="sb-active"
className="list-group-item list-group-item-action sidebar-button"
to={link}
onClick={toggle}
>
<DisplayItem
badge={badgeValue ?? undefined}
name={name}
icon={icon}
></DisplayItem>
</NavLink>
);
};
export const CollapseItem: FunctionComponent<CollapseItemType> = ({
icon,
name,
children,
}) => {
const badges = useContext(BadgesContext);
const hiddenKeys = useContext(HiddenKeysContext);
const toggleSidebar = useContext(SidebarToggleContext);
const sidebarKey = useSidebarKey();
const updateSidebar = useToggleSidebar();
const [badgeValue, childValue] = useMemo<
[Nullable<number>, Nullable<ChildBadgeProvider>]
>(() => {
let badge: Nullable<number> = null;
let child: Nullable<ChildBadgeProvider> = null;
if (name in badges) {
const item = badges[name];
if (typeof item === "number") {
badge = item;
} else if (typeof item === "object") {
badge = 0;
child = item;
for (const it in item) {
badge += item[it];
}
}
}
return [badge, child];
}, [badges, name]);
const active = useMemo(() => sidebarKey === name, [sidebarKey, name]);
const collapseBoxClass = useMemo(
() => `sidebar-collapse-box ${active ? "active" : ""}`,
[active]
);
const childrenElems = useMemo(
() =>
children
.filter((v) => !hiddenKeys.includes(v.hiddenKey ?? ""))
.map((ch) => {
let badge: Nullable<number> = null;
if (childValue && ch.name in childValue) {
badge = childValue[ch.name];
}
return (
<NavLink
key={ch.name}
activeClassName="sb-active"
className="list-group-item list-group-item-action sidebar-button sb-collapse"
to={ch.link}
onClick={toggleSidebar}
>
<DisplayItem
badge={badge === 0 ? undefined : badge ?? undefined}
name={ch.name}
></DisplayItem>
</NavLink>
);
}),
[children, hiddenKeys, childValue, toggleSidebar]
);
if (childrenElems.length === 0) {
return null;
}
return (
<div className={collapseBoxClass}>
<ListGroupItem
action
className="sidebar-button"
onClick={() => {
if (active) {
updateSidebar("");
} else {
updateSidebar(name);
}
}}
>
<DisplayItem
badge={badgeValue === 0 ? undefined : badgeValue ?? undefined}
icon={icon}
name={name}
></DisplayItem>
</ListGroupItem>
<Collapse in={active}>
<div className="sidebar-collapse">{childrenElems}</div>
</Collapse>
</div>
);
};
interface DisplayProps {
name: string;
icon?: IconDefinition;
badge?: number;
}
const DisplayItem: FunctionComponent<DisplayProps> = ({
name,
icon,
badge,
}) => (
<React.Fragment>
{icon && (
<FontAwesomeIcon size="1x" className="icon" icon={icon}></FontAwesomeIcon>
)}
<span className="d-flex flex-grow-1 justify-content-between">
{name} <Badge variant="secondary">{badge}</Badge>
</span>
</React.Fragment>
);

View File

@ -1,148 +0,0 @@
import {
faClock,
faCogs,
faExclamationTriangle,
faFileExcel,
faFilm,
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
import { SidebarDefinition } from "./types";
export const SonarrDisabledKey = "sonarr-disabled";
export const RadarrDisabledKey = "radarr-disabled";
export const SidebarList: SidebarDefinition[] = [
{
icon: faPlay,
name: "Series",
link: "/series",
hiddenKey: SonarrDisabledKey,
},
{
icon: faFilm,
name: "Movies",
link: "/movies",
hiddenKey: RadarrDisabledKey,
},
{
icon: faClock,
name: "History",
children: [
{
name: "Series",
link: "/history/series",
hiddenKey: SonarrDisabledKey,
},
{
name: "Movies",
link: "/history/movies",
hiddenKey: RadarrDisabledKey,
},
{
name: "Statistics",
link: "/history/stats",
},
],
},
{
icon: faFileExcel,
name: "Blacklist",
children: [
{
name: "Series",
link: "/blacklist/series",
hiddenKey: SonarrDisabledKey,
},
{
name: "Movies",
link: "/blacklist/movies",
hiddenKey: RadarrDisabledKey,
},
],
},
{
icon: faExclamationTriangle,
name: "Wanted",
children: [
{
name: "Series",
link: "/wanted/series",
hiddenKey: SonarrDisabledKey,
},
{
name: "Movies",
link: "/wanted/movies",
hiddenKey: RadarrDisabledKey,
},
],
},
{
icon: faCogs,
name: "Settings",
children: [
{
name: "General",
link: "/settings/general",
},
{
name: "Languages",
link: "/settings/languages",
},
{
name: "Providers",
link: "/settings/providers",
},
{
name: "Subtitles",
link: "/settings/subtitles",
},
{
name: "Sonarr",
link: "/settings/sonarr",
},
{
name: "Radarr",
link: "/settings/radarr",
},
{
name: "Notifications",
link: "/settings/notifications",
},
{
name: "Scheduler",
link: "/settings/scheduler",
},
{
name: "UI",
link: "/settings/ui",
},
],
},
{
icon: faLaptop,
name: "System",
children: [
{
name: "Tasks",
link: "/system/tasks",
},
{
name: "Logs",
link: "/system/logs",
},
{
name: "Providers",
link: "/system/providers",
},
{
name: "Status",
link: "/system/status",
},
{
name: "Releases",
link: "/system/releases",
},
],
},
];

View File

@ -1,29 +0,0 @@
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
type SidebarDefinition = LinkItemType | CollapseItemType;
type BaseSidebar = {
icon: IconDefinition;
name: string;
hiddenKey?: string;
};
type LinkItemType = BaseSidebar & {
link: string;
};
type CollapseItemType = BaseSidebar & {
children: {
name: string;
link: string;
hiddenKey?: string;
}[];
};
type BadgeProvider = {
[parent: string]: ChildBadgeProvider | number;
};
type ChildBadgeProvider = {
[child: string]: number;
};

View File

@ -7,7 +7,7 @@ import { BuildKey } from "../../utilities";
interface Props {} interface Props {}
const ReleasesView: FunctionComponent<Props> = () => { const SystemReleasesView: FunctionComponent<Props> = () => {
const releases = useSystemReleases(); const releases = useSystemReleases();
return ( return (
@ -32,25 +32,6 @@ const ReleasesView: FunctionComponent<Props> = () => {
</Row> </Row>
</Container> </Container>
); );
// return (
// <AsyncStateOverlay state={releases}>
// {({ data }) => (
// <Container fluid className="px-5 py-4 bg-light">
// <Helmet>
// <title>Releases - Bazarr (System)</title>
// </Helmet>
// <Row>
// {data.map((v, idx) => (
// <Col xs={12} key={BuildKey(idx, v.date)}>
// <InfoElement {...v}></InfoElement>
// </Col>
// ))}
// </Row>
// </Container>
// )}
// </AsyncStateOverlay>
// );
}; };
const headerBadgeCls = "mr-2"; const headerBadgeCls = "mr-2";
@ -95,4 +76,4 @@ const InfoElement: FunctionComponent<ReleaseInfo> = ({
); );
}; };
export default ReleasesView; export default SystemReleasesView;

View File

@ -1,37 +0,0 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { useSetSidebar } from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import Logs from "./Logs";
import Providers from "./Providers";
import Releases from "./Releases";
import Status from "./Status";
import Tasks from "./Tasks";
const Router: FunctionComponent = () => {
useSetSidebar("System");
return (
<Switch>
<Route exact path="/system/tasks">
<Tasks></Tasks>
</Route>
<Route exact path="/system/status">
<Status></Status>
</Route>
<Route exact path="/system/providers">
<Providers></Providers>
</Route>
<Route exact path="/system/logs">
<Logs></Logs>
</Route>
<Route exact path="/system/releases">
<Releases></Releases>
</Route>
<Route path="/system/*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

View File

@ -1,36 +0,0 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import {
useIsRadarrEnabled,
useIsSonarrEnabled,
useSetSidebar,
} from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import Movies from "./Movies";
import Series from "./Series";
const Router: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
useSetSidebar("Wanted");
return (
<Switch>
{sonarr && (
<Route exact path="/wanted/series">
<Series></Series>
</Route>
)}
{radarr && (
<Route exact path="/wanted/movies">
<Movies></Movies>
</Route>
)}
<Route path="/wanted/*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

View File

@ -6,7 +6,6 @@ import React, {
MouseEvent, MouseEvent,
PropsWithChildren, PropsWithChildren,
useCallback, useCallback,
useRef,
useState, useState,
} from "react"; } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
@ -59,16 +58,13 @@ export function ContentHeaderAsyncButton<T extends () => Promise<any>>(
const [updating, setUpdate] = useState(false); const [updating, setUpdate] = useState(false);
const promiseRef = useRef(promise);
const successRef = useRef(onSuccess);
const click = useCallback(() => { const click = useCallback(() => {
setUpdate(true); setUpdate(true);
promiseRef.current().then((val) => { promise().then((val) => {
setUpdate(false); setUpdate(false);
successRef.current && successRef.current(val); onSuccess && onSuccess(val);
}); });
}, [successRef, promiseRef]); }, [onSuccess, promise]);
return ( return (
<ContentHeaderButton <ContentHeaderButton

View File

@ -26,17 +26,15 @@ export const Chips: FunctionComponent<ChipsProps> = ({
const input = useRef<HTMLInputElement>(null); const input = useRef<HTMLInputElement>(null);
const changeRef = useRef(onChange);
const addChip = useCallback( const addChip = useCallback(
(value: string) => { (value: string) => {
setChips((cp) => { setChips((cp) => {
const newChips = [...cp, value]; const newChips = [...cp, value];
changeRef.current && changeRef.current(newChips); onChange && onChange(newChips);
return newChips; return newChips;
}); });
}, },
[changeRef] [onChange]
); );
const removeChip = useCallback( const removeChip = useCallback(
@ -46,14 +44,14 @@ export const Chips: FunctionComponent<ChipsProps> = ({
if (index !== -1) { if (index !== -1) {
const newChips = [...cp]; const newChips = [...cp];
newChips.splice(index, 1); newChips.splice(index, 1);
changeRef.current && changeRef.current(newChips); onChange && onChange(newChips);
return newChips; return newChips;
} else { } else {
return cp; return cp;
} }
}); });
}, },
[changeRef] [onChange]
); );
const clearInput = useCallback(() => { const clearInput = useCallback(() => {

View File

@ -49,22 +49,27 @@ class Addic7edSubtitle(Subtitle):
def get_matches(self, video): def get_matches(self, video):
matches = set() matches = set()
# series name if isinstance(video, Episode):
if video.series and sanitize(self.series) in ( # series name
sanitize(name) for name in [video.series] + video.alternative_series): if video.series and sanitize(self.series) in (
matches.add('series') sanitize(name) for name in [video.series] + video.alternative_series):
# season matches.add('series')
if video.season and self.season == video.season: # season
matches.add('season') if video.season and self.season == video.season:
# episode matches.add('season')
if video.episode and self.episode == video.episode: # episode
matches.add('episode') if video.episode and self.episode == video.episode:
matches.add('episode')
# year
if video.original_series and self.year is None or video.year and video.year == self.year:
matches.add('year')
else:
# year
if video.year and video.year == self.year:
matches.add('year')
# title of the episode # title of the episode
if video.title and sanitize(self.title) == sanitize(video.title): if video.title and sanitize(self.title) == sanitize(video.title):
matches.add('title') matches.add('title')
# year
if video.original_series and self.year is None or video.year and video.year == self.year:
matches.add('year')
# release_group # release_group
if (video.release_group and self.version and if (video.release_group and self.version and
any(r in sanitize_release_group(self.version) any(r in sanitize_release_group(self.version)

View File

@ -6,10 +6,12 @@ import subliminal
import time import time
from random import randint from random import randint
from urllib.parse import quote_plus
from dogpile.cache.api import NO_VALUE from dogpile.cache.api import NO_VALUE
from requests import Session from requests import Session
from subliminal.cache import region from subliminal.cache import region
from subliminal.video import Episode, Movie
from subliminal.exceptions import DownloadLimitExceeded, AuthenticationError, ConfigurationError from subliminal.exceptions import DownloadLimitExceeded, AuthenticationError, ConfigurationError
from subliminal.providers.addic7ed import Addic7edProvider as _Addic7edProvider, \ from subliminal.providers.addic7ed import Addic7edProvider as _Addic7edProvider, \
Addic7edSubtitle as _Addic7edSubtitle, ParserBeautifulSoup Addic7edSubtitle as _Addic7edSubtitle, ParserBeautifulSoup
@ -25,16 +27,18 @@ logger = logging.getLogger(__name__)
series_year_re = re.compile(r'^(?P<series>[ \w\'.:(),*&!?-]+?)(?: \((?P<year>\d{4})\))?$') series_year_re = re.compile(r'^(?P<series>[ \w\'.:(),*&!?-]+?)(?: \((?P<year>\d{4})\))?$')
SHOW_EXPIRATION_TIME = datetime.timedelta(weeks=1).total_seconds() SHOW_EXPIRATION_TIME = datetime.timedelta(weeks=1).total_seconds()
MOVIE_EXPIRATION_TIME = datetime.timedelta(weeks=1).total_seconds()
class Addic7edSubtitle(_Addic7edSubtitle): class Addic7edSubtitle(_Addic7edSubtitle):
hearing_impaired_verifiable = True hearing_impaired_verifiable = True
def __init__(self, language, hearing_impaired, page_link, series, season, episode, title, year, version, def __init__(self, language, hearing_impaired, page_link, series, season, episode, title, year, version,
download_link): download_link, uploader=None):
super(Addic7edSubtitle, self).__init__(language, hearing_impaired, page_link, series, season, episode, super(Addic7edSubtitle, self).__init__(language, hearing_impaired, page_link, series, season, episode,
title, year, version, download_link) title, year, version, download_link)
self.release_info = version.replace('+', ',') self.release_info = version.replace('+', ',')
self.uploader = uploader
def get_matches(self, video): def get_matches(self, video):
matches = super(Addic7edSubtitle, self).get_matches(video) matches = super(Addic7edSubtitle, self).get_matches(video)
@ -63,6 +67,7 @@ class Addic7edProvider(_Addic7edProvider):
]} | {Language.fromietf(l) for l in ["sr-Latn", "sr-Cyrl"]} ]} | {Language.fromietf(l) for l in ["sr-Latn", "sr-Cyrl"]}
languages.update(set(Language.rebuild(l, hi=True) for l in languages)) languages.update(set(Language.rebuild(l, hi=True) for l in languages))
video_types = (Episode, Movie)
USE_ADDICTED_RANDOM_AGENTS = False USE_ADDICTED_RANDOM_AGENTS = False
hearing_impaired_verifiable = True hearing_impaired_verifiable = True
subtitle_class = Addic7edSubtitle subtitle_class = Addic7edSubtitle
@ -218,6 +223,52 @@ class Addic7edProvider(_Addic7edProvider):
return show_id return show_id
@region.cache_on_arguments(expiration_time=MOVIE_EXPIRATION_TIME)
def get_movie_id(self, movie, year=None):
"""Get the best matching movie id for `movie`, `year`.
:param str movie: movie.
:param year: year of the movie, if any.
:type year: int
:return: the movie id, if found.
:rtype: int
"""
movie_id = None
# get the movie id
logger.info('Getting movie id')
r = self.session.get(self.server_url + 'search.php?search=' + quote_plus(movie), timeout=60)
r.raise_for_status()
soup = ParserBeautifulSoup(r.content.decode('utf-8', 'ignore'), ['lxml', 'html.parser'])
# populate the movie id
movies_table = soup.find('table', {'class': 'tabel'})
movies = movies_table.find_all('tr')
for item in movies:
link = item.find('a', href=True)
if link:
type, media_id = link['href'].split('/')
if type == 'movie':
media_title = link.text
match = re.search(r'(.+)\s\((\d{4})\)$', media_title)
if match:
media_name = match.group(1)
media_year = match.group(2)
if sanitize(media_name.lower()) == sanitize(movie.lower()) and media_year == str(year):
movie_id = media_id
soup.decompose()
soup = None
logger.debug(f'Found this movie id: {movie_id}')
if not movie_id:
logging.debug(f"Addic7ed: Cannot find this movie with guessed year {year}: {movie}")
return movie_id
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME) @region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
def _get_show_ids(self): def _get_show_ids(self):
"""Get the ``dict`` of show ids per series by querying the `shows.php` page. """Get the ``dict`` of show ids per series by querying the `shows.php` page.
@ -390,6 +441,110 @@ class Addic7edProvider(_Addic7edProvider):
return subtitles return subtitles
def query_movie(self, movie_id, title, year=None):
# get the page of the movie
logger.info('Getting the page of movie id %d', movie_id)
r = self.session.get(self.server_url + 'movie/' + movie_id,
timeout=60,
headers={
"referer": self.server_url,
"X-Requested-With": "XMLHttpRequest"
}
)
r.raise_for_status()
if r.status_code == 304:
raise TooManyRequests()
if not r.text:
# Provider wrongful return a status of 304 Not Modified with an empty content
# raise_for_status won't raise exception for that status code
logger.error('No data returned from provider')
return []
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
# loop over subtitle rows
tables = []
subtitles = []
for table in soup.find_all('table', {'align': 'center',
'border': '0',
'class': 'tabel95',
'width': '100%'}):
if table.find_all('td', {'class': 'NewsTitle'}):
tables.append(table)
for table in tables:
row1 = table.contents[1]
row2 = table.contents[4]
row3 = table.contents[6]
# other rows are useless
# ignore incomplete subtitles
status = row2.contents[6].text
if "%" in status:
logger.debug('Ignoring subtitle with status %s', status)
continue
# read the item
language = Language.fromaddic7ed(row2.contents[4].text.strip('\n'))
hearing_impaired = bool(row3.contents[1].contents[1].attrs['src'].endswith('hi.jpg'))
page_link = self.server_url + 'movie/' + movie_id
version_matches = re.search(r'Version\s(.+),.+', str(row1.contents[1].contents[1]))
version = version_matches.group(1) if version_matches else None
download_link = row2.contents[8].contents[2].attrs['href'][1:]
uploader = row1.contents[2].contents[8].text.strip()
# set subtitle language to hi if it's hearing_impaired
if hearing_impaired:
language = Language.rebuild(language, hi=True)
subtitle = self.subtitle_class(language, hearing_impaired, page_link, None, None, None, title, year,
version, download_link, uploader)
logger.debug('Found subtitle %r', subtitle)
subtitles.append(subtitle)
soup.decompose()
soup = None
return subtitles
def list_subtitles(self, video, languages):
if isinstance(video, Episode):
# lookup show_id
titles = [video.series] + video.alternative_series[5:]
show_id = None
for title in titles:
show_id = self.get_show_id(title, video.year)
if show_id is not None:
break
# query for subtitles with the show_id
if show_id is not None:
subtitles = [s for s in self.query(show_id, title, video.season, video.year)
if s.language in languages and s.episode == video.episode]
if subtitles:
return subtitles
else:
logger.error('No show id found for %r (%r)', video.series, {'year': video.year})
else:
titles = [video.title] + video.alternative_titles[5:]
for title in titles:
movie_id = self.get_movie_id(title, video.year)
if movie_id is not None:
break
# query for subtitles with the movie_id
if movie_id is not None:
subtitles = [s for s in self.query_movie(movie_id, title, video.year) if s.language in languages]
if subtitles:
return subtitles
else:
logger.error('No movie id found for %r (%r)', video.title, {'year': video.year})
return []
def download_subtitle(self, subtitle): def download_subtitle(self, subtitle):
# download the subtitle # download the subtitle
r = self.session.get(self.server_url + subtitle.download_link, headers={'Referer': subtitle.page_link}, r = self.session.get(self.server_url + subtitle.download_link, headers={'Referer': subtitle.page_link},

View File

@ -0,0 +1,452 @@
# -*- coding: utf-8 -*-
import io
import logging
import os
import json
from subzero.language import Language
from guessit import guessit
from requests import Session
from subliminal.providers import ParserBeautifulSoup
from subliminal_patch.providers import Provider
from subliminal_patch.subtitle import Subtitle
from subliminal.subtitle import fix_line_ending
from subliminal import __short_version__
from subliminal.cache import SHOW_EXPIRATION_TIME, region
from subliminal.exceptions import AuthenticationError, ConfigurationError
from subliminal_patch.subtitle import guess_matches
from subliminal_patch.utils import sanitize
from subliminal.video import Episode, Movie
logger = logging.getLogger(__name__)
class KtuvitSubtitle(Subtitle):
"""Ktuvit Subtitle."""
provider_name = "ktuvit"
def __init__(
self,
language,
hearing_impaired,
page_link,
series,
season,
episode,
title,
imdb_id,
ktuvit_id,
subtitle_id,
release,
):
super(KtuvitSubtitle, self).__init__(language, hearing_impaired, page_link)
self.series = series
self.season = season
self.episode = episode
self.title = title
self.imdb_id = imdb_id
self.ktuvit_id = ktuvit_id
self.subtitle_id = subtitle_id
self.release = release
@property
def id(self):
return str(self.subtitle_id)
@property
def release_info(self):
return self.release
def get_matches(self, video):
matches = set()
# episode
if isinstance(video, Episode):
# series
if video.series and (
sanitize(self.title)
in (
sanitize(name) for name in [video.series] + video.alternative_series
)
):
matches.add("series")
# season
if video.season and self.season == video.season:
matches.add("season")
# episode
if video.episode and self.episode == video.episode:
matches.add("episode")
# imdb_id
if video.series_imdb_id and self.imdb_id == video.series_imdb_id:
matches.add("series_imdb_id")
# guess
matches |= guess_matches(video, guessit(self.release, {"type": "episode"}))
# movie
elif isinstance(video, Movie):
# guess
matches |= guess_matches(video, guessit(self.release, {"type": "movie"}))
# title
if video.title and (
sanitize(self.title)
in (sanitize(name) for name in [video.title] + video.alternative_titles)
):
matches.add("title")
return matches
class KtuvitProvider(Provider):
"""Ktuvit Provider."""
languages = {Language(l) for l in ["heb"]}
server_url = "https://www.ktuvit.me/"
sign_in_url = "Services/MembershipService.svc/Login"
search_url = "Services/ContentProvider.svc/SearchPage_search"
movie_info_url = "MovieInfo.aspx?ID="
episode_info_url = "Services/GetModuleAjax.ashx?"
request_download_id_url = "Services/ContentProvider.svc/RequestSubtitleDownload"
download_link = "Services/DownloadFile.ashx?DownloadIdentifier="
subtitle_class = KtuvitSubtitle
_tmdb_api_key = "a51ee051bcd762543373903de296e0a3"
def __init__(self, email=None, hashed_password=None):
if any((email, hashed_password)) and not all((email, hashed_password)):
raise ConfigurationError("Email and Hashed Password must be specified")
self.email = email
self.hashed_password = hashed_password
self.logged_in = False
self.session = None
self.loginCookie = None
def initialize(self):
self.session = Session()
# login
if self.email and self.hashed_password:
logger.info("Logging in")
data = {"request": {"Email": self.email, "Password": self.hashed_password}}
self.session.headers['Accept-Encoding'] = 'gzip'
self.session.headers['Accept-Language'] = 'en-us,en;q=0.5'
self.session.headers['Pragma'] = 'no-cache'
self.session.headers['Cache-Control'] = 'no-cache'
self.session.headers['Content-Type'] = 'application/json'
self.session.headers['User-Agent']: os.environ.get("SZ_USER_AGENT", "Sub-Zero/2")
r = self.session.post(
self.server_url + self.sign_in_url,
json=data,
allow_redirects=False,
timeout=10,
)
if r.content:
try:
responseContent = r.json()
except json.decoder.JSONDecodeError:
AuthenticationError("Unable to parse JSON return while authenticating to the provider.")
else:
isSuccess = False
if 'd' in responseContent:
responseContent = json.loads(responseContent['d'])
isSuccess = responseContent.get('IsSuccess', False)
if not isSuccess:
AuthenticationError("ErrorMessage: " + responseContent['d'].get("ErrorMessage", "[None]"))
else:
AuthenticationError("Incomplete JSON returned while authenticating to the provider.")
logger.debug("Logged in")
self.loginCookie = (
r.headers["set-cookie"][1].split(";")[0].replace("Login=", "")
)
self.session.headers["Accept"]="application/json, text/javascript, */*; q=0.01"
self.session.headers["Cookie"]="Login=" + self.loginCookie
self.logged_in = True
def terminate(self):
self.session.close()
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
def _search_imdb_id(self, title, year, is_movie):
"""Search the IMDB ID for the given `title` and `year`.
:param str title: title to search for.
:param int year: year to search for (or 0 if not relevant).
:param bool is_movie: If True, IMDB ID will be searched for in TMDB instead of Wizdom.
:return: the IMDB ID for the given title and year (or None if not found).
:rtype: str
"""
# make the search
logger.info(
"Searching IMDB ID for %r%r",
title,
"" if not year else " ({})".format(year),
)
category = "movie" if is_movie else "tv"
title = title.replace("'", "")
# get TMDB ID first
r = self.session.get(
"http://api.tmdb.org/3/search/{}?api_key={}&query={}{}&language=en".format(
category,
self._tmdb_api_key,
title,
"" if not year else "&year={}".format(year),
)
)
r.raise_for_status()
tmdb_results = r.json().get("results")
if tmdb_results:
tmdb_id = tmdb_results[0].get("id")
if tmdb_id:
# get actual IMDB ID from TMDB
r = self.session.get(
"http://api.tmdb.org/3/{}/{}{}?api_key={}&language=en".format(
category,
tmdb_id,
"" if is_movie else "/external_ids",
self._tmdb_api_key,
)
)
r.raise_for_status()
imdb_id = r.json().get("imdb_id")
if imdb_id:
return str(imdb_id)
else:
return None
return None
def query(
self, title, season=None, episode=None, year=None, filename=None, imdb_id=None
):
# search for the IMDB ID if needed.
is_movie = not (season and episode)
imdb_id = imdb_id or self._search_imdb_id(title, year, is_movie)
if not imdb_id:
return {}
# search
logger.debug("Using IMDB ID %r", imdb_id)
query = {
"FilmName": title,
"Actors": [],
"Studios": [],
"Directors": [],
"Genres": [],
"Countries": [],
"Languages": [],
"Year": "",
"Rating": [],
"Page": 1,
"SearchType": "0",
"WithSubsOnly": False,
}
if not is_movie:
query["SearchType"] = "1"
if year:
query["Year"] = year
# get the list of subtitles
logger.debug("Getting the list of subtitles")
url = self.server_url + self.search_url
r = self.session.post(
url, json={"request": query}, timeout=10
)
r.raise_for_status()
if r.content:
try:
responseContent = r.json()
except json.decoder.JSONDecodeError:
json.decoder.JSONDecodeError("Unable to parse JSON returned while getting Film/Series Information.")
else:
isSuccess = False
if 'd' in responseContent:
responseContent = json.loads(responseContent['d'])
results = responseContent.get('Films', [])
else:
json.decoder.JSONDecodeError("Incomplete JSON returned while getting Film/Series Information.")
else:
return {}
# loop over results
subtitles = {}
for result in results:
imdb_link = result["IMDB_Link"]
imdb_link = imdb_link[0: -1] if imdb_link.endswith("/") else imdb_link
results_imdb_id = imdb_link.split("/")[-1]
if results_imdb_id != imdb_id:
logger.debug(
"Subtitles is for IMDB %r but actual IMDB ID is %r",
results_imdb_id,
imdb_id,
)
continue
language = Language("heb")
hearing_impaired = False
ktuvit_id = result["ID"]
page_link = self.server_url + self.movie_info_url + ktuvit_id
if is_movie:
subs = self._search_movie(ktuvit_id)
else:
subs = self._search_tvshow(ktuvit_id, season, episode)
for sub in subs:
# otherwise create it
subtitle = KtuvitSubtitle(
language,
hearing_impaired,
page_link,
title,
season,
episode,
title,
imdb_id,
ktuvit_id,
sub["sub_id"],
sub["rls"],
)
logger.debug("Found subtitle %r", subtitle)
subtitles[sub["sub_id"]] = subtitle
return subtitles.values()
def _search_tvshow(self, id, season, episode):
subs = []
url = (
self.server_url
+ self.episode_info_url
+ "moduleName=SubtitlesList&SeriesID={}&Season={}&Episode={}".format(
id, season, episode
)
)
r = self.session.get(url, timeout=10)
r.raise_for_status()
sub_list = ParserBeautifulSoup(r.content, ["html.parser"])
sub_rows = sub_list.find_all("tr")
for row in sub_rows:
columns = row.find_all("td")
sub = {"id": id}
for index, column in enumerate(columns):
if index == 0:
sub['rls'] = column.get_text().strip().split("\n")[0]
if index == 5:
sub['sub_id'] = column.find("input", attrs={"data-sub-id": True})["data-sub-id"]
subs.append(sub)
return subs
def _search_movie(self, movie_id):
subs = []
url = self.server_url + self.movie_info_url + movie_id
r = self.session.get(url, timeout=10)
r.raise_for_status()
html = ParserBeautifulSoup(r.content, ["html.parser"])
sub_rows = html.select("table#subtitlesList tbody > tr")
for row in sub_rows:
columns = row.find_all("td")
sub = {
'id': movie_id
}
for index, column in enumerate(columns):
if index == 0:
sub['rls'] = column.get_text().strip().split("\n")[0]
if index == 5:
sub['sub_id'] = column.find("a", attrs={"data-subtitle-id": True})["data-subtitle-id"]
subs.append(sub)
return subs
def list_subtitles(self, video, languages):
season = episode = None
year = video.year
filename = video.name
imdb_id = video.imdb_id
if isinstance(video, Episode):
titles = [video.series] + video.alternative_series
season = video.season
episode = video.episode
imdb_id = video.series_imdb_id
else:
titles = [video.title] + video.alternative_titles
imdb_id = video.imdb_id
for title in titles:
subtitles = [
s
for s in self.query(title, season, episode, year, filename, imdb_id)
if s.language in languages
]
if subtitles:
return subtitles
return []
def download_subtitle(self, subtitle):
if isinstance(subtitle, KtuvitSubtitle):
downloadIdentifierRequest = {
"FilmID": subtitle.ktuvit_id,
"SubtitleID": subtitle.subtitle_id,
"FontSize": 0,
"FontColor": "",
"PredefinedLayout": -1,
}
logger.debug("Download Identifier Request data: " + str(json.dumps({"request": downloadIdentifierRequest})))
# download
url = self.server_url + self.request_download_id_url
r = self.session.post(
url, json={"request": downloadIdentifierRequest}, timeout=10
)
r.raise_for_status()
if r.content:
try:
responseContent = r.json()
except json.decoder.JSONDecodeError:
json.decoder.JSONDecodeError("Unable to parse JSON returned while getting Download Identifier.")
else:
isSuccess = False
if 'd' in responseContent:
responseContent = json.loads(responseContent['d'])
downloadIdentifier = responseContent.get('DownloadIdentifier', None)
if not downloadIdentifier:
json.decoder.JSONDecodeError("Missing Download Identifier.")
else:
json.decoder.JSONDecodeError("Incomplete JSON returned while getting Download Identifier.")
url = self.server_url + self.download_link + downloadIdentifier
r = self.session.get(url, timeout=10)
r.raise_for_status()
if not r.content:
logger.debug(
"Unable to download subtitle. No data returned from provider"
)
return
subtitle.content = fix_line_ending(r.content)

View File

@ -97,7 +97,7 @@ class TitrariProvider(Provider, ProviderSubtitleArchiveMixin):
languages = {Language(l) for l in ['ron', 'eng']} languages = {Language(l) for l in ['ron', 'eng']}
languages.update(set(Language.rebuild(l, forced=True) for l in languages)) languages.update(set(Language.rebuild(l, forced=True) for l in languages))
api_url = 'https://www.titrari.ro/' api_url = 'https://www.titrari.ro/'
query_advanced_search = 'cautaredevansata' query_advanced_search = 'cautarenedevansata'
def __init__(self): def __init__(self):
self.session = None self.session = None